business: the human adds and changes role instances from the CLI and the Console (row 165, Q171, #1662)

`mosaic business instances BUSINESS` reads the role instances, and
`mosaic business instance add|set` writes one add or change through the
broker's new human-only `instances` and `instances.set` verbs. The writer is
packages/business/src/instance-settings.mjs. It takes the caller's revision,
validates the next document, refuses a definition that declares credentials
(organization-credentials-out-of-scope), keeps protected instances as they
are, logs the human as the actor, and says when the running bus needs a
restart. Neither command restarts the bus.

The writer refuses the instance ids human and tester with invalid-request,
because the broker refuses a business whose roles use either name when the
bus starts.

The Console's Settings view shows the instances and, under --send only,
posts to POST /api/instances with the exact Origin and same-origin Fetch
Metadata. A refusal keeps the draft. An answer without the view's shape is
outcome-unknown (503). The page's public/ diff carries the decision 123 go
in mos-0832.

Neither verb is in ORGANIZATION_KEYS, so no agent tool reaches them.

Owner Darkwing, brief in row 165 (#1662). Round 3 rebases round 2 onto
4f45cadc0 (row 156). The public/ +/- digest is 939ecd28...9444, unchanged
at landing. Reviewers on candidate manifest e30b54da...f580: Dewey
approve 29052, Filbert approve 29053. Seat run on 4f45cadc0: 1436/0
singles, 37/37 mutants killed. Sage gate on e8b09735a: GATE_PASSED 25/25,
packages 2282/0, queue node 156/0, script selftests 422/0, 23:59:13Z to
00:05:35Z. Rebased onto 50a57e454 over two SESSIONS-only commits.

Nothing live.
This commit is contained in:
2026-10-18 19:10:20 -05:00
parent 50a57e454b
commit 9466f3e5ed
24 changed files with 1343 additions and 18 deletions
+17
View File
@@ -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
+16 -3
View File
@@ -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
+14
View File
@@ -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
+7 -2
View File
@@ -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
+5
View File
@@ -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;
+5 -1
View File
@@ -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);
@@ -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');
});
+52
View File
@@ -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.
+155
View File
@@ -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 };
}
@@ -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);
});
+51
View File
@@ -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
+4
View File
@@ -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);
+121
View File
@@ -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);
}
}
+154
View File
@@ -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/);
});
+14
View File
@@ -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" }));
+36
View File
@@ -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
+4
View File
@@ -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;
+6 -5
View File
@@ -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.`);
+54 -1
View File
@@ -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>`;
}
+70 -3
View File
@@ -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 });
@@ -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);}
});
+173
View File
@@ -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);
});
+4 -3
View File
@@ -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);
+5
View File
@@ -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