diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 6e02835e4..3f377dc67 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -220,6 +220,9 @@ abandon it) · `4` usage. Details: `packages/queue/README.md`. ```bash scripts/mosaic business validate scripts/mosaic business resolve [--project ] +scripts/mosaic business instances [--json] +scripts/mosaic business instance add --definition [--vars ] [--launch] --revision +scripts/mosaic business instance set [--definition ] [--vars ] [--launch | --no-launch] --revision ``` Reads `businesses/.json` next to the system config @@ -234,6 +237,20 @@ Exit codes: `0` ok · `2` invalid file or credential reference · `3` system config problem · `4` usage or a required file missing. Details: `packages/business/README.md`. +`instances` and `instance add|set` (row 165) go to `packages/cli` and the +bus's human-only `instances` and `instances.set` verbs, so they need a live +bus host and refuse inside an agent session. `instances` prints the revision, +each instance and the definitions `add` and `set` accept. `add` and `set` +write the business file under that revision and log the change with you as +the actor. They bind no credentials: a definition that declares credentials, +or a definition change on an instance that holds them, is refused with +`organization-credentials-out-of-scope` and stays a hand edit. After an add +or a definition change the output says a bus restart is needed; the restart +needs Mos's go or Jason's hand, and the command doesn't run it. Exit codes: +`0` ok · `2` refused, nothing written · `3` human proof refused · `4` usage. +The Console's Settings page has the same two forms. Details: +`packages/cli/README.md`. + ## Inbox, decisions and the bus host (`scripts/mosaic`) ```bash diff --git a/docs/acceptance/314.md b/docs/acceptance/314.md index c652fc01c..3611cce31 100644 --- a/docs/acceptance/314.md +++ b/docs/acceptance/314.md @@ -25,9 +25,22 @@ bus schema. - `mosaic inbox` shows no open decision. If one is open, resolve or supersede it first, so step 6 starts from an empty inbox. - The business has security-reviewer and merge-gate instances, and both - are in `launch.instances`. No verb adds an instance today, so Jason adds - them by editing the business file, then runs - `scripts/mosaic business validate `. + are in `launch.instances`. Jason adds them with the row 165 command (or + the Console's Settings page), reading the revision first, then runs + `scripts/mosaic business validate `: + + ``` + scripts/mosaic business instances + scripts/mosaic business instance add security-reviewer --definition security-reviewer --launch --revision + scripts/mosaic business instances + scripts/mosaic business instance add merge-gate --definition merge-gate --launch --revision + scripts/mosaic business validate + ``` + + Each add prints the new revision, so the second add can take it instead + of a second read. Both adds say a bus restart is needed. The running bus + only learns the new instances at restart, which needs Mos's go or Jason's + hand. - `arbiters.technical` is `cto`, which step 5 assumes. ## Steps diff --git a/packages/bus/README.md b/packages/bus/README.md index 8518187dd..7867cdbe6 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -571,6 +571,20 @@ refuses `message-not-found`; malformed arguments refuse `invalid-request`. Roles get `human-required`. A successful read records nothing, so Console history reads never prevent a later `message.receive` from delivering it. +`instances` and `instances.set` (row 165, #1662, Q171, REQ-ROLE-3) are human-only. +A role session, a reader and a tester each get `human-required`, and the chief of staff +does too: neither verb is in `ORGANIZATION_KEYS`, so `organization.set` refuses the instance +keys with `invalid-request` before the writer runs (sage-0918). `instances` takes no args and +answers the business's instances, their definitions, vars and launch membership, plus the +definitions `instances.set` accepts. `instances.set` takes `{revision,instance,op,definition?, +vars?,launch?}`. `op` is `add` or `change`, and every other key is `invalid-request`. The +business package's `instance-settings.mjs` writes through the organization writer, which +logs the human as the actor. Its refusals pass through as `organization-*` codes, among them +`organization-credentials-out-of-scope` for a definition that declares credentials. The answer +carries `restart: true` after an add or a definition change: the broker keeps the roles it +loaded at start, so the change counts only after a restart that needs Mos's go or Jason's +hand. Tests: `packages/bus/tests/instances-verb.test.mjs`. + A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs ` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`. ## Shared reads and adapter boundary diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 48394fa67..76f7dfaea 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -134,6 +134,8 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test // Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept. // Row 155 (#1649) adds assistants: an assistant instance's vars, tracker and definition. export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants']; +// Row 165 (#1662): the keys of the human-only instances.set (business instance-settings.mjs). +const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch']; // Row 123 (#1617): role.end never reaches the caller's own run. END_PROTECTED (vocabulary.mjs, row // 155): only the chief of staff, or a decision the human resolved through the CLI, ends or revokes // an instance of one (#protectedEnd, Q169). A raise to revoke one is a hard wait. @@ -1154,15 +1156,18 @@ export class Broker { return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) }); if (PROJECT_FILE_VERBS.includes(request.verb)) return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) }); - if (["limits", "limits.set", "organization.set", "plugins", "plugins.set"].includes(request.verb)) { + if (["limits", "limits.set", "organization.set", "plugins", "plugins.set", "instances", "instances.set"].includes(request.verb)) { // Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose // file lists organization.change. It takes the same keys, runs the same write path and // logs the same receipt; only the actor differs: the role definition and instance. const role = request.verb === "organization.set"; if (!role && !s.human) fail("human-required"); if (!this.#organization) fail("not-configured"); - if (["limits", "plugins"].includes(request.verb)) keys(args, []); + if (["limits", "plugins", "instances"].includes(request.verb)) keys(args, []); else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]); + // Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so + // organization.set and the chief of staff's tool never reach it (sage-0918). + else if (request.verb === "instances.set") keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]); else keys(args, ORGANIZATION_KEYS, ["revision"]); let actor = s.human; // This branch runs outside the store transaction below, so the grant is checked before the writer diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index ae4a3070e..f226304f0 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -9,6 +9,7 @@ import { launchSettings } from '../../business/src/launch-settings.mjs'; import { readSessions } from '../../seat/src/session.mjs'; import { createLoader, hasModule } from '../../plugins/src/loader.mjs'; import { pluginSettings } from '../../business/src/plugin-settings.mjs'; +import { instanceSettings } from '../../business/src/instance-settings.mjs'; import { webService } from './web.mjs'; import { verifyHuman } from './human.mjs'; import { createUsage } from './usage.mjs'; @@ -66,6 +67,8 @@ export async function startBroker({ // plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused. const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null; const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot)}):null; + // Row 165 (#1662): the human adds and changes role instances through instances and instances.set. + const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system}):null; const usage = await createUsage({ store, dataRoot, businesses, log: entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`) }); // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at @@ -93,6 +96,8 @@ export async function startBroker({ if(verb==='limits')return withTokens(id,settings.read(id)); if(verb==='limits.set')return withTokens(id,settings.set(id,args,actor)); if(verb==='plugins')return plugins.read(id); + if(verb==='instances')return instances.read(id); + if(verb==='instances.set')return instances.set(id,args,actor); const result=plugins.set(id,args,actor); limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message})); return result; diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs index 4ea8746ec..797a120dd 100644 --- a/packages/bus/tests/channel-proof.test.mjs +++ b/packages/bus/tests/channel-proof.test.mjs @@ -11,7 +11,11 @@ const baselines = { "packages/bus/src/human.mjs": "b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573", "packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073", "packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774", - "packages/webui/src/bus.mjs": "d1f3a274f7aa15eb7ecc2267c65bf4fe7e45c898f77c34235b4e9c29cd1be241", // Row 155 (#1649): eight STATUS entries for the assistant routes, no proof or transport change + // Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists, + // organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured, + // organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or + // transport change. Row 155 (#1649) added eight for the assistant routes. + "packages/webui/src/bus.mjs": "dfbc2289337376b94a399a61599fd4af402b802deb2267dcffc5cf23502c9a4c", }; const repoFile = path => new URL("../../../" + path, import.meta.url); diff --git a/packages/bus/tests/instances-verb.test.mjs b/packages/bus/tests/instances-verb.test.mjs new file mode 100644 index 000000000..a4950b8e1 --- /dev/null +++ b/packages/bus/tests/instances-verb.test.mjs @@ -0,0 +1,91 @@ +// Row 165 (#1662, Q171, REQ-ROLE-3): instances and instances.set are human-only broker actions, +// logged with the verified actor. They stay outside ORGANIZATION_KEYS, so neither organization.set +// nor the chief of staff's change_organization reaches them (sage-0918). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Store } from '../src/store.mjs'; +import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs'; + +const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority; +const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; +const biz = () => ({ + demo: { + id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' }, definitions: {}, + roles: { chief: { definition: 'chief-of-staff', authority: chiefAuthority }, pm: { definition: 'pm', ...none } }, + projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true }, + }, +}); +const ADD = { revision: 'a'.repeat(64), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }; + +test('row 165: instances and instances.set refuse every non-human session, the chief of staff included, and pass the verified actor', (t) => { + const root = mkdtempSync(join(tmpdir(), 'instances-verb-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const calls = []; + const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) }); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const caps = { reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }) }; + for (const role of ['chief', 'pm']) { + caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' }); + b.request(caps[role], { verb: 'role.claim' }); + } + for (const [who, cap] of Object.entries(caps)) + for (const verb of ['instances', 'instances.set']) + assert.throws(() => b.request(cap, { verb, args: verb === 'instances' ? {} : ADD }), /human-required/, `${who} ${verb}`); + // The chief of staff holds organization.change, but organization.set takes ORGANIZATION_KEYS only: + // the instance keys are refused before the writer, so change_organization can't add an instance. + for (const key of ['instance', 'op', 'definition', 'launch']) assert.equal(ORGANIZATION_KEYS.includes(key), false, key); + for (const args of [ADD, { revision: ADD.revision, instance: 'pm', op: 'change', definition: 'merge-gate' }]) + assert.throws(() => b.request(caps.chief, { verb: 'organization.set', args }), /invalid-request/); + assert.equal(calls.length, 0); + assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, actor: 'forged' } }), /invalid-request/); + assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, holder: 'mos' } }), /invalid-request/); + assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, credentials: {} } }), /invalid-request/); + assert.throws(() => b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, op: 'add' } }), /invalid-request/); + assert.throws(() => b.request(human, { verb: 'instances', args: { x: 1 } }), /invalid-request/); + assert.equal(calls.length, 0); + b.request(human, { verb: 'instances' }); + b.request(human, { verb: 'instances.set', args: ADD }); + assert.deepEqual(calls, [['demo', 'instances', {}, 'jason'], ['demo', 'instances.set', ADD, 'jason']]); +}); + +test('row 165 runtime: an add and a change reach the organization file with the human as actor; each refusal keeps the bytes and logs nothing', async (t) => { + const { startBroker } = await import('../src/runtime.mjs'); + const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs'); + const root = mkdtempSync(join(tmpdir(), 'instances-runtime-')); + const rolesDir = rolesCopy(root); + writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo')); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, organization: { dir: root, rolesDir, system: systemFor(root) } }); + t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); + const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const file = join(root, 'businesses/demo.json'); + const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); + const view = bus.broker.request(human, { verb: 'instances' }); + assert.equal(view.instances.chief.launcher, true); + assert.equal(view.definitions['merge-gate'].accepted, true); + const added = bus.broker.request(human, { verb: 'instances.set', args: { ...ADD, revision: view.revision } }); + assert.equal(added.instances['sec-1'].launched, true); + assert.equal(added.restart, true); + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' }); + const first = log().at(-1); + assert.deepEqual([first.actor, first.state, first.transaction], ['jason', 'committed', added.transaction]); + const changed = bus.broker.request(human, { verb: 'instances.set', args: { revision: added.revision, instance: 'sec-1', op: 'change', definition: 'merge-gate' } }); + assert.deepEqual(log().at(-1).changes.map(c => [c.path, c.old, c.new]), [['roles.sec-1.definition', 'security-reviewer', 'merge-gate']]); + const before = readFileSync(file, 'utf8'); + const entries = log().length; + const code = (args) => { try { bus.broker.request(human, { verb: 'instances.set', args: { revision: changed.revision, ...args } }); } catch (e) { return e.code; } return null; }; + assert.equal(code({ instance: 'reviewer-2', op: 'add', definition: 'reviewer' }), 'organization-credentials-out-of-scope'); + assert.equal(code({ instance: 'sec-1', op: 'change', definition: 'coder' }), 'organization-credentials-out-of-scope'); + assert.equal(code({ instance: 'sec-1', op: 'add', definition: 'merge-gate' }), 'organization-instance-exists'); + assert.equal(code({ instance: 'ghost', op: 'change', vars: {} }), 'organization-unknown-instance'); + assert.equal(code({ instance: 'thread-2', op: 'add', definition: 'assistant' }), 'organization-definition-refused'); + assert.equal(code({ instance: 'chief', op: 'change', launch: true }), 'invalid-request'); + assert.equal(code({ instance: 'gate-2', op: 'add', definition: 'merge-gate', revision: view.revision }), 'revision-conflict'); + assert.equal(readFileSync(file, 'utf8'), before); + assert.equal(log().length, entries, 'a refused write logs nothing'); +}); diff --git a/packages/business/README.md b/packages/business/README.md index ead6538d1..62d243a28 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -357,3 +357,55 @@ For inherited selections, print-only migration reads the existing system config through its normal validator and requires an exact provider/model match. Missing or invalid config refuses; it never changes the system config. Explicit role models retain their external model and use the human-supplied provider table. + +### Role instances (row 165, #1662) + +Q171 and REQ-ROLE-3 let the human add a role instance and change an existing +one's definition, vars and launch membership without a hand edit. +`src/instance-settings.mjs` is that writer. The bus reaches it through the +human-only verbs `instances` and `instances.set`. Neither is in the broker's +`ORGANIZATION_KEYS`, so `organization.set` and the chief of staff's +`change_organization` can't reach it (sage-0918). + +`read` returns the file's revision, each instance (`definition`, `vars`, +`launched`, `launcher`, `protected`), whether the file has a launch block, and +each definition in the roles directory with the services its credentials name +and whether `add` and `set` accept it. `set` takes: + +```json +{ "revision": "<64 hex>", "instance": "sec-1", "op": "add", "definition": "security-reviewer", "vars": {}, "launch": true } +``` + +- `add` needs `definition`; `change` needs at least one of `definition`, + `vars` and `launch`. `vars` replaces the instance's vars whole. `launch` + adds the instance to `launch.instances` or removes it. Any other key is + `invalid-request`, so `holder`, `credentials` and `tracker` stay hand edits. + There is no delete. +- An instance named `human` or `tester` refuses with `invalid-request`. The + broker refuses a business whose roles use either name when it loads, so + such an add would stop the bus at its next start (row 165 r2). +- A definition that declares credentials refuses with + `organization-credentials-out-of-scope` (the brief's named refusal, sage-0922). + So does a definition change on an instance whose definition declares them, + because its credentials would bind to the new definition's authority. The + writer never writes an instance without its binding. A vars or launch change + on such an instance succeeds and leaves `credentials` and `tracker` as they + are. +- An `END_PROTECTED` definition, given or held, refuses with + `organization-definition-refused`; the assistant changes through + `assistants` above. +- `organization-instance-exists`, `organization-unknown-instance`, + `organization-launch-not-configured` and `organization-no-change` refuse + what their names say. +- The whole next document goes through `validateBusinessDocument`, the model + check, and each project file's id and instance resolution + (`organization-project-mismatch`, `organization-unresolvable-instance`), + the checks `mosaic business validate` runs. +- The organization writer then refuses a stale revision + (`revision-conflict`) and logs the change with the actor. + +A refusal writes nothing and logs nothing. The answer is the read with the +`transaction` and `restart`. `restart` is true after an add or a definition +change: the running broker keeps the roles it loaded at start. The writer +doesn't restart anything. The CLI (`mosaic business instance add|set`) and the +Console Settings page send the patch. diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs new file mode 100644 index 000000000..0a8262da4 --- /dev/null +++ b/packages/business/src/instance-settings.mjs @@ -0,0 +1,155 @@ +// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition, +// vars and launch membership. It is human-only: the broker's instances and instances.set verbs +// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918). +// holder, credentials and tracker stay hand edits, and there is no delete. Every write goes +// through the organization writer, which logs the actor and the old and new values. +import { existsSync, readdirSync } from "node:fs"; +import { organizationWriter, OrganizationError } from "./organization.mjs"; +import { validateBusinessDocument, businessFilePath } from "./business.mjs"; +import { loadProject, projectFilePath } from "./project.mjs"; +import { resolveInstance } from "./resolve.mjs"; +import { loadRole } from "./role.mjs"; +import { isPlainObject, canonicalJson } from "./util.mjs"; +import { END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs"; + +const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"]; +const id = value => typeof value === "string" && ID_PATTERN.test(value); + +// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no +// credential. An instance of one that declares credentials needs credentials and a tracker bot, +// which stay out of this row (sage-0918); the refusal names that, so a later row lifts it alone. +function definitionFor(rolesDir, name) { + if (END_PROTECTED.includes(name)) throw new OrganizationError("organization-definition-refused"); + const definition = loadRole(rolesDir, name); + if (definition.roleVersion !== 2) throw new OrganizationError("invalid-request", `role definition ${name} is version 1`); + if (definition.credentials.length > 0) { + throw new OrganizationError("organization-credentials-out-of-scope", + `${name} declares ${definition.credentials.map(c => c.service).join(" and ")} credentials; binding them is a hand edit`); + } + return definition; +} + +export function instanceSettings({ dir, rolesDir, system }) { + const writer = organizationWriter({ dir, rolesDir }); + + // The definitions in rolesDir and whether add and set accept each one. + function definitions() { + const out = {}; + for (const file of readdirSync(rolesDir).filter(f => f.endsWith(".json")).sort()) { + const name = file.slice(0, -5); + let definition; + try { + definition = loadRole(rolesDir, name); + } catch (e) { + if (e.name === "BusinessError") continue; + throw e; + } + if (definition.roleVersion !== 2) continue; + const services = definition.credentials.map(c => c.service); + out[name] = { services, accepted: !END_PROTECTED.includes(name) && services.length === 0 }; + } + return out; + } + + function read(business) { + const { document, revision } = writer.read(business); + validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir }); + const launch = document.launch ?? null; + const instances = Object.fromEntries(Object.entries(document.roles).map(([name, entry]) => [name, { + definition: entry.definition, + vars: entry.vars ?? {}, + launched: launch ? launch.instances.includes(name) : false, + launcher: launch ? launch.by === name : false, + protected: END_PROTECTED.includes(entry.definition), + }])); + return { business, revision, launch: launch !== null, instances, definitions: definitions() }; + } + + // args: { revision, instance, op: "add" | "change", definition?, vars?, launch?: boolean }. + // add needs definition; change needs at least one of definition, vars and launch. vars replaces + // the instance's vars whole. The answer's restart is true when the running bus must restart + // before the change counts: the broker holds the roles it loaded at start (broker.mjs bindLaunch). + function set(business, args, actor) { + if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) { + throw new OrganizationError("invalid-request"); + } + if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) { + throw new OrganizationError("invalid-request"); + } + const { instance, op } = args; + if (!id(instance) || (op !== "add" && op !== "change")) throw new OrganizationError("invalid-request"); + // The broker refuses a business whose roles name human or tester (broker.mjs, the load check), + // so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1). + if (instance === "human" || instance === "tester") { + throw new OrganizationError("invalid-request", `${instance} names the bus's own sessions, never a role instance`); + } + if (Object.hasOwn(args, "definition") && !id(args.definition)) throw new OrganizationError("invalid-request"); + if (Object.hasOwn(args, "vars") && !isPlainObject(args.vars)) throw new OrganizationError("invalid-request"); + if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request"); + + const old = writer.read(business).document; + const next = structuredClone(old); + const exists = Object.hasOwn(next.roles, instance); + let redefined = false; + if (op === "add") { + if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request"); + if (exists) throw new OrganizationError("organization-instance-exists"); + definitionFor(rolesDir, args.definition); + next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) }; + redefined = true; + } else { + if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request"); + if (!exists) throw new OrganizationError("organization-unknown-instance"); + const entry = next.roles[instance]; + // An assistant's settings change only through limits.set's assistants key (row 155), so + // nothing here touches a protected instance, its vars and launch membership included. + if (END_PROTECTED.includes(entry.definition)) throw new OrganizationError("organization-definition-refused"); + if (Object.hasOwn(args, "definition") && args.definition !== entry.definition) { + definitionFor(rolesDir, args.definition); + // The old definition's credentials would bind to the new one's authority. + if (loadRole(rolesDir, entry.definition).credentials.length > 0) { + throw new OrganizationError("organization-credentials-out-of-scope", + `${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`); + } + entry.definition = args.definition; + redefined = true; + } + if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars); + } + if (Object.hasOwn(args, "launch")) { + if (!next.launch) throw new OrganizationError("organization-launch-not-configured"); + const listed = next.launch.instances; + if (!args.launch) next.launch.instances = listed.filter(name => name !== instance); + else if (!listed.includes(instance)) listed.push(instance); + } + if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change"); + + // The checks mosaic business validate runs, before the writer publishes anything. + const proposed = validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir }); + if (proposed.launch) { + for (const name of Object.keys(proposed.roles)) { + const vars = resolveInstance({ business: proposed, system, instance: name }).vars; + if (!Object.hasOwn(proposed.launch.models, vars.model ?? vars["execution.model"])) { + throw new OrganizationError("unknown-model"); + } + } + } + for (const [project, entry] of Object.entries(proposed.projects)) { + if (!existsSync(projectFilePath(entry.root))) continue; + const loaded = loadProject(entry.root); + if (loaded.id !== project) throw new OrganizationError("organization-project-mismatch"); + for (const name of Object.keys(proposed.roles)) { + try { + resolveInstance({ system, business: proposed, project: loaded, instance: name }); + } catch (e) { + if (e.name === "BusinessError") throw new OrganizationError("organization-unresolvable-instance", e.message); + throw e; + } + } + } + const receipt = writer.write(business, next, { revision: args.revision, actor }); + return { ...read(business), transaction: receipt.transaction, restart: redefined }; + } + + return { writer, read, set }; +} diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs new file mode 100644 index 000000000..3a50b56c4 --- /dev/null +++ b/packages/business/tests/instance-settings.test.mjs @@ -0,0 +1,188 @@ +// Row 165 (#1662, Q171, REQ-ROLE-3): the human adds a role instance and changes one's definition, +// vars and launch membership through the organization writer. holder, credentials and tracker +// stay hand edits; a refused write leaves the file's bytes alone. +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { instanceSettings } from "../src/instance-settings.mjs"; +import { projectFilePath } from "../src/project.mjs"; +import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs"; + +const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); +const roots = []; +after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true }))); + +// The business file sits where mosaic business validate looks for it, so the test can run it. +function fixture(mutate) { + const root = tmp(); + roots.push(root); + const rolesDir = rolesCopy(root); + const doc = businessDoc(root, "acme"); + mutate?.(doc, root); + const dir = join(root, "config"); + const file = writeJson(join(dir, "businesses", "acme.json"), doc); + const config = systemConfig(root); + const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) }); + const validate = () => spawnSync(process.execPath, [CLI, "validate", "acme"], { + env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir }, encoding: "utf8", + }); + const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + return { root, rolesDir, doc, file, settings, validate, log }; +} + +// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged. +function refuses(f, args, code) { + const bytes = readFileSync(f.file, "utf8"); + const revision = f.settings.read("acme").revision; + assert.throws(() => f.settings.set("acme", { revision, ...args }, "jason"), + e => (code === "BusinessError" ? e.name === code : e.code === code), JSON.stringify(args)); + assert.equal(readFileSync(f.file, "utf8"), bytes, JSON.stringify(args)); +} + +test("row 165 read lists each instance and the definitions add accepts", () => { + const f = fixture(doc => { doc.roles.assistant = { definition: "assistant", holder: "mos" }; }); + const view = f.settings.read("acme"); + assert.match(view.revision, /^[a-f0-9]{64}$/); + assert.equal(view.launch, true); + assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false }); + assert.equal(view.instances.chief.launcher, true); + assert.equal(view.instances.assistant.protected, true); + assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true }); + assert.deepEqual(view.definitions.coder, { services: ["gitea", "vikunja"], accepted: false }); + assert.equal(view.definitions.assistant.accepted, false); + assert.equal(Object.hasOwn(view.definitions, "researcher"), false); + assert.equal(Object.hasOwn(view.definitions, "conductor-policy"), false); +}); + +test("row 165 add: two instances join, validate exits 0, the log names the human, and the bus restart is reported", () => { + const f = fixture(); + const view = f.settings.read("acme"); + const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason"); + assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false }); + assert.notEqual(added.revision, view.revision); + assert.match(added.transaction, /\S/); + assert.equal(added.restart, true); + const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason"); + assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false }); + const file = JSON.parse(readFileSync(f.file, "utf8")); + assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" }); + assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]); + const run = f.validate(); + assert.equal(run.status, 0, run.stderr); + assert.ok(JSON.parse(run.stdout).instances["sec-1"]); + const [first, second] = f.log().filter(e => e.state === "committed"); + assert.equal(first.actor, "jason"); + assert.equal(first.transaction, added.transaction); + assert.ok(first.changes.some(c => c.path === "roles.sec-1" && c.oldPresent === false && c.new.definition === "security-reviewer")); + assert.ok(first.changes.some(c => c.path === "launch.instances" && c.new.includes("sec-1") && !c.old.includes("sec-1"))); + assert.equal(second.actor, "jason"); +}); + +test("row 165 change: definition, vars and launch membership log before and after; only a definition change asks for a restart", () => { + const f = fixture(doc => { doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; }); + const redefined = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "sec-1", op: "change", definition: "merge-gate" }, "jason"); + assert.equal(redefined.instances["sec-1"].definition, "merge-gate"); + assert.equal(redefined.restart, true); + let entry = f.log().at(-1); + assert.ok(entry.changes.some(c => c.path === "roles.sec-1.definition" && c.old === "security-reviewer" && c.new === "merge-gate")); + const revarred = f.settings.set("acme", { revision: redefined.revision, instance: "sec-1", op: "change", vars: { harness: "claude-code", model: "claude-opus-5-5" } }, "jason"); + assert.deepEqual(revarred.instances["sec-1"].vars, { harness: "claude-code", model: "claude-opus-5-5" }); + assert.equal(revarred.restart, false); + entry = f.log().at(-1); + assert.ok(entry.changes.some(c => c.path === "roles.sec-1.vars.harness" && c.old === "pi" && c.new === "claude-code")); + assert.ok(entry.changes.some(c => c.path === "roles.sec-1.vars.model" && c.oldPresent === false && c.new === "claude-opus-5-5")); + const launched = f.settings.set("acme", { revision: revarred.revision, instance: "sec-1", op: "change", launch: true }, "jason"); + assert.equal(launched.instances["sec-1"].launched, true); + assert.equal(launched.restart, false); + const dropped = f.settings.set("acme", { revision: launched.revision, instance: "reviewer", op: "change", launch: false }, "jason"); + assert.deepEqual(JSON.parse(readFileSync(f.file, "utf8")).launch.instances, ["coder", "sec-1"]); + assert.deepEqual(f.log().at(-1).changes.find(c => c.path === "launch.instances"), + { path: "launch.instances", old: ["coder", "reviewer", "sec-1"], new: ["coder", "sec-1"], oldPresent: true, newPresent: true }); + assert.equal(f.settings.read("acme").revision, dropped.revision); + assert.equal(f.validate().status, 0); +}); + +test("row 165 refuses each validation failure and leaves the file's bytes alone", () => { + const f = fixture(doc => { doc.roles["sec-1"] = { definition: "security-reviewer" }; }); + refuses(f, { instance: "pm", op: "add", definition: "merge-gate" }, "organization-instance-exists"); + refuses(f, { instance: "ghost", op: "change", vars: {} }, "organization-unknown-instance"); + refuses(f, { instance: "sec-2", op: "add", definition: "no-such-role" }, "BusinessError"); + refuses(f, { instance: "sec-2", op: "add", definition: "researcher" }, "invalid-request"); + refuses(f, { instance: "sec-2", op: "add", definition: "merge-gate", vars: { "no.such.var": 1 } }, "BusinessError"); + refuses(f, { instance: "sec-2", op: "add", definition: "merge-gate", vars: { model: "not-in-the-table" } }, "BusinessError"); + refuses(f, { instance: "chief", op: "change", launch: true }, "BusinessError"); + refuses(f, { instance: "chief", op: "change", definition: "merge-gate" }, "BusinessError"); + refuses(f, { instance: "coder", op: "change", launch: true }, "organization-no-change"); + refuses(f, { instance: "sec-1", op: "change", definition: "security-reviewer" }, "organization-no-change"); + for (const bad of [ + { instance: "Bad Name", op: "add", definition: "merge-gate" }, { instance: "sec-2", op: "remove" }, { instance: "sec-2", op: "add" }, + { instance: "sec-1", op: "change" }, { instance: "sec-2", op: "add", definition: "merge-gate", holder: "mos" }, + { instance: "sec-2", op: "add", definition: "merge-gate", credentials: {} }, { instance: "sec-2", op: "add", definition: "merge-gate", tracker: {} }, + { instance: "sec-2", op: "add", definition: "merge-gate", vars: [] }, { instance: "sec-2", op: "add", definition: "merge-gate", launch: "yes" }, + ]) refuses(f, bad, "invalid-request"); + // Dewey F1: the broker refuses a business whose roles name human or tester, so the writer does too. + refuses(f, { instance: "human", op: "add", definition: "merge-gate" }, "invalid-request"); + refuses(f, { instance: "tester", op: "add", definition: "merge-gate" }, "invalid-request"); + const one = fixture(doc => { doc.launch.instances = ["coder"]; }); + refuses(one, { instance: "coder", op: "change", launch: false }, "BusinessError"); + const none = fixture(doc => { delete doc.launch; }); + refuses(none, { instance: "sec-2", op: "add", definition: "merge-gate", launch: true }, "organization-launch-not-configured"); +}); + +// mos-0823: no instance is written without its credential binding, and nothing binds one here. +test("row 165 refuses an added instance whose definition declares credentials, and says why", () => { + const f = fixture(); + refuses(f, { instance: "reviewer-2", op: "add", definition: "reviewer" }, "organization-credentials-out-of-scope"); + assert.throws(() => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "reviewer-2", op: "add", definition: "reviewer" }, "jason"), + /reviewer declares gitea and vikunja credentials/); +}); + +test("row 165 refuses a definition change to or from one that declares credentials", () => { + const f = fixture(doc => { doc.roles["sec-1"] = { definition: "security-reviewer" }; }); + refuses(f, { instance: "sec-1", op: "change", definition: "coder" }, "organization-credentials-out-of-scope"); + refuses(f, { instance: "reviewer", op: "change", definition: "security-reviewer" }, "organization-credentials-out-of-scope"); + refuses(f, { instance: "reviewer", op: "change", definition: "cto" }, "organization-credentials-out-of-scope"); +}); + +test("row 165 a vars or launch change on an instance with its binding succeeds and keeps the binding", () => { + const f = fixture(); + const before = JSON.parse(readFileSync(f.file, "utf8")).roles.coder; + const done = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "coder", op: "change", vars: { harness: "claude-code" }, launch: false }, "jason"); + assert.equal(done.restart, false); + const after = JSON.parse(readFileSync(f.file, "utf8")).roles.coder; + assert.deepEqual(after, { ...before, vars: { harness: "claude-code" } }); + assert.deepEqual(after.credentials, before.credentials); + assert.deepEqual(after.tracker, before.tracker); + assert.equal(f.log().at(-1).changes.some(c => c.path.startsWith("roles.coder.credentials") || c.path.startsWith("roles.coder.tracker")), false); + assert.equal(f.validate().status, 0); +}); + +test("row 165 refuses an END_PROTECTED definition and every change to a protected instance", () => { + const f = fixture(doc => { doc.roles.assistant = { definition: "assistant", holder: "mos" }; doc.roles["sec-1"] = { definition: "security-reviewer" }; }); + refuses(f, { instance: "assistant-2", op: "add", definition: "assistant" }, "organization-definition-refused"); + refuses(f, { instance: "sec-1", op: "change", definition: "assistant" }, "organization-definition-refused"); + refuses(f, { instance: "assistant", op: "change", definition: "merge-gate" }, "organization-definition-refused"); + refuses(f, { instance: "assistant", op: "change", vars: { model: "claude-opus-5-5" } }, "organization-definition-refused"); + refuses(f, { instance: "assistant", op: "change", launch: true }, "organization-definition-refused"); +}); + +test("row 165 refuses while a project file doesn't resolve, as validate would", () => { + const ghost = fixture((doc, root) => writeJson(projectFilePath(join(root, "project")), { projectVersion: 1, id: "stack", roles: { ghost: { vars: {} } } })); + refuses(ghost, { instance: "sec-2", op: "add", definition: "merge-gate" }, "organization-unresolvable-instance"); + const misnamed = fixture((doc, root) => writeJson(projectFilePath(join(root, "project")), { projectVersion: 1, id: "other" })); + refuses(misnamed, { instance: "sec-2", op: "add", definition: "merge-gate" }, "organization-project-mismatch"); + const good = fixture((doc, root) => writeJson(projectFilePath(join(root, "project")), { projectVersion: 1, id: "stack", roles: { coder: { vars: {} } } })); + const done = good.settings.set("acme", { revision: good.settings.read("acme").revision, instance: "sec-2", op: "add", definition: "merge-gate" }, "jason"); + assert.equal(done.instances["sec-2"].definition, "merge-gate"); +}); + +test("row 165 refuses a stale revision and leaves the file's bytes alone", () => { + const f = fixture(); + const stale = f.settings.read("acme").revision; + f.settings.set("acme", { revision: stale, instance: "sec-1", op: "add", definition: "security-reviewer" }, "jason"); + const bytes = readFileSync(f.file, "utf8"); + assert.throws(() => f.settings.set("acme", { revision: stale, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason"), e => e.code === "revision-conflict"); + assert.equal(readFileSync(f.file, "utf8"), bytes); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index 38a2ac7a4..b32a8d3e2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -854,6 +854,57 @@ mappings refuse. Every held role must map too. A migrated file prints unchanged. Jason installs the output himself in Mos's window with the PM run idle, and the re-pin follows in the same window (decision 145). +## Role instances + +Row 165 (#1662, Q171, REQ-ROLE-3). + +``` +mosaic business instances BUSINESS [--json] +mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV +mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV +``` + +`scripts/mosaic` sends `business instances` and `business instance` here and +every other `business` command to `packages/business`. `instances` prints the +file's revision, each instance with its definition and its marks (`launcher`, +`launched`, `protected`), the definitions `add` and `set` accept, and the +definitions that declare credentials. `add` and `set` send the bus's +human-only `instances.set` verb. `set` needs at least one of `--definition`, +`--vars` and a launch flag. `--vars FILE` is a JSON object that replaces the +instance's vars whole. `--launch` and `--no-launch` add the instance to +`launch.instances` or remove it. + +The business package's instance writer runs the checks `mosaic business +validate` runs before it writes, and logs the change in `changes.jsonl` with +the human as actor. It refuses, and the file keeps its bytes: + +| Code | When | +|---|---| +| `organization-credentials-out-of-scope` | the definition declares credentials, or a definition change on an instance that holds them | +| `organization-instance-exists` | `add` names an instance the file has | +| `organization-unknown-instance` | `set` names an instance the file lacks | +| `organization-definition-refused` | an assistant definition or instance; those change through `mosaic limits set` | +| `organization-launch-not-configured` | a launch flag when the file has no launch block | +| `organization-no-change` | the file already says that | +| `organization-project-mismatch`, `organization-unresolvable-instance` | the change would fail `mosaic business validate` against a project file | +| `revision-conflict` | the file changed since `--revision` | + +The bus sends a refusal's code alone, so the command adds the reason. A +refusal exits 2; an agent session or a non-human transport exits 3; a usage +error exits 4 before any bus call. + +The commands bind no credentials. A definition that declares them needs +`credentials` and a `tracker` bot in the instance, and those, with `holder`, +stay hand edits of the business file. A vars or launch change on an instance +that holds credentials succeeds and leaves them as they are. There is no +delete. + +An add or a definition change prints `restart needed`: the running bus keeps +the roles it loaded at start; this command does not restart it. The output +names no one; the restart itself needs Mos's go or Jason's hand. A vars or launch change +prints `no restart`, because the launcher reads it at its next launch check. +The Console Settings page has the same read and write (`packages/webui`). + ## Token usage `mosaic usage [--business ID] [--json]` reads the bus's accounting, without diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index a56a1b118..6a4f68be6 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -17,6 +17,7 @@ import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } f import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs"; import { limitsCommand, LIMITS_USAGE } from "./limits.mjs"; import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs"; +import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs"; import { createChannelCaller } from "../../plugins/src/channel.mjs"; import { PluginError } from "../../plugins/src/kinds.mjs"; import { CliError } from "./errors.mjs"; @@ -37,6 +38,7 @@ export const USAGE = `usage: ${USAGE_USAGE} ${LIMITS_USAGE} ${PLUGINS_USAGE} + ${INSTANCES_USAGE} ${PROJECT_USAGE} ${PROJECT_BUS_USAGE} ${THREADS_USAGE} @@ -568,6 +570,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps)); if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps)); if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps)); + // Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here. + if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps)); if (verb === "talk") return talk(rest, io, deps); if (verb === "follow") return talk(rest, io, deps, true); if (verb === "name" || verb === "names") return names(verb, rest, io, deps); diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs new file mode 100644 index 000000000..e64cb17ae --- /dev/null +++ b/packages/cli/src/instances.mjs @@ -0,0 +1,121 @@ +// `mosaic business instances` and `mosaic business instance add|set` (row 165, #1662, Q171, +// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's human-only +// instances and instances.set verbs. The broker refuses an agent, a reader and a tester; the +// business package's instance writer refuses a stale revision and every document +// `mosaic business validate` would refuse, and logs the change with its actor. +import { readFileSync } from "node:fs"; +import { CliError } from "./errors.mjs"; + +export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] | " + + "business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV | " + + "business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV"; + +// The bus answers a refusal with its code alone, so the reason a human reads is here. +export const REASONS = { + "organization-credentials-out-of-scope": "this command binds no credentials, so it refuses a definition that declares them " + + "and a definition change on an instance that holds them; that stays a hand edit of the business file", + "organization-instance-exists": "the business already has that instance; change it with `mosaic business instance set`", + "organization-unknown-instance": "the business has no such instance; add it with `mosaic business instance add`", + "organization-definition-refused": "this command neither adds nor changes an assistant, and moves no instance to the assistant " + + "definition; an assistant changes only through `mosaic limits set` with an assistants patch", + "organization-launch-not-configured": "the business file has no launch block, so launch membership can't change", + "organization-no-change": "the business file already says that", + "organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it", + "organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why", + "revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`", +}; + +export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it"; +const NO_RESTART = "no restart: the launcher reads the change at its next launch check"; + +function parse(args) { + const out = { positional: [], flags: new Set(), values: {} }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (["--json", "--launch", "--no-launch"].includes(arg)) { + if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4); + out.flags.add(arg); + } else if (["--definition", "--vars", "--revision"].includes(arg)) { + if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4); + out.values[arg] = args[++i]; + } else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4); + else out.positional.push(arg); + } + return out; +} + +// The args instances.set takes, from `instance add|set`. A usage error is exit 4 before any call. +function setArgs(parsed) { + const [, op, , instance] = parsed.positional; + const { values, flags } = parsed; + const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined; + if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") || + (flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) || + (op === "set" && !values["--definition"] && !values["--vars"] && launch === undefined)) { + throw new CliError(INSTANCES_USAGE, 4); + } + const args = { revision: values["--revision"], instance, op: op === "add" ? "add" : "change" }; + if (values["--definition"]) args.definition = values["--definition"]; + if (values["--vars"]) { + try { + args.vars = JSON.parse(readFileSync(values["--vars"], "utf8")); + } catch (error) { + throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4); + } + } + if (launch !== undefined) args.launch = launch; + return args; +} + +export function formatInstances(view) { + const lines = [`business ${view.business}, revision ${view.revision}`]; + for (const [name, entry] of Object.entries(view.instances)) { + const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean); + const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : ""; + lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`); + } + const definitions = Object.entries(view.definitions); + const accepted = definitions.filter(([, d]) => d.accepted).map(([name]) => name); + const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`); + lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`); + if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`); + return `${lines.join("\n")}\n`; +} + +// args are the words after `mosaic business`. callFor(parsed) returns the human's call(verb, args). +export async function instancesCommand(args, io, callFor) { + const parsed = parse(args); + const sub = parsed.positional[0]; + const business = parsed.positional[sub === "instances" ? 1 : 2]; + if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4); + let request; + if (sub === "instances") { + if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) { + throw new CliError(INSTANCES_USAGE, 4); + } + } else request = setArgs(parsed); + try { + const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } }); + if (!request) { + const view = await call("instances", {}); + return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view)); + } + const result = await call("instances.set", request); + const entry = result.instances[request.instance]; + io.stdout.write([ + `business ${result.business}: ${request.op === "add" ? "added" : "changed"} ${request.instance} (${entry.definition}${entry.launched ? ", launched" : ""})`, + `revision ${result.revision}`, + `transaction ${result.transaction}`, + result.restart ? RESTART_NEEDED : NO_RESTART, + ].join("\n") + "\n"); + } catch (error) { + const code = error.code; + if (Object.hasOwn(REASONS, code)) { + const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2); + refused.code = code; + throw refused; + } + if (error instanceof CliError) throw error; + throw new CliError(error.message, 2); + } +} diff --git a/packages/cli/tests/instances.test.mjs b/packages/cli/tests/instances.test.mjs new file mode 100644 index 000000000..eecff8fbf --- /dev/null +++ b/packages/cli/tests/instances.test.mjs @@ -0,0 +1,154 @@ +// Row 165 (#1662, Q171, REQ-ROLE-3): `mosaic business instances` and `mosaic business instance +// add|set` reach the broker's human-only verbs, which write through the business package's +// instance writer. The bus here is a real startBroker over a temporary organization directory. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BusError } from "../../bus/src/broker.mjs"; +import { startBroker } from "../../bus/src/runtime.mjs"; +import { systemFor, writeJson } from "../../business/tests/helpers.mjs"; +import { main, run } from "../src/cli.mjs"; +import { RESTART_NEEDED } from "../src/instances.mjs"; +import { fixture, io, tmp } from "./helpers.mjs"; + +const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url)); +const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url)); + +async function setup(t) { + const root = tmp(t, "mosaic-cli-instances-"); + const f = fixture(root, "acme"); + mkdirSync(f.dataRoot, { mode: 0o700 }); + const dir = join(root, "config"); + const bus = await startBroker({ + dataRoot: f.dataRoot, + businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, + organization: { dir, rolesDir: f.roles, system: systemFor(root) }, + }); + t.after(() => bus.close()); + const human = bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }); + const calls = []; + const deps = { + system: { dataRoot: f.dataRoot }, + transport: (business) => async (verb, args = {}) => { + calls.push({ business, verb, args }); + try { + return structuredClone(bus.broker.request(human, { verb, args })); + } catch (e) { + if (!(e instanceof BusError)) throw e; + const error = new Error(e.code); + error.code = e.code; + throw error; + } + }, + }; + const file = join(dir, "businesses", "acme.json"); + const log = () => readFileSync(join(dir, "businesses", ".history", "acme", "changes.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l)); + const validate = () => spawnSync(process.execPath, [BUSINESS_CLI, "validate", "acme"], { + env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.roles }, encoding: "utf8", + }); + const mosaic = async (args, env = f.env) => { + const x = io(env); + const code = await run(["business", ...args], x, deps); + return { code, out: x.out.text, err: x.err.text }; + }; + const revision = async () => JSON.parse((await mosaic(["instances", "acme", "--json"])).out).revision; + return { root, f, file, log, validate, mosaic, revision, calls }; +} + +test("row 165 CLI: instance add puts an instance in the file, validate exits 0, and the log names the human", async (t) => { + const s = await setup(t); + const view = await s.mosaic(["instances", "acme"]); + assert.equal(view.code, 0, view.err); + assert.match(view.out, /^business acme, revision [a-f0-9]{64}$/m); + assert.match(view.out, /^ {2}chief: chief-of-staff \(launcher\)$/m); + assert.match(view.out, /^definitions add and set accept: .*\bmerge-gate\b.*\bsecurity-reviewer\b/m); + assert.match(view.out, /^definitions that declare credentials, a hand edit: .*\breviewer \(gitea, vikunja\)/m); + const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]); + assert.equal(added.code, 0, added.err); + const entry = s.log().at(-1); + const now = await s.revision(); + assert.deepEqual(added.out.split("\n"), [ + "business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`, RESTART_NEEDED, "", + ]); + assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it"); + assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)"); + assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it"); + const doc = JSON.parse(readFileSync(s.file, "utf8")); + assert.deepEqual(doc.roles["sec-1"], { definition: "security-reviewer" }); + assert.ok(doc.launch.instances.includes("sec-1")); + assert.deepEqual([entry.actor, entry.state], ["jason", "committed"]); + const checked = s.validate(); + assert.equal(checked.status, 0, checked.stderr); +}); + +test("row 165 CLI: instance set passes the vars file and --no-launch, and a vars-only change needs no restart", async (t) => { + const s = await setup(t); + const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" }); + const changed = await s.mosaic(["instance", "set", "acme", "coder", "--vars", vars, "--no-launch", "--revision", await s.revision()]); + assert.equal(changed.code, 0, changed.err); + assert.deepEqual(s.calls.at(-1).args, { revision: s.calls.at(-1).args.revision, instance: "coder", op: "change", vars: { harness: "pi", model: "claude-sonnet-5-5" }, launch: false }); + assert.match(changed.out, /^business acme: changed coder \(coder\)$/m); + assert.match(changed.out, /^no restart: the launcher reads the change at its next launch check$/m); + const doc = JSON.parse(readFileSync(s.file, "utf8")); + assert.equal(doc.launch.instances.includes("coder"), false); + assert.ok(doc.roles.coder.credentials.gitea && doc.roles.coder.tracker, "a vars change keeps the binding"); + assert.equal(s.validate().status, 0); +}); + +test("row 165 CLI: a refusal names its code and why, exits 2 and keeps the bytes", async (t) => { + const s = await setup(t); + const rev = await s.revision(); + const before = readFileSync(s.file, "utf8"); + const cases = [ + [["instance", "add", "acme", "reviewer-2", "--definition", "reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: this command binds no credentials/], + [["instance", "set", "acme", "coder", "--definition", "security-reviewer", "--revision", rev], /^mosaic: organization-credentials-out-of-scope: /], + [["instance", "add", "acme", "coder", "--definition", "merge-gate", "--revision", rev], /^mosaic: organization-instance-exists: /], + [["instance", "set", "acme", "ghost", "--launch", "--revision", rev], /^mosaic: organization-unknown-instance: /], + [["instance", "add", "acme", "asst-2", "--definition", "assistant", "--revision", rev], /^mosaic: organization-definition-refused: this command neither adds nor changes an assistant, and moves no instance to the assistant definition;/], + [["instance", "add", "acme", "gate-1", "--definition", "merge-gate", "--revision", "b".repeat(64)], /^mosaic: revision-conflict: the business file changed/], + ]; + for (const [args, message] of cases) { + const r = await s.mosaic(args); + assert.equal(r.code, 2, args.join(" ")); + assert.match(r.err, message); + } + assert.equal(readFileSync(s.file, "utf8"), before); +}); + +test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session is refused", async (t) => { + const s = await setup(t); + const rev = "a".repeat(64); + for (const args of [ + ["instances"], ["instances", "acme", "extra"], ["instances", "acme", "--launch"], + ["instance", "add", "acme", "sec-1", "--definition", "security-reviewer"], + ["instance", "add", "acme", "sec-1", "--revision", rev], + ["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--no-launch", "--revision", rev], + ["instance", "set", "acme", "coder", "--revision", rev], + ["instance", "set", "acme", "coder", "--launch", "--no-launch", "--revision", rev], + ["instance", "set", "acme", "coder", "--vars", join(s.root, "missing.json"), "--revision", rev], + ["instance", "remove", "acme", "coder", "--revision", rev], + ["instance", "set", "acme", "coder", "--holder", "mos", "--revision", rev], + ]) { + const r = await s.mosaic(args); + assert.equal(r.code, 4, args.join(" ")); + } + assert.equal(s.calls.length, 0); + const agent = await s.mosaic(["instances", "acme"], { ...s.f.env, CODEX_THREAD_ID: "fixture-agent" }); + assert.equal(agent.code, 3); + assert.match(agent.err, /agent/i); + assert.equal(s.calls.length, 0); + await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/); +}); + +test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => { + const root = tmp(t, "mosaic-cli-route-"); + const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") }; + const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" }); + assert.equal(routed.status, 4); + assert.match(routed.stderr, /mosaic business instances BUSINESS/); + const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" }); + assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/); +}); diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index 9a2b8ee6c..fd5d5ef03 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -143,6 +143,20 @@ test("a role without role.end or organization.change gets neither tool, and each assert.deepEqual(names(typedTools({ resolved: only("organization.change") })).filter((n) => n === "end_run" || n === "change_organization"), ["change_organization"]); }); +// Row 165 (#1662, sage-0918): instances and instances.set are the human's. No role's tools send +// either verb, and change_organization takes none of the instance write's keys, so the chief of +// staff can't add or change an instance through it. +test("no role's tools send instances or instances.set, and change_organization takes no instance write keys", (t) => { + const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" })); + for (const [who, resolved] of [...["chief", "pm", "coder", "reviewer"].map((i) => [i, resolvedFor(t, i)]), ["assistant", assistant]]) { + const tools = typedTools({ resolved, arbiter: true, tracker: true }); + assert.deepEqual(tools.filter((x) => x.verb === "instances" || x.verb === "instances.set").map((x) => x.name), [], who); + assert.equal(untypedActions(resolved, tools).some((a) => a.startsWith("instances")), false, who); + } + const change = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true }).find((x) => x.name === "change_organization"); + for (const key of ["instance", "op", "definition", "vars", "launch"]) assert.equal(Object.hasOwn(change.parameters.properties, key), false, key); +}); + // Row 119 gave the assistant message.send; row 138 adds record.write. test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => { const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" })); diff --git a/packages/webui/README.md b/packages/webui/README.md index 6bfc117dd..49146d119 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -594,6 +594,41 @@ Any other method is 405. An answer is the bus's result with `at`. A bus refusal names the route, the user and the instance, and the outcome: `done` or the code. A Console started inside an agent run gets `human-required` from the broker. +## Role instances (row 165, #1662) + +Q171 (REQ-ROLE-3) lets the human add a role instance and change one's definition, vars and +launch membership from the Console. Settings shows a Role instances section, read through +`/api/bus/instances`: a table of each instance with its definition, whether it is launched +(or is the launcher) and its vars, then two forms. + +- Add an instance: a name, a definition from the ones the writer accepts, vars as JSON and, + when the file has a launch block, a Launch it box. +- Change an instance: any instance except an assistant, which changes in the Assistant + section. The definition, the vars (replacing them whole) and launch can change; a field + left as Keep or empty is not sent. + +Definitions that declare credentials are not offered. The section names them, with their +services, as hand edits of the business file, because the Console binds no credentials. +Each form sends one POST `/api/instances` under the revision the page read. When the answer +says a bus restart is needed, the receipt says so and that the Console does not restart it; +the page has no restart control. Without `--send` every control is off. A +refusal shows the code and the reason and keeps the draft; a lost answer says to read the +instances again. Nothing is sent again on its own. + +Route: POST `/api/instances` exists only under `--send` and calls the human-only +`instances.set` through the human transport. It has the Settings boundary: the signed-in +user's header (401 without it), the exact `https://` Origin and +`Sec-Fetch-Site: same-origin` (403), and a JSON object of at most 16 KiB (400). It takes +exactly `revision` (64 hex), `instance`, `op` (`add` or `change`) and any of `definition`, +`vars` (an object) and `launch` (a boolean); any other key, `holder`, `credentials` and +`tracker` included, is 400 before the bus. Any other method is 405. An answer is the bus's +result with `at`. The bus sends a refusal's code alone, so the route adds the reason: +`{error, message, sent:false}`, where the message is the code, the reason and "Nothing +changed." `revision-conflict` and `organization-instance-exists` are 409, +`human-required` 403 and the other refusals 400. A lost or unusable answer is 503 with +`sent:"unknown"`. The log line names the user, the instance and the outcome (`done +restart=true|false` or the code), never the vars. + ## Data and boundaries GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST @@ -607,6 +642,7 @@ session reader or transport is implemented here. The session reader is GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`, `/api/bus/trail?subject=`, `/api/bus/decisions?list=roles|recent`, `/api/bus/credentials`, `/api/bus/names` (row 155, each instance's seat name), +`/api/bus/instances` (row 165, the role instances and the definitions `add` accepts), `/api/bus/replies?id=` and `/api/bus/messages?limit=50&after=` are the bus reads (`src/bus.mjs`). Any other method is 405. The subject and the id must diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs index 6c61dc52d..fee95a829 100644 --- a/packages/webui/src/bus.mjs +++ b/packages/webui/src/bus.mjs @@ -96,5 +96,9 @@ const STATUS = { // is the user's to resolve by reading again. 'organization-not-assistant': 400, 'organization-definition-refused': 400, 'unknown-model': 400, 'name-taken': 409, 'name-is-instance': 400, 'name-is-role': 400, 'not-assistant': 400, 'not-held': 409, + // Row 165 (#1662): role instances. An id already taken is the user's to resolve by reading again. + 'organization-instance-exists': 409, 'organization-unknown-instance': 400, + 'organization-credentials-out-of-scope': 400, 'organization-launch-not-configured': 400, + 'organization-no-change': 400, 'organization-project-mismatch': 400, 'organization-unresolvable-instance': 400, }; export const busStatus = code => STATUS[code] ?? 502; diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 57bfb6b9a..7d7cf3d5c 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -52,18 +52,19 @@ try { // the system config, Business can't resolve authority and Settings has // no notifier. A refusal printed here names no path (redactor). // --send without a bus refuses to start rather than serve a composer that can't send. - let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, busLine, system = null; + let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, busLine, system = null; try { system = loadSystem(); const { dataRoot } = system; const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); chat = createChat({ call }); - // Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. - bus = { ...views({ call }), names: () => call('names') }; + // Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165 + // (#1662): so is `instances`, and its write `instances.set` is only under --send. + bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances') }; // Row 148: the spec and docs editor reads like the other reads and saves only under --send. projectFiles = args => call('project.files', args); projectFile = args => call('project.file', args); - if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);} + if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);} busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`; } catch (err) { const why = redactor({ configDir: configDir() })(err.message); @@ -71,7 +72,7 @@ try { busLine = `Bus: not configured (${why})`; } const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); - const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop }); + const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite }); const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : ''; const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : ''; console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 40c6b6758..876dbad8a 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -671,17 +671,70 @@ await assistantPost('/api/name',{instance,name},instance,result=>{if(typeof result?.name!=='string')return unknownAssistant;delete nameDrafts[instance];const prior=last.get('names');if(prior)last.set('names',{...prior,rows:[...prior.rows.filter(r=>r.instance!==result.instance),{instance:result.instance,name:result.name,at:result.at}]});return `Seat name set to ${result.name}.`;});} else if(e.target.dataset.assistantStop!==undefined)await assistantPost('/api/assistant-stop',{instance},instance,result=>Array.isArray(result?.stopped)?`Stopped ${plural(result.stopped.length,'run')}.`:unknownAssistant); }); + // Row 165 (#1662, Q171): role instances. Add one, or change one's definition, vars and launch + // membership, with the read's revision. A definition that declares credentials is named but not + // offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt + // says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries. + let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusy=false, instanceReceipt=''; + const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'; + const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.'; + function instancesSection(view){ + const title='

