Files
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

1698 lines
124 KiB
Diff

diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 46a3084cc..bf469748d 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -221,6 +221,9 @@ abandon it) · `4` usage. Details: `packages/queue/README.md`.
```bash
scripts/mosaic business validate <business>
scripts/mosaic business resolve <business> <instance> [--project <id>]
+scripts/mosaic business instances <business> [--json]
+scripts/mosaic business instance add <business> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
+scripts/mosaic business instance set <business> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
```
Reads `businesses/<business>.json` next to the system config
@@ -235,6 +238,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 <business>`.
+ 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 <business>`:
+
+ ```
+ scripts/mosaic business instances <business>
+ scripts/mosaic business instance add <business> security-reviewer --definition security-reviewer --launch --revision <rev>
+ scripts/mosaic business instances <business>
+ scripts/mosaic business instance add <business> merge-gate --definition merge-gate --launch --revision <rev>
+ scripts/mosaic business validate <business>
+ ```
+
+ 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 <socket>` 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 5b8385acd..61167ddff 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.
@@ -1200,15 +1202,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 3971bde8e..4df4406a4 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 20f3fe739..e8b4c31d8 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -899,6 +899,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://<public host>` 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=<id>`, `/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=<message id>` and
`/api/bus/messages?limit=50&after=<message id>` 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='<h2 id="instances-title">Role instances</h2>';
+ if(!view)return `<section class="panel" aria-labelledby="instances-title">${title}<p class="small warn">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.</p></section>`;
+ 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])=>`<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launcher?'launcher':e.launched?'yes':'no'}</td><td>${e.vars&&Object.keys(e.vars).length?`<code>${txt(JSON.stringify(e.vars))}</code>`:'<span class="muted">none</span>'}</td></tr>`).join('');
+ const opts=(list,sel,keep)=>`${keep?`<option value="">${keep}</option>`:''}${list.map(n=>`<option value="${esc(n)}"${n===sel?' selected':''}>${txt(n)}</option>`).join('')}`;
+ const a=instanceDrafts.add||{}, c=instanceDrafts.change||{};
+ const addLaunch=v.launch?`<label><input type="checkbox" data-instance-field="launch"${a.launch?' checked':''}${disabled}> Launch it</label>`:'';
+ const changeLaunch=v.launch?`<label>Launch <select data-instance-field="launch"${disabled}>${[['','Keep'],['yes','Launch it'],['no','Don\'t launch it']].map(([x,l])=>`<option value="${x}"${(c.launch??'')===x?' selected':''}>${l}</option>`).join('')}</select></label>`:'';
+ const addForm=`<form id="instance-add-form" class="instance-form" data-op="add"><fieldset><legend>Add an instance</legend><div class="filters"><label>Instance <input data-instance-field="instance" value="${esc(a.instance??'')}" maxlength="64"${disabled}></label><label>Definition <select data-instance-field="definition"${disabled}>${opts(accepted,a.definition)}</select></label>${addLaunch}</div><label>Vars (JSON, empty for none) <textarea data-instance-field="vars" rows="3"${disabled}>${esc(a.vars??'')}</textarea></label><button type="submit"${disabled}>Add instance</button></fieldset></form>`;
+ const changeForm=changeable.length?`<form id="instance-change-form" class="instance-form" data-op="change"><fieldset><legend>Change an instance</legend><div class="filters"><label>Instance <select data-instance-field="instance"${disabled}>${opts(changeable,c.instance)}</select></label><label>Definition <select data-instance-field="definition"${disabled}>${opts(accepted,c.definition,'Keep the definition')}</select></label>${changeLaunch}</div><label>Vars (JSON, replaces them whole; empty keeps them) <textarea data-instance-field="vars" rows="3"${disabled}>${esc(c.vars??'')}</textarea></label><button type="submit"${disabled}>Save instance</button></fieldset></form>`:'';
+ return `<section class="panel" aria-labelledby="instances-title">${title}<p>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.</p><div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table id="instances-table"><thead><tr><th scope="col">Instance</th><th scope="col">Definition</th><th scope="col">Launched</th><th scope="col">Vars</th></tr></thead><tbody>${rows}</tbody></table></div>${addForm}${changeForm}${bound.length?`<p class="small muted">Definitions that declare credentials, a hand edit: ${bound.join(', ')}.</p>`:''}<p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}</p><button type="button" id="instances-reload">Read current instances and discard drafts</button><p role="status" id="instances-receipt">${txt(instanceReceipt)}</p></section>`;
+ }
+ 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'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${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)}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}
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='<img src=x onerror="window.injected=true">';
+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, /<button[^>]*>[^<]*(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('<script'), false);
diff --git a/scripts/mosaic b/scripts/mosaic
index 22087e2fd..827c6ec29 100755
--- a/scripts/mosaic
+++ b/scripts/mosaic
@@ -26,6 +26,11 @@ if [ "${1:-}" = queue ]; then
exec node "$REPO/packages/queue/src/cli.mjs" "$@"
fi
if [ "${1:-}" = business ]; then
+ # Row 165 (#1662): the human's role instance commands need the human proof,
+ # so they run in packages/cli. validate and resolve stay in packages/business.
+ case "${2:-}" in
+ instances|instance) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
+ esac
shift
exec node "$REPO/packages/business/src/cli.mjs" "$@"
fi