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 onto4f45cadc0(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 on4f45cadc0: 1436/0 singles, 37/37 mutants killed. Sage gate one8b09735a: GATE_PASSED 25/25, packages 2282/0, queue node 156/0, script selftests 422/0, 23:59:13Z to 00:05:35Z. Rebased onto50a57e454over two SESSIONS-only commits. Nothing live.
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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);}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user