Role instances

'; + if(!view)return `
${title}

The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.

`; + if(!instanceDrafts.add&&!instanceDrafts.change)instanceView=view; + const v=instanceView, disabled=!canSend||out()||instanceBusy?' disabled':''; + const defs=Object.entries(v.definitions), accepted=defs.filter(([,d])=>d.accepted).map(([n])=>n); + const bound=defs.filter(([,d])=>Array.isArray(d.services)&&d.services.length).map(([n,d])=>`${txt(n)} (${d.services.map(txt).join(', ')})`); + const changeable=Object.entries(v.instances).filter(([,e])=>!e.protected).map(([n])=>n); + const rows=Object.entries(v.instances).map(([name,e])=>`${txt(name)}${txt(e.definition)}${e.launcher?'launcher':e.launched?'yes':'no'}${e.vars&&Object.keys(e.vars).length?`${txt(JSON.stringify(e.vars))}`:'none'}`).join(''); + const opts=(list,sel,keep)=>`${keep?``:''}${list.map(n=>``).join('')}`; + const a=instanceDrafts.add||{}, c=instanceDrafts.change||{}; + const addLaunch=v.launch?``:''; + const changeLaunch=v.launch?``:''; + const addForm=`
Add an instance
${addLaunch}
`; + const changeForm=changeable.length?`
Change an instance
${changeLaunch}
`:''; + return `
${title}

Add an instance or change one's definition, vars and launch membership. Holder, credentials and tracker stay hand edits, and so does an instance of a definition that declares credentials. An assistant changes in the Assistant section.

${rows}
InstanceDefinitionLaunchedVars
${addForm}${changeForm}${bound.length?`

Definitions that declare credentials, a hand edit: ${bound.join(', ')}.

`:''}

${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}

${txt(instanceReceipt)}

`; + } + const instanceFields=form=>Object.fromEntries([...form.querySelectorAll('[data-instance-field]')].map(el=>[el.dataset.instanceField,el.type==='checkbox'?el.checked:el.value])); + function instancePayload(op,f){ + const payload={revision:instanceView.revision,instance:String(f.instance??'').trim(),op}; + if(!payload.instance)throw Error('Name the instance.'); + if(f.definition)payload.definition=f.definition; + if(op==='add'&&!payload.definition)throw Error('Choose a definition.'); + if(String(f.vars??'').trim()!==''){let parsed;try{parsed=JSON.parse(f.vars);}catch{throw Error('Vars must be JSON.');}if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Vars must be a JSON object.');payload.vars=parsed;} + if(op==='add'&&f.launch===true)payload.launch=true; + if(op==='change'&&(f.launch==='yes'||f.launch==='no'))payload.launch=f.launch==='yes'; + if(op==='change'&&!['definition','vars','launch'].some(k=>Object.hasOwn(payload,k)))throw Error('Nothing changed.'); + return payload; + } + const unknownInstance='Outcome unknown. Read the role instances again before trying. Draft kept.'; + // The answer replaces the view the next render draws, so it must have the read's whole shape. + const instanceAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&typeof r.restart==='boolean'&&[r.instances,r.definitions].every(o=>o&&typeof o==='object'&&!Array.isArray(o)); + for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form)instanceDrafts[form.dataset.op]=instanceFields(form);}); + document.addEventListener('click',e=>{if(e.target.id!=='instances-reload')return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';last.delete('instances');render(true);}); + document.addEventListener('submit',async e=>{ + const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||instanceBusy||!instanceView)return; + const op=form.dataset.op;instanceDrafts[op]=instanceFields(form);let payload; + try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;render(false);return;} + instanceBusy=true;instanceReceipt='';render(false); + try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); + if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;} + else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}} + catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);} + }); async function settingsView(get) { const s = await get.api('settings'), n = s.notifier || {}; let usage, names = null; try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { names = await get('names'); } catch { /* the section says the name could not be read */ } + let instances = null; + try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } let limitRows = [], limitsRefused = false; try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } const notify = typeof n.binding === 'string' ? `
Binding
${txt(n.binding)}

The binding name only. Its channels, server and user ids stay in the binding file.

` : empty('No notifier binding to show.', `${n.refused ? `${txt(n.refused)}: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`); - return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)} + return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}

Notifications

${notify}

About

Address
${txt(s.address) || 'unknown'}
Release
${s.release ? `${txt(s.release)}` : 'unknown: no RELEASE file was read'}
Business
${txt(s.business) || none}
Board
${txt(s.board) || 'unknown'}
`; } diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 1c7c2edcd..5276cf086 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -53,8 +53,9 @@ async function body(req, max = 4096) { // (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81 // (#1575) adds `replies`: the messages answering one message, for the send receipt. Row 125 // (#1619, REQ-LIMIT-3) adds `limits`: only the token limits' report, never the document. Row 155 -// (#1649) adds `names`: each instance's current seat name, for the Assistant section. -const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names']; +// (#1649) adds `names`: each instance's current seat name, for the Assistant section. Row 165 +// (#1662) adds `instances`: the business file's role instances and the definitions add accepts. +const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances']; const DECISION_LISTS = ['roles', 'recent']; async function busRead(res, bus, verb, search) { let arg; @@ -82,6 +83,18 @@ async function busRead(res, bus, verb, search) { return json(res, busStatus(code), { error: code, message: code }); } } + if (verb === 'instances') { + if (search) return json(res, 400, { error: 'invalid-request', message: 'instances takes no arguments' }); + if (!bus?.instances) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' }); + try { + const value = await bus.instances(); + if (!/^[a-f0-9]{64}$/.test(value?.revision ?? '') || !value.instances || typeof value.instances !== 'object' || !value.definitions || typeof value.definitions !== 'object') throw new BusReadError('invalid-response'); + return json(res, 200, { rows: [value], at: new Date().toISOString() }); + } catch (err) { + const code = err instanceof BusReadError ? err.code : 'invalid-response'; + return json(res, busStatus(code), { error: code, message: code }); + } + } if (verb === 'trail') { arg = new URLSearchParams(search).get('subject'); if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' }); @@ -255,6 +268,55 @@ async function seatSend(req, res, { route, write, preview, log }) { line('done'); return json(res, 200, { ...result, at: new Date().toISOString() }); } +// Row 165 (#1662, Q171): the human adds a role instance or changes one's definition, vars and +// launch membership. The Settings boundary (header user, exact Origin, same-origin Fetch Metadata, +// JSON within 16 KiB, strict keys) and one bus call, `instances.set`, through the human transport. +// The writer refuses a definition that declares credentials. The answer's `restart` says whether +// the running bus must restart before the change counts; the Console never restarts it. +const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch']; +// The bus answers a refusal with its code alone, so the reason the page shows is here. +const INSTANCE_REASONS = { + 'organization-credentials-out-of-scope': 'that definition declares credentials, or the instance holds them. The Console binds no credentials, so that stays a hand edit of the business file', + 'organization-instance-exists': 'the business already has that instance; change it instead', + 'organization-unknown-instance': 'the business has no such instance; add it instead', + 'organization-definition-refused': 'this form neither adds nor changes an assistant, and moves no instance to the assistant definition; an assistant changes only in the Assistant section', + 'organization-launch-not-configured': 'the business file has no launch block, so launch membership can\'t change', + 'organization-no-change': 'the business file already says that', + 'organization-project-mismatch': 'a project file\'s id differs from its key in the business file; mosaic business validate shows it', + 'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic business validate shows why', + 'revision-conflict': 'the business file changed since you read it. Read the role instances again', +}; +async function instanceSend(req, res, { instancesWrite, preview, log }) { + let instance = null; + // The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged. + const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); + const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); }; + if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false }); + if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin'); + if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata'); + let value; + try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); } + const has = key => Object.hasOwn(value, key); + if (Object.keys(value).some(key => !INSTANCE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) || + typeof value.instance !== 'string' || !['add', 'change'].includes(value.op) || (has('definition') && typeof value.definition !== 'string') || + (has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) { + return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change, and a definition, vars or launch, nothing else'); + } + ({ instance } = value); + let result; + try { result = await instancesWrite(Object.fromEntries(INSTANCE_KEYS.filter(has).map(k => [k, value[k]]))); } catch (err) { + const { code, sent } = sendOutcome(err); + return sent === false + ? refuse(busStatus(code), code, `${code}: ${Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : 'refused by the bus'}. Nothing changed.`) + : refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the role instances again before trying', 'unknown'); + } + const object = o => o && typeof o === 'object' && !Array.isArray(o); + if (!/^[a-f0-9]{64}$/.test(result?.revision ?? '') || typeof result.restart !== 'boolean' || !object(result.definitions) || !object(result.instances) || !result.instances[instance]) { + return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the role instances again before trying', 'unknown'); + } + line(`done restart=${result.restart}`); + return json(res, 200, { ...result, at: new Date().toISOString() }); +} // Row 148 (#1642): the spec and docs editor. Two reads and one save, all through the human transport. // The save has the Settings boundary (header user, exact Origin, same-origin Fetch Metadata, JSON, // strict keys) with a 1 MiB body on this route only; every other POST keeps its own smaller limit. @@ -313,7 +375,7 @@ async function projectFileRead(res, read, path, search) { // `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log` // takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // tests; the field table is otherwise the committed packages/tasks/fields.json. -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, log = line => console.log(line), fieldsFile = undefined } = {}) { +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) { if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (host === 'localhost') host = '127.0.0.1'; const upstream = boardURL(board); @@ -359,6 +421,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!send || !write || !named) return json(res, 403, { error: 'read-only', sent: false }); return seatSend(req, res, { route: SEAT_ROUTES[path], write, preview, log }); } + if (path === '/api/instances') { + if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); + if (!send || !instancesWrite || !named) return json(res, 403, { error: 'read-only', sent: false }); + return instanceSend(req, res, { instancesWrite, preview, log }); + } if (path === '/api/project-file' && req.method === 'POST') { if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false }); return projectFileSend(req, res, { projectSave, preview, log }); diff --git a/packages/webui/tests/instances-browser.test.mjs b/packages/webui/tests/instances-browser.test.mjs new file mode 100644 index 000000000..e58e2d2cd --- /dev/null +++ b/packages/webui/tests/instances-browser.test.mjs @@ -0,0 +1,97 @@ +// Row 165 (#1662, Q171, REQ-ROLE-3): Settings shows the role instances, offers only the +// definitions add and set accept, names the ones that declare credentials as hand edits, and posts +// one add or change with the read's revision. The receipt names a needed bus restart and offers no +// way to run it. A refusal keeps the draft. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {browser} from './browser.mjs'; +import {startServer} from '../src/serve.mjs'; +import {close} from './fixture.mjs'; + +const hostile=''; +const REV='a'.repeat(64); +const view={business:'acme',revision:REV,launch:true, + instances:{chief:{definition:'chief-of-staff',vars:{},launched:false,launcher:true,protected:false},coder:{definition:'coder',vars:{note:hostile},launched:true,launcher:false,protected:false},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true}}, + definitions:{assistant:{services:[],accepted:false},coder:{services:['gitea'],accepted:false},'merge-gate':{services:[],accepted:true},reviewer:{services:['gitea','vikunja'],accepted:false},'security-reviewer':{services:[],accepted:true}}}; + +test('row 165 Settings: role instances read, add with a restart named, change without one, a refusal keeps the draft',{timeout:90000},async()=>{ + // The server's read follows each write, so a poll between steps shows what the stub answered. + let current=view; + const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>current}; + const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser(); + try{ + // The stub answers each POST with the next queued reply and records the body. + await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/instances'&&options.method==='POST'){posts.push(JSON.parse(options.body));const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}return original(url,options);};`}); + const port=web.address().port; + const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); + const receipt=()=>b.evaluate('document.querySelector("#instances-receipt").textContent'); + const set=(form,field,value)=>b.evaluate(`(()=>{const e=document.querySelector('#${form} [data-instance-field="${field}"]');if(e.type==='checkbox')e.checked=${JSON.stringify(value)};else e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`); + // Read-only: the table, the accepted definitions only, the hand-edit line, every control disabled. + await b.navigate(`http://127.0.0.1:${port}/#/settings`); + await wait('!!document.querySelector("#instances-table")'); + assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instances-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].map(c=>c.textContent))"),[ + ['chief','chief-of-staff','launcher','none'],['coder','coder','yes',JSON.stringify({note:hostile})],['assistant','assistant','no','none'], + ]); + assert.equal(await b.evaluate('window.injected===true'),false); + assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instance-add-form [data-instance-field=definition] option')].map(o=>o.value)"),['merge-gate','security-reviewer']); + assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instance-change-form [data-instance-field=instance] option')].map(o=>o.value)"),['chief','coder'],'an assistant changes in the Assistant section'); + assert.match(await b.evaluate('document.querySelector("#instances-title").parentElement.textContent'),/Definitions that declare credentials, a hand edit: coder \(gitea\), reviewer \(gitea, vikunja\)\./); + assert.equal(await b.evaluate('[...document.querySelectorAll(".instance-form button,.instance-form input,.instance-form select,.instance-form textarea")].every(e=>e.disabled)'),true); + + // Send mode: an add posts the read's revision and the fields, and the receipt names the restart. + await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`); + await wait('document.querySelector("#instance-add-form button")?.disabled===false'); + await set('instance-add-form','instance','sec-1'); + await set('instance-add-form','definition','security-reviewer'); + await set('instance-add-form','launch',true); + current={...view,revision:'b'.repeat(64)}; + await b.evaluate(`replies.push([200,${JSON.stringify({...current,transaction:'t-1',restart:true})}]);true`); + await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true'); + await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Added")'); + assert.deepEqual(await b.evaluate('posts.at(-1)'),{revision:REV,instance:'sec-1',op:'add',definition:'security-reviewer',launch:true}); + assert.equal(await receipt(),"Added sec-1. Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it."); + assert.equal(await b.evaluate('[...document.querySelectorAll("button")].some(e=>/restart/i.test(e.textContent))'),false,'the page offers no restart'); + + // A change sends the new revision and only what changed; a launch-only change needs no restart. + await set('instance-change-form','instance','coder'); + await set('instance-change-form','launch','no'); + current={...view,revision:'c'.repeat(64)}; + await b.evaluate(`replies.push([200,${JSON.stringify({...current,transaction:'t-2',restart:false})}]);true`); + await b.evaluate('document.querySelector("#instance-change-form").requestSubmit();true'); + await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Changed")'); + assert.deepEqual(await b.evaluate('posts.at(-1)'),{revision:'b'.repeat(64),instance:'coder',op:'change',launch:false}); + assert.equal(await receipt(),'Changed coder. No restart: the launcher reads the change at its next launch check.'); + + // Nothing chosen is caught on the page; a refusal shows the server's reason and keeps the draft. + await b.evaluate('document.querySelector("#instance-change-form").requestSubmit();true'); + await wait('document.querySelector("#instances-receipt")?.textContent==="Nothing changed."'); + assert.equal(await b.evaluate('posts.length'),2); + await set('instance-add-form','instance','gate-1'); + await set('instance-add-form','vars','{"model":"x"}'); + await b.evaluate(`replies.push([400,{error:'organization-credentials-out-of-scope',message:'organization-credentials-out-of-scope: that definition declares credentials. Nothing changed.',sent:false}]);true`); + await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true'); + await wait('document.querySelector("#instances-receipt")?.textContent.endsWith("Draft kept.")'); + assert.match(await receipt(),/^organization-credentials-out-of-scope: that definition declares credentials\. Nothing changed\. Draft kept\.$/); + assert.deepEqual(await b.evaluate('posts.at(-1).vars'),{model:'x'}); + assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=instance]").value'),'gate-1'); + assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=vars]").value'),'{"model":"x"}'); + + // mos-0825 F1: a 200 without the view's definitions leaves the view as it was, keeps the draft and + // says to read again, and Settings still draws. + await b.evaluate(`replies.push([200,${JSON.stringify({revision:'d'.repeat(64),restart:true,instances:{'gate-1':{definition:'merge-gate',vars:{}}}})}]);true`); + await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true'); + await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Outcome unknown")'); + assert.equal(await receipt(),'Outcome unknown. Read the role instances again before trying. Draft kept.'); + assert.equal(await b.evaluate('posts.length'),4); + assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=instance]").value'),'gate-1'); + assert.equal(await b.evaluate('document.querySelectorAll("#instances-table tbody tr").length'),3); + + // An unread view is one section's warning, not the page's empty state, and offers no form. + // The same URL with a hash would be a same-document navigation, so reload the page. + current=null; + await b.evaluate('location.reload();true'); + await wait('!!document.querySelector("#instances-title")?.parentElement.querySelector(".warn")'); + assert.equal(await b.evaluate('document.querySelector("#instances-title").parentElement.querySelector(".warn").textContent'),'The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.'); + assert.equal(await b.evaluate('document.querySelectorAll("#instances-title ~ .empty, .instance-form").length'),0); + }finally{await b.close();await close(web);} +}); diff --git a/packages/webui/tests/instances.test.mjs b/packages/webui/tests/instances.test.mjs new file mode 100644 index 000000000..fb1c5c428 --- /dev/null +++ b/packages/webui/tests/instances.test.mjs @@ -0,0 +1,173 @@ +// Row 165 (#1662, Q171, REQ-ROLE-3): the Console's role instance read and write. The write has the +// Settings boundary and makes one human-transport call, `instances.set`; the broker's and the +// writer's own checks are in the bus and business tests. The first test runs a real broker over a +// temporary organization directory, so the add reaches the file, the log and `validate`. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { startServer } from '../src/serve.mjs'; +import { BusReadError } from '../src/bus.mjs'; +import { BusError } from '../../bus/src/broker.mjs'; +import { startBroker } from '../../bus/src/runtime.mjs'; +import { systemFor } from '../../business/tests/helpers.mjs'; +import { fixture, tmp } from '../../cli/tests/helpers.mjs'; + +const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url)); +const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; +const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; +const REV = 'a'.repeat(64); +const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra }); + +async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: true }), bus = null } = {}) { + const calls = [], lines = []; + const server = await startServer({ + port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null, + instancesWrite: writer ? async args => { calls.push(args); return write(args); } : null, + }); + t.after(() => new Promise(r => server.close(r))); + return { calls, lines, port: server.address().port }; +} +const send = (s, path, data, hs = headers, method = 'POST') => new Promise((resolve, reject) => { + const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => { + let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null })); + }); + req.on('error', reject); + req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data)); +}); + +test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0 and the log names the human', async t => { + const root = tmp(t, 'webui-instances-'); + const f = fixture(root, 'acme'); + mkdirSync(f.dataRoot, { mode: 0o700 }); + const dir = join(root, 'config'); + const host = await startBroker({ + dataRoot: f.dataRoot, + businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }, + organization: { dir, rolesDir: f.roles, system: systemFor(root) }, + }); + t.after(() => host.close()); + const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); + // The human transport's shape: an answer, or a BusReadError with the broker's code. + const call = async (verb, args) => { + try { return structuredClone(host.broker.request(human, { verb, args })); } catch (e) { + if (e instanceof BusError) throw new BusReadError(e.code); + throw e; + } + }; + const s = await setup(t, { bus: { instances: () => call('instances') }, write: args => call('instances.set', args) }); + const file = join(dir, 'businesses', 'acme.json'); + const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); + const read = await send(s, '/api/bus/instances', undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET'); + assert.equal(read.status, 200); + const [current] = read.body.rows; + assert.equal(current.definitions['security-reviewer'].accepted, true); + assert.deepEqual(current.definitions.reviewer, { services: ['gitea', 'vikunja'], accepted: false }); + + const added = await send(s, '/api/instances', { revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }); + assert.equal(added.status, 200, JSON.stringify(added.body)); + assert.equal(added.body.restart, true); + assert.equal(added.body.instances['sec-1'].launched, true); + assert.deepEqual(s.calls, [{ revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }]); + assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=true$/); + const entry = log().at(-1); + assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', added.body.transaction]); + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' }); + const checked = spawnSync(process.execPath, [BUSINESS_CLI, 'validate', 'acme'], { + env: { PATH: process.env.PATH, HOME: join(root, 'home'), MOSAIC_CONFIG: f.config, MOSAIC_ROLES_DIR: f.roles }, encoding: 'utf8', + }); + assert.equal(checked.status, 0, checked.stderr); + + // A definition that declares credentials, a definition change on an instance holding them and a + // stale revision: each refused with its code and why, nothing written, nothing logged. + const before = readFileSync(file, 'utf8'), entries = log().length; + for (const [args, status, code, why] of [ + [{ revision: added.body.revision, instance: 'reviewer-2', op: 'add', definition: 'reviewer' }, 400, 'organization-credentials-out-of-scope', /declares credentials, or the instance holds them\. The Console binds no credentials/], + [{ revision: added.body.revision, instance: 'coder', op: 'change', definition: 'security-reviewer' }, 400, 'organization-credentials-out-of-scope', /hand edit of the business file/], + [{ revision: current.revision, instance: 'gate-1', op: 'add', definition: 'merge-gate' }, 409, 'revision-conflict', /changed since you read it/], + [{ revision: added.body.revision, instance: 'asst-2', op: 'add', definition: 'assistant' }, 400, 'organization-definition-refused', /^organization-definition-refused: this form neither adds nor changes an assistant, and moves no instance to the assistant definition;/], + ]) { + const r = await send(s, '/api/instances', args); + assert.deepEqual([r.status, r.body.error, r.body.sent], [status, code, false], JSON.stringify(args)); + assert.match(r.body.message, why); + assert.match(r.body.message, /Nothing changed\.$/); + } + assert.equal(readFileSync(file, 'utf8'), before); + assert.equal(log().length, entries); +}); + +test('row 165 Console: the instance write needs send on, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => { + const data = { revision: REV, instance: 'gate-1', op: 'add', definition: 'merge-gate' }; + const off = await setup(t, { on: false }); + assert.equal((await send(off, '/api/instances', data)).status, 403); + // A writer without send on still refuses, so the route itself checks send. + const unsent = await setup(t, { on: false, writer: true }); + assert.equal((await send(unsent, '/api/instances', data)).status, 403); + assert.equal(unsent.calls.length, 0); + const s = await setup(t); + for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) { + const hs = { ...headers }; + if (value === undefined) delete hs[key]; else hs[key] = value; + assert.equal((await send(s, '/api/instances', data, hs)).status, status, key); + } + assert.equal((await send(s, '/api/instances', undefined, headers, 'GET')).status, 405); + assert.equal(s.calls.length, 0); +}); + +test('row 165 Console: the instance write takes JSON within 16 KiB and only its own keys; holder, credentials and an actor are refused', async t => { + const s = await setup(t); + const base = { revision: REV, instance: 'gate-1', op: 'add', definition: 'merge-gate' }; + for (const value of [ + { ...base, actor: 'mos' }, { ...base, holder: 'mos' }, { ...base, credentials: {} }, { ...base, tracker: {} }, + { ...base, revision: 'stale' }, { ...base, op: 'remove' }, { ...base, instance: 7 }, { ...base, definition: 7 }, + { ...base, vars: [] }, { ...base, vars: null }, { ...base, launch: 'yes' }, [], 'bad', { ...base, vars: { pad: 'x'.repeat(17000) } }, + ]) assert.equal((await send(s, '/api/instances', value)).status, 400, JSON.stringify(value).slice(0, 60)); + assert.equal((await send(s, '/api/instances', base, { ...headers, 'content-type': 'text/plain' })).status, 400); + assert.equal(s.calls.length, 0); + assert.ok(s.lines.every(l => !l.includes('17000') && !l.includes('xxxx')), 'vars are never logged'); +}); + +test('row 165 Console: a bus refusal says nothing changed; a lost or odd answer says the change may have been made', async t => { + const fail = code => async () => { throw new BusReadError(code); }; + for (const [write, status, error, sent] of [ + [fail('human-required'), 403, 'human-required', false], [fail('organization-instance-exists'), 409, 'organization-instance-exists', false], + [fail('organization-unknown-instance'), 400, 'organization-unknown-instance', false], [fail('invalid-request'), 400, 'invalid-request', false], + [fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], [async () => { throw new Error('lost'); }, 503, 'outcome-unknown', 'unknown'], + [async () => ({ revision: REV, instances: {} }), 503, 'outcome-unknown', 'unknown'], + // mos-0825 F1: the page draws the answer as its next view, so one without definitions is odd. + [async () => ({ revision: REV, restart: true, instances: { 'gate-1': {} } }), 503, 'outcome-unknown', 'unknown'], + [async () => ({ revision: REV, restart: true, instances: { 'gate-1': {} }, definitions: [] }), 503, 'outcome-unknown', 'unknown'], + // Dewey F2: each other part of the shape wrong alone. + [async () => ({ revision: 'x', restart: true, instances: { 'gate-1': {} }, definitions: {} }), 503, 'outcome-unknown', 'unknown'], + [async () => ({ revision: REV, restart: 'yes', instances: { 'gate-1': {} }, definitions: {} }), 503, 'outcome-unknown', 'unknown'], + [async () => ({ revision: REV, restart: true, instances: {}, definitions: {} }), 503, 'outcome-unknown', 'unknown'], + ]) { + const s = await setup(t, { write }); + const r = await send(s, '/api/instances', { revision: REV, instance: 'gate-1', op: 'add', definition: 'merge-gate' }); + assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent]); + assert.equal(s.calls.length, 1); + assert.match(s.lines[0], new RegExp(` instance user="jason" instance="gate-1" error=${error}$`)); + } +}); + +test('row 165 Console: the instances read takes no arguments, needs a bus and refuses an answer without a revision', async t => { + const get = (s, path) => send(s, path, undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET'); + const none = await setup(t); + assert.equal((await get(none, '/api/bus/instances')).status, 503); + const s = await setup(t, { bus: { instances: async () => view() } }); + assert.equal((await get(s, '/api/bus/instances?x=1')).status, 400); + assert.deepEqual((await get(s, '/api/bus/instances')).body.rows, [view()]); + const odd = await setup(t, { bus: { instances: async () => ({ ...view(), revision: 'x' }) } }); + assert.equal((await get(odd, '/api/bus/instances')).status, 502); + // Dewey F2 note: an answer without instances or without definitions is refused the same way. + for (const drop of ['instances', 'definitions']) { + const { [drop]: _, ...rest } = view(); + const partial = await setup(t, { bus: { instances: async () => rest } }); + assert.equal((await get(partial, '/api/bus/instances')).status, 502, drop); + } + const refused = await setup(t, { bus: { instances: async () => { throw new BusReadError('human-required'); } } }); + assert.equal((await get(refused, '/api/bus/instances')).status, 403); +}); diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs index 45e09b774..4ca035457 100644 --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -52,11 +52,12 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () = ]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`); // No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes: // row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path, - // to the same Settings route) and row 148's spec and docs save. + // to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role + // instance add or change to /api/instances. assert.doesNotMatch(src, /]*>[^<]*(Resolve|Decide|Approve)/i); const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? []; - assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); - assert.equal(read('bus.js').toString().split(/method:/).length - 1, 5); + assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); + assert.equal(read('bus.js').toString().split(/method:/).length - 1, 6); // Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal. assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'"]); assert.equal(read('icons.svg').toString().includes('