diff --git a/packages/bus/README.md b/packages/bus/README.md index ebf71f0d0..236ba8178 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -611,7 +611,7 @@ or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mj A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs ` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`. Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`, `limits`, `plugins`, `instances`, `project.list` and `thread.list`, each answering what the human's answers (`thread.list` lists the tester's own threads since row 177), and the writes `name.set` (`by` `tester`), `project.create` (`created_by` `tester`), `plugins.set`, `instances.set` and `routes.set`, each with every check the human's write runs and the organization receipt's actor `tester`. `TESTER_VERBS` and `TESTER_ORGANIZATION_VERBS` in `src/broker.mjs` list them; every other verb refuses `tester-refused`, among them `limits.set`, `organization.set`, `organization.revision`, `routes`, `decision.resolve`, `launch.revoke`, `launch.restore`, the credential verbs and `project.file.save`. Tests: `tests/tester-writes.test.mjs`. It sends only to a role, with any body 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`. -Row 177 (#1675, Q173) gives the tester threads of its own. `thread.create`, `thread.open`, `thread.send`, `thread.messages` and `assistant.stop` join `TESTER_VERBS`. A tester's thread has owner `tester` and its opens `by` `tester`; the human and the tester each reach only their own threads (`thread-not-owned`), and each `thread.list` lists only its own, so the human's list never shows a tester thread. A business whose `human` is `tester` refuses `invalid-business` at start, since the two would share threads. A tester's `thread.send` is stored `from_role 'tester'`, `from_run 'tester-cli'`, and returns `request: null` with no `human.input`, as its `message.send` does. Row 120's lists can't name the tester (`invalid-messaging`), so in a thread owned by `tester` the broker reads the tester as `human`: the tester's `thread.send` passes when the instance would hear the human, and the thread's run answers `to: 'tester'` (with `in_reply_to`, as above) when it could answer the human. The run's own `message.send` to `human` refuses `tester-thread-human`, so its replies stay in the thread and never reach the human's inbox; the human sees them in the trail as `tester` traffic. That covers the run's own sends only: the assistant's lists still let it message the chief of staff, and the chief can message the human. That is no new reach, since the chief has no lists and a tester's `message.send` already reaches it. The tester's `assistant.stop` revokes the runs of its own threads only, recorded `by` `tester`; the human's stop never touches them. Thread names are unique per project, not per owner, so a tester's `thread.create` with a name the human already used in that project answers `thread-exists`; that tells the tester the name exists and nothing more. A tester reads a reply with `message.receive` (what `talk --tester` and `follow --tester` use) or, in its own thread, `thread.messages`. `inbox --tester --json` prints decisions only, and the `messages` view reads the human's address, so neither shows a reply to the tester (sage-1148, #1673 comment 29334). Tests: `tests/tester-threads.test.mjs`. +Row 177 (#1675, Q173) gives the tester threads of its own. `thread.create`, `thread.open`, `thread.send`, `thread.messages` and `assistant.stop` join `TESTER_VERBS`. A tester's thread has owner `tester` and its opens `by` `tester`; the human and the tester each reach only their own threads (`thread-not-owned`), and each `thread.list` lists only its own, so the human's list never shows a tester thread. A business whose `human` is `tester` refuses `invalid-business` at start, since the two would share threads. A tester's `thread.send` is stored `from_role 'tester'`, `from_run 'tester-cli'`, and returns `request: null` with no `human.input`, as its `message.send` does. Row 120's lists can't name the tester (`invalid-messaging`), so in a thread owned by `tester` the broker reads the tester as `human`: the tester's `thread.send` passes when the instance would hear the human, and the thread's run answers `to: 'tester'` (with `in_reply_to`, as above) when it could answer the human. The run's own `message.send` to `human` refuses `tester-thread-human`, and so does its `decision.raise` when the route comes out `human` (an uncited gated raise or a hard wait), before anything is stored. So neither its replies nor its decisions reach the human's inbox; the human sees its messages in the trail as `tester` traffic. That covers the run's own sends and raises only: the assistant's lists still let it message the chief of staff, a raise routed to the chief as arbiter stays allowed, and the chief can message the human. That is no new reach, since the chief has no lists and a tester's `message.send` already reaches it. The tester's `assistant.stop` revokes the runs of its own threads only, recorded `by` `tester`; the human's stop never touches them. Thread names are unique per project, not per owner, so a tester's `thread.create` with a name the human already used in that project answers `thread-exists`; that tells the tester the name exists and nothing more. A tester reads a reply with `message.receive` (what `talk --tester` and `follow --tester` use) or, in its own thread, `thread.messages`. `inbox --tester --json` prints decisions only, and the `messages` view reads the human's address, so neither shows a reply to the tester (sage-1148, #1673 comment 29334). Tests: `tests/tester-threads.test.mjs`. ## Shared reads and adapter boundary diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index f540ebfee..b118452d8 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -620,6 +620,11 @@ export class Broker { #sender(s) { return s.human ? 'human' : s.tester ? 'tester' : s.role; } + // Row 177 (#1675): a run of a thread the tester owns. It answers the tester, never the human. + #testerThread(s) { + return !s.human && !s.tester && !!s.thread + && this.#store.get('SELECT owner FROM threads WHERE id=?', s.thread)?.owner === 'tester'; + } // Row 141 (#1635, REQ-MSG-2): the role names a name may not equal, which are the definitions the // loaded business knows (sage-0590). The broker doesn't read roles/. #definitions(b) { @@ -1292,8 +1297,9 @@ export class Broker { fail('read-only'); if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused'); // A tester reads the human's views, as a reader does, and its message verbs are its own. - // Since row 175 the view also carries the writes name.set and project.create. Neither - // branch checks `reader`: each passes a tester by `s.tester` and records `tester`. + // Since row 175 the view also carries the writes name.set and project.create, and since row 177 + // thread.create, thread.open, thread.send, thread.messages and assistant.stop. None of their + // branches checks `reader`: each passes a tester by `s.tester`, and each write records `tester`. const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s; const result = this.#dispatch(view, request.verb, args); this.#secretCheck(result); @@ -1665,6 +1671,9 @@ export class Broker { cls === 'cross-role' || (cls === 'gated' && spec !== null && !this.#hardWait(s, a.action, a.target)); const proposedRoute = arbitrated ? b.arbiters[domain] : cls === 'gated' ? 'human' : s.role; const route = arbitrated && proposedRoute === s.role ? 'human' : proposedRoute; + // Row 177 r2 (#1675, F1): nor does it put a decision in the human's inbox. A raise routed to an + // arbiter stays allowed; the chief's reach to the human is no new reach (README, N1). + if (route === 'human' && this.#testerThread(s)) fail('tester-thread-human'); // Row 133 (#1627): whoever closes the decision messages the raiser, so the route must be // allowed to message the raising role before anything is stored. A within-role decision is // routed to the raiser's own role and closes in this call. @@ -1846,8 +1855,7 @@ export class Broker { if (to !== 'human' && to !== 'tester' && !Object.hasOwn(b.roles, to)) fail('unknown-role'); // Row 177 (#1675): a run of a tester's thread answers the tester, never the human, and the // lists read the tester there as the human, so the run reaches it as far as it reaches the human. - const testerThread = !s.human && !s.tester && s.thread - && this.#store.get('SELECT owner FROM threads WHERE id=?', s.thread)?.owner === 'tester'; + const testerThread = this.#testerThread(s); if (testerThread && to === 'human') fail('tester-thread-human'); this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to); string(a.body, 32768); diff --git a/packages/bus/tests/tester-threads.test.mjs b/packages/bus/tests/tester-threads.test.mjs index b54c0aaa7..92eb25067 100644 --- a/packages/bus/tests/tester-threads.test.mjs +++ b/packages/bus/tests/tester-threads.test.mjs @@ -192,3 +192,38 @@ test('row 177 T7: the human trail shows a tester thread message as the tester', assert.equal(rows[0].from_role, 'tester'); assert.equal(rows[0].from_run, 'tester-cli'); }); + +// Row 177 r2 (#1675, F1): an action outside the assistant's grants is gated, and an uncited gated +// raise routes to the human, so the tester-thread run's raise refuses before anything is stored. +const raise = (action = 'task.create') => ({ + action, + question: `${action}?`, + options: [ + { key: 'yes', text: 'Yes' }, + { key: 'no', text: 'No' }, + ], + recommendation: 'no', + blocking: false, +}); + +test('row 177 T8: a tester-thread run never raises a decision to the human; a human-thread run still does', (t) => { + const { store, call, code, launch, H, T } = both(t); + const decisions = () => store.get('SELECT count(*) n FROM decisions').n; + launch('a', T); + launch('h', H); + assert.equal(code('a', 'decision.raise', raise()), 'tester-thread-human'); + assert.deepEqual(call('human', 'inbox'), []); + assert.equal(decisions(), 0); + const raised = call('h', 'decision.raise', raise()); + assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', raised.id).route_to, 'human'); + assert.deepEqual(call('human', 'inbox').map((d) => d.id), [raised.id]); +}); + +test('row 177 T8: a tester-thread raise routed to the arbiter stays allowed', (t) => { + const crossRole = { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: ['task.create'] } }; + const { store, call, launch, T } = both(t, demo({ roles: { ...roles, assistant: crossRole } })); + launch('a', T); + const raised = call('a', 'decision.raise', raise()); + assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', raised.id).route_to, 'chief'); + assert.deepEqual(call('human', 'inbox'), []); +}); diff --git a/packages/bus/tests/tester-writes.test.mjs b/packages/bus/tests/tester-writes.test.mjs index 22d97bf0f..bd9964332 100644 --- a/packages/bus/tests/tester-writes.test.mjs +++ b/packages/bus/tests/tester-writes.test.mjs @@ -1,6 +1,6 @@ // Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the test system's tester. It reads what the human // reads, makes seven writes recorded with actor `tester`, and every other human action still -// refuses it tester-refused. +// refuses it tester-refused. Row 177 (#1675) adds the thread verbs, tested in tester-threads.test.mjs. import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; @@ -57,7 +57,7 @@ async function runtime(t) { return { root, file, bus, human, tester, call, code, log, bytes }; } -test('row 175: each of the seven reads as the tester deep-equals the human read', async (t) => { +test('row 175: each of the six reads as the tester deep-equals the human read', async (t) => { const r = await runtime(t); r.call(r.human, 'name.set', { instance: 'coder', name: 'rex' }); const project = r.call(r.human, 'project.create', { name: 'alpha' }).id;