diff --git a/agents/darkwing/work/queue-110/r1/build.md b/agents/darkwing/work/queue-110/r1/build.md new file mode 100644 index 000000000..ae141c982 --- /dev/null +++ b/agents/darkwing/work/queue-110/r1/build.md @@ -0,0 +1,158 @@ +# Row 110 (#1604) round 1: the bus tester identity for PM tests + +Brief: docs/plans/2026-10-13_pm-tester-and-fresh-state.md, section "Bus tester identity for PM tests" (blob f07964d914a5), with the placement ruling in 503a81b50 and the amended ruling 2 in 80bc9d618. +BASE: badfa52b9d5b956392bd9cb141ff1f4ce9f402f0. origin/refactor is at 94ebfcac3; badfa52b9..94ebfcac3 touches only docs/SESSIONS.md, docs/plans/QUEUE.md and docs/plans/queue.json. +Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 19 landing files (15 changed, 4 new). Applying the patch to a fresh worktree at BASE reproduces every digest. + +## What changed + +### The switch (ruling 1) + +`bus.tester` is a business var in packages/business/src/vars.mjs: boolean, business layer only, default false. `bindTester` binds only when the business's `vars["bus.tester"]` is exactly `true`; anything else refuses `tester-off`. The broker reads the business at boot, so turning it on or off takes a bus restart. packages/business/README.md "Variables" says so. + +### The tester session and what it may do + +`Broker.bindTester({ business })` returns a cap for `{ business, tester: true, role: 'tester', run: null, human: false }` (broker.mjs:265). + +- `TESTER_VERBS` (broker.mjs:55) is message.send, message.receive, message.read, inbox, agents, tasks, trail, decisions, messages and replies. `request()` refuses any other verb with `tester-refused` (broker.mjs:672), and the refusal is recorded against the tester like any other. +- The non-message verbs run on a reader view of the session (broker.mjs:674), so the tester reads exactly what a reader reads. +- `#agent` refuses a tester first (broker.mjs:171). That covers every role-only verb, the eight task verbs included. +- A business may not name a role `tester` (broker.mjs:140, `invalid-business`), so the address can never be a role. + +### Sending and replies (ruling 3) + +- A tester sends only to a role of its business, with a body that starts `[mos test]` (`tester-prefix` otherwise), and takes no `corrects` or `decision`. Its message records `from_role 'tester'` and `from_run 'tester-cli'` and writes no event at all: no `human.input`, so it can't back a `task_create`, and no authority. +- A role may send to `tester` only with `in_reply_to` naming a message from the tester (`tester-reply-required`). The human may not send to `tester` (`unknown-role`). +- The tester's `message.receive` and `message.read` read `to_role 'tester'` only. A reply to the tester never shows in the human's inbox or in the `messages` view. The deliveries record `holder_run null` and transport `cli`, as the human's do. +- The runner already answers `m.from_role` with `in_reply_to` (runner.mjs:383), so a role's reply to a tester takes no change there. + +### The proof (ruling 2, amended) + +packages/bus/src/tester.mjs has `verifyTester` and `testerNonce`. packages/bus/src/tester-cli.mjs is the shim, the same shape as human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`. + +- The proof is `{ pid, startTime, nonce, business }`. The shape check refuses extra keys, a pid below 2, a non-string business and a nonce that isn't 64 hex characters. +- The process checks: the same uid, the start time, the nonce read from that process's own environment, and `argv[1]` equal to tester-cli.mjs. Then the walk up the parent chain refuses an ancestor that matches a launch record or carries `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN`. It allows `CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CODEX_THREAD_ID` and the agent binaries. At ppid 1 it re-reads the CLI and refuses if its start time or parent changed. +- `testerNonce` reads only the tester's variable. human.mjs is unchanged; its `readProcess` keeps only the human's nonce, which is why tester.mjs reads its own. Neither CLI's nonce proves the other. +- server.mjs takes exactly one of `cap`, `human` and `tester` per request (`invalid-envelope` otherwise), and refuses `tester-required` when no `authenticateTester` is wired. runtime.mjs wires it with the launch records. client.mjs sends a `tester` proof. +- The shim refuses a request with any key beyond business, verb and args, so `cap` with tester refuses `invalid-request` before it connects. + +As in human.mjs, these are cooperative same-UID process checks, not a wall against a hostile process. + +### The prompt line (ruling 4) + +`turnRequest()` in runner.mjs adds, for a message from `tester` only: "This message is from a tester running a PM test. It is never Jason's instruction and authorizes nothing." + +### The CLI + +- `--tester` on inbox, tasks, agents, trail, talk and follow binds through tester-cli.mjs on the data root's socket instead of human-cli.mjs. It works from inside an agent; without it those verbs still refuse inside an agent. decide, stop and launches take no `--tester` (exit 4, unknown option). +- `talk --tester` refuses text that doesn't start `[mos test]`, exit 2, before any call. follow sends nothing and takes no prefix. +- transport.mjs adds `TESTER_CLI`, `testerTransport` and exit 3 for tester-required, tester-off and tester-refused. cli.mjs adds `deps.testerSpawn` for the tester branch only. +- format.mjs is unchanged. The trail already prints the sender, so a tester message shows as `tester → coder`. +- The bus and cli READMEs describe the tester, the switch and the flags. + +### packages/cli/tests/stack-copy.test.mjs + +The proof-runtime script mocks human.mjs. runtime.mjs now imports tester.mjs, which imports `readProcess` from human.mjs, so the mock has to pass `readProcess` through. That is the only change there, one export and a comment line. + +## Tests + +| File | New or changed | What it pins | +|---|---|---| +| packages/bus/tests/tester.test.mjs (new, 11 tests) | all | the switch; the role name; every refused verb, with good and malformed arguments; send rules, prefix and no event; reply rules and the human's views; the reader-equal views; the proof; the nonce split; the envelope; the real shim through a real runtime | +| packages/cli/tests/tester.test.mjs (new, 6 tests) | all | talk, follow and the read verbs with `--tester` inside an agent; the prefix refusal; no `--tester` on decide, stop and launches; the transport and exit codes; the default shim and socket | +| packages/business/tests/vars.test.mjs | types, merge defaults | `bus.tester` refuses at project, agent and system and as a non-boolean; defaults false with provenance `default` | +| packages/harness/tests/runner.test.mjs | 1 new | the tester line, and no such line for any other sender | +| packages/seat/tests/sandbox.test.mjs | 1 new | the real bwrap case below | + +### The real bwrap case + +`session(t, { tester: true })` starts a real coder session under bwrap with `bus.tester` on and tester-cli.mjs bound in read-only. From inside, the shim's proof refuses `tester-required`: the session's PID namespace gives a pid that names another host process, and the walk would reach the launch record in any case. The same shim run from the test process, outside the sandbox, binds. + +It ran in the final gate and passed. It did not skip. From gate/pkg-seat.log:77: + +``` +✔ a confined session that runs tester-cli.mjs is refused as tester, and the same shim outside the sandbox binds (260.163034ms) +``` + +The seat suite reports 120 pass, 0 fail, 0 skipped. + +## Gate + +gate.sh in this packet, run with the slot from Sage. gate-run.log is the run on the final candidate. Two earlier full runs are kept, and both passed with the same counts and the bwrap case passing: gate-run-1.log (00:41:55-00:47:22Z, before the proof-test edits) and gate-run-2.log (00:55:51-01:01:18Z, before the malformed-argument assertions). Only gate-run.log is on the final candidate, and every number below comes from it. + +Run 01:03:44-01:09:09Z on BASE badfa52b9, `DOCKER_HOST` unset, docker 29.7.2. GATE_PASSED, exit 0. + +| Step | Passed | Failed | +|---|---|---| +| bus | 147 | 0 | +| business | 67 | 0 | +| cli | 184 | 0 | +| control-board | 124 | 0 | +| conversation | 182 | 0 | +| discord | 178 | 0 | +| harness | 80 | 0 | +| ledger | 78 | 0 | +| mosaic | 69 | 0 | +| queue | 156 | 0 | +| runs | 43 | 0 | +| seat | 120 (0 skipped) | 0 | +| tasks | 92 | 0 | +| webui | 108 | 0 | +| scripts | auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 28 (node 156), release 14, task 98 | 0 | + +No docker network of mine is left. The release script step made `wt_default` at 01:08:30Z with no containers, and I removed it after the run. The same happened after runs 1 and 2. + +### The flakes + +Neither the review.test.mjs:123 flake (#261) nor the release `active.json` flake (#258) came back, in this gate or in runs 1 and 2. + +## Mutants + +mutants.py in this packet. mutants-run.log is the run on the final candidate: 96 mutants, 01:03:15-01:03:39Z, candidate 7a4ab9c9a6c7770b at start and end, no `.orig` left. 94 killed as expected, 2 survived as expected (b44, b45), MUTANTS_OK. Two earlier runs are kept as mutants-run-1.log and mutants-run-2.log; the section after next says what each found. + +### The two expected survivors + +b44 and b45 are the holder mutants: + +- b44, broker.mjs:978 in `message.receive`: `s.human || s.tester ? null : this.#holder(s.business, s.role),` becomes `s.human ? null : this.#holder(...)`. +- b45, broker.mjs:1003 in `message.read`: the same change on the read's delivery holder. + +With either mutant, a tester's delivery passes `#holder(business, 'tester')` to `#delivery` instead of `null`. `#delivery` writes `h?.holder_run ?? null`, `transport ?? h?.harness ?? null` and `h?.address ?? null`, so the row is the same as long as that lookup returns undefined. It always does, and here is where the code makes that true: + +1. Only two statements write role_claims rows. `role.claim` (broker.mjs:719) inserts `s.role`, the claiming session's own role. `#claimEnd` (broker.mjs:1283) inserts `h.role`, the role of a holder that already has a claim row. +2. So a `tester` row needs a session with role `tester` to claim. Three checks rule that out: + - The constructor refuses a business role named `tester` (broker.mjs:140). Mutant b32 removes it and the ROLE test kills it. + - `bindLaunch` refuses a role outside the business (broker.mjs:195). Mutant b46 lets a launch name `tester` and the ROLE test kills it. + - The tester session's own `role.claim` refuses at the `TESTER_VERBS` gate (broker.mjs:672). Mutant b02 lets `role.claim` through, and the ROLE and REFUSE tests kill it. + +The ROLE test (`a business may not name a role tester, so the tester address is never a role`, tester.test.mjs:53) pins all three. `bindLaunch` with role `tester` refuses `unknown-role`, the tester's `role.claim` refuses `tester-refused`, and role_claims holds no `tester` row while `pm` has its one. The b32, b46 and b02 kills show those assertions fail when the checks go. + +### How the counts changed: 91, 92, 96, and six survivors down to two + +- 91 mutants, 2 expected survivors: my slot request, before any run. +- 92 on run 1: I added b46 with the ROLE test's equivalence assertions, so one check behind b44 and b45 had its own mutant. +- 96 on run 2: I added b47-b50, described below. +- Survivors: run 1 had ten bad results; run 2 (96 mutants, candidate 9b24af36791edf1d) ended MUTANTS_OK with six expected survivors; the final run has two. The four that dropped out are b03, b05, b13 and b16, now killed. +- The final run has the same 96 mutants as run 2. Only the expected result of b03, b05, b13 and b16 changed, from survived to killed. + +### The first run, and what I changed + +The first run (92 mutants, candidate a266a3aa195a7841) ended MUTANTS_BAD with ten bad results: + +- b03, b05, b13 and b16 survived where I'd expected kills. See the next section. +- t02 (no start-time check) survived. At ppid 1 the re-read compares the start time again, so it masked the first check. The PROOF test now has a reader whose first read of the CLI gives another start time and later reads give the right one. t02 is killed. +- t12, t13 and t14 (the proof's key, pid and nonce shape checks) survived. The shape cases passed no `readNonce`, so the real `/proc` read refused first, whatever the shape check did. Each bad shape now sits on a stand-in table where every other check passes. All three are killed. +- l03 (`--tester` uses the human transport) was a FAULT. On the human stand-in, the talk test waits its full 900 s for a reply that goes to the tester, and the mutant runner's 300 s timeout killed it. l03 now runs READS and DEFAULT, which kill it, and not TALK. +- x01 (`TESTER_CLI` points at human-cli.mjs) was MIXED. The default-transport test compares argv with the imported `TESTER_CLI`, so it follows the mutant and passes. x01 now runs TRANS, which pins the path and kills it. + +### b03, b05, b13 and b16: a label first, then a test + +Each adds one role-only verb to `TESTER_VERBS` (broker.mjs:55): role.release, decision.raise, event.emit and requirements. Each of those cases calls `this.#agent(s)` (role.release broker.mjs:734, decision.raise :768, event.emit :1098, requirements :1117), and `#agent` refuses a tester with the same `tester-refused` (broker.mjs:171). The REFUSE test passed well-formed arguments, so after run 1 I called them equivalent and added b47-b50. Each of those adds one verb and also removes `#agent`'s tester line; all four are killed, because the reader view then makes `#agent` throw `read-only`. Run 2 ran that way: MUTANTS_OK with six expected survivors. + +That was a label, and it was wrong. Each case runs `keys(a, ...)` before `#agent`, so with the mutant a tester sending malformed arguments gets `invalid-request`, not `tester-refused`. Nothing tested that. The REFUSE test now ends with every refused verb called with `{ bogus: 1 }`, expecting `tester-refused`, which is what the gate at broker.mjs:672 gives today, since it runs before any argument check. b03, b05, b13 and b16 are now expected killed. In the final run each one is killed by the REFUSE test, `every verb outside the tester list refuses tester-refused and is recorded against the tester` (tester.test.mjs:72), at its new malformed-argument loop. b47-b50 stay, as the pins on `#agent`'s tester line for those four verbs. + +## Limits and notes + +- The `task_create` limit is tested at the bus only: `taskView('demo', 'input', id)` is false for a tester message and there is no `human.input`. The tasks package's own test of `task_create` isn't in this row's files. +- Going live needs two things outside this row: `"bus.tester": true` in Jason's business file, and a bus restart under Mos's go. +- human.mjs is unchanged. diff --git a/agents/darkwing/work/queue-110/r1/build.patch b/agents/darkwing/work/queue-110/r1/build.patch new file mode 100644 index 000000000..77a1c8a90 --- /dev/null +++ b/agents/darkwing/work/queue-110/r1/build.patch @@ -0,0 +1,1275 @@ +diff --git a/packages/bus/README.md b/packages/bus/README.md +index b0c469263..18f3039dd 100644 +--- a/packages/bus/README.md ++++ b/packages/bus/README.md +@@ -324,6 +324,8 @@ 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. + ++A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs ` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`. ++ + ## Shared reads and adapter boundary + + ```js +diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs +index ddc18f21c..64a544d4b 100644 +--- a/packages/bus/src/broker.mjs ++++ b/packages/bus/src/broker.mjs +@@ -50,6 +50,10 @@ const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', + export const TASK_PAGE = 50; + export const TASK_PAGE_MAX = 200; + export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.'))); ++// Row 110 (#1604): what a tester session may do. Send to a role, receive and read the replies ++// sent to `tester`, and the human's read views. Every other verb refuses tester-refused. ++const TESTER_VERBS = ['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies']; ++const TESTER_PREFIX = '[mos test]'; + const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); + const POLL_KINDS = new Set([ + 'task.changed.external', +@@ -133,7 +137,7 @@ export class Broker { + if (!id(name) || b.id !== name || !id(b.human) || !object(b.roles) || !object(b.arbiters)) + fail('invalid-business'); + for (const role of Object.keys(b.roles)) { +- if (!id(role) || role === 'human') fail('invalid-business'); ++ if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business'); + const a = b.roles[role].authority; + if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole)) + fail('invalid-authority'); +@@ -164,6 +168,7 @@ export class Broker { + ); + } + #agent(s) { ++ if (s.tester) fail('tester-refused'); + if (s.reader) fail('read-only'); + if (s.human) fail('agent-required'); + const h = this.#holder(s.business, s.role); +@@ -254,6 +259,14 @@ export class Broker { + fail('human-required'); + return this.#cap({ ...record, role: null, run: null, human: b.human }); + } ++ // Row 110 (#1604): the trusted tester transport invokes this only after checking its CLI process ++ // and that no managed run is among its ancestors. The business turns testing on with the business ++ // var `bus.tester`, exactly true; anything else refuses tester-off. ++ bindTester(record) { ++ keys(record, ['business'], ['business']); ++ if (this.#business(record.business).vars?.['bus.tester'] !== true) fail('tester-off'); ++ return this.#cap({ business: record.business, tester: true, role: 'tester', run: null, human: false }); ++ } + bindReader({ business }) { + this.#business(business); + return this.#cap({ business, reader: true, role: null, run: null, human: false }); +@@ -656,7 +669,10 @@ export class Broker { + return this.#store.transaction(() => { + if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb)) + fail('read-only'); +- const result = this.#dispatch(s, request.verb, args); ++ 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. ++ const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s; ++ const result = this.#dispatch(view, request.verb, args); + this.#secretCheck(result); + return result; + }); +@@ -877,14 +893,29 @@ export class Broker { + } + case 'message.send': { + keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']); +- if (!s.human) { ++ if (s.tester) { ++ // A tester writes to a role only, says so in its first words, and cites no decision. ++ // It consumes no authority and writes no human.input. ++ keys(a, ['to', 'body', 'class', 'in_reply_to'], ['to', 'body']); ++ if (!Object.hasOwn(b.roles, a.to)) fail('unknown-role'); ++ if (typeof a.body !== 'string' || !a.body.startsWith(TESTER_PREFIX)) fail('tester-prefix'); ++ } else if (!s.human) { + const withinRole = this.#classification(s, 'message.send') === 'within-role'; + this.#consumeAuthority(s, 'message.send', { + decision: withinRole ? null : (a.decision ?? null), + target: a.to, + }); +- } else this.#human(s); +- if (a.to !== 'human' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); ++ // A role answers a tester only in reply to a tester's message. ++ if ( ++ a.to === 'tester' && ++ !(id(a.in_reply_to) && this.#store.get("SELECT 1 FROM messages WHERE id=? AND business=? AND from_role='tester'", a.in_reply_to, s.business)) ++ ) ++ fail('tester-reply-required'); ++ } else { ++ this.#human(s); ++ if (a.to === 'tester') fail('unknown-role'); ++ } ++ if (a.to !== 'human' && a.to !== 'tester' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); + string(a.body, 32768); + if ( + a.class !== undefined && +@@ -915,7 +946,7 @@ export class Broker { + this.#now(), + s.business, + s.human ? 'human' : s.role, +- s.human ? 'human-cli' : s.run, ++ s.human ? 'human-cli' : s.tester ? 'tester-cli' : s.run, + a.to, + a.class ?? 'INFO', + a.in_reply_to ?? null, +@@ -933,7 +964,7 @@ export class Broker { + case 'message.receive': { + keys(a, []); + if (s.human) this.#human(s); +- else this.#agent(s); ++ else if (!s.tester) this.#agent(s); + const role = s.human ? 'human' : s.role; + const rows = this.#store.all( + "SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) ORDER BY seq LIMIT 100", +@@ -944,8 +975,8 @@ export class Broker { + this.#delivery( + m.id, + 'delivered', +- s.human ? null : this.#holder(s.business, s.role), +- s.human ? 'cli' : 'broker', ++ s.human || s.tester ? null : this.#holder(s.business, s.role), ++ s.human || s.tester ? 'cli' : 'broker', + ); + return rows.map((m) => ({ + ...m, +@@ -960,7 +991,7 @@ export class Broker { + case 'message.read': { + keys(a, ['id'], ['id']); + if (s.human) this.#human(s); +- else this.#agent(s); ++ else if (!s.tester) this.#agent(s); + const m = this.#store.get('SELECT * FROM messages WHERE business=? AND id=?', s.business, a.id); + if (!m || m.to_role !== (s.human ? 'human' : s.role)) fail('message-not-found'); + const delivery = this.#store.get( +@@ -969,7 +1000,7 @@ export class Broker { + ); + if (!delivery || delivery.holder_run !== (s.run ?? null)) fail('not-recipient'); + if (!this.#store.get("SELECT seq FROM deliveries WHERE message=? AND op='read'", m.id)) +- this.#delivery(m.id, 'read', s.human ? null : this.#holder(s.business, s.role)); ++ this.#delivery(m.id, 'read', s.human || s.tester ? null : this.#holder(s.business, s.role)); + return { read: true }; + } + // Row 93 (#1587): the coder asks a reviewer role to review the task's latest pushed commit. +diff --git a/packages/bus/src/client.mjs b/packages/bus/src/client.mjs +index 52d46be3d..11543b5b3 100644 +--- a/packages/bus/src/client.mjs ++++ b/packages/bus/src/client.mjs +@@ -1,15 +1,20 @@ + import { connect } from 'node:net'; + import { BusError } from './broker.mjs'; + export class Client { +- constructor({ path, cap, human, timeout = 5000 }) { ++ constructor({ path, cap, human, tester, timeout = 5000 }) { + this.path = path; + this.cap = cap; + this.human = human; ++ this.tester = tester; + this.timeout = timeout; + } + call(verb, args = {}) { + const request = +- JSON.stringify({ ...(this.human ? { human: this.human } : { cap: this.cap }), verb, args }) + '\n'; ++ JSON.stringify({ ++ ...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : { cap: this.cap }), ++ verb, ++ args, ++ }) + '\n'; + if (Buffer.byteLength(request) > 65536) return Promise.reject(new BusError('request-too-large')); + return new Promise((resolve, reject) => { + const socket = connect(this.path); +diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs +index a4912853f..dbb09e9b6 100644 +--- a/packages/bus/src/runtime.mjs ++++ b/packages/bus/src/runtime.mjs +@@ -5,6 +5,7 @@ import { Broker, BusError } from './broker.mjs'; + import { Credentials } from './credentials.mjs'; + import { serve } from './server.mjs'; + import { verifyHuman } from './human.mjs'; ++import { verifyTester } from './tester.mjs'; + // Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them. + // Neither a business-file writer nor a socket-accessible configuration endpoint. + // `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}. +@@ -72,6 +73,15 @@ export async function startBroker({ + if (!human) throw new BusError('unknown-business'); + return broker.bindHuman({ business, human, via: 'cli', outsideAgent: true }); + }, ++ // Row 110 (#1604): a tester proves its CLI process and that no managed run is above it. ++ // The broker then binds only if the business sets `bus.tester` to true. ++ authenticateTester: (proof) => { ++ const { business } = verifyTester(proof, { ++ cliPath: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)), ++ launches: launchRecords, ++ }); ++ return broker.bindTester({ business }); ++ }, + }); + // S6 launcher operations. The host decides capacity and the instance list; the broker + // authorizes role.launch and keeps the evidence, refusals included. +diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs +index 860d88bd0..b6517bfc3 100644 +--- a/packages/bus/src/server.mjs ++++ b/packages/bus/src/server.mjs +@@ -4,7 +4,8 @@ import { BusError, TASK_VERBS } from './broker.mjs'; + const LIMIT = 65536; + // One request per connection. There is deliberately no reconnect/retry or SQL verb. + // `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it. +-export async function serve({ broker, path, authenticateHuman = null, timeout = 5000, tasks = null }) { ++// A request carries exactly one of cap, human (row S4) and tester (row 110) proof. ++export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, timeout = 5000, tasks = null }) { + try { + lstatSync(path); + throw new BusError('socket-exists'); +@@ -44,9 +45,9 @@ export async function serve({ broker, path, authenticateHuman = null, timeout = + !r || + Array.isArray(r) || + typeof r !== 'object' || +- Object.keys(r).some((k) => !['cap', 'human', 'verb', 'args'].includes(k)) || ++ Object.keys(r).some((k) => !['cap', 'human', 'tester', 'verb', 'args'].includes(k)) || + typeof r.verb !== 'string' || +- Boolean(r.cap) === Boolean(r.human) ++ [r.cap, r.human, r.tester].filter(Boolean).length !== 1 + ) + throw new BusError('invalid-envelope'); + let cap = r.cap; +@@ -54,6 +55,10 @@ export async function serve({ broker, path, authenticateHuman = null, timeout = + if (!authenticateHuman) throw new BusError('human-required'); + transient = cap = authenticateHuman(r.human); + } ++ if (r.tester) { ++ if (!authenticateTester) throw new BusError('tester-required'); ++ transient = cap = authenticateTester(r.tester); ++ } + if (typeof cap !== 'string') throw new BusError('unauthenticated'); + if (tasks && TASK_VERBS.includes(r.verb)) { + const own = transient; +diff --git a/packages/business/README.md b/packages/business/README.md +index 8c7a43350..f36ded9eb 100644 +--- a/packages/business/README.md ++++ b/packages/business/README.md +@@ -141,6 +141,10 @@ each layer that sets one narrows it, and the result also narrows the role + definition. `limits.authority` is an allowlist, so a role action it leaves + out becomes gated. + ++`bus.tester` is business-only and defaults to false. Set to `true`, the bus ++binds a tester from `tester-cli.mjs` for PM tests; it reads the value at bus ++start, so a change takes a bus restart. ++ + `resolve` prints `provenance` beside `vars`. For a plain key it's the + layer that set the value (`default`, `system`, `business:`, + `project:`, `project::roles.`, +diff --git a/packages/business/src/vars.mjs b/packages/business/src/vars.mjs +index 69b6ea575..c7e8d0a67 100644 +--- a/packages/business/src/vars.mjs ++++ b/packages/business/src/vars.mjs +@@ -96,6 +96,8 @@ export const REGISTRY = Object.freeze({ + return v; + }), + "gitea.baseUrl": def(["business"], baseUrl), ++ // On: the bus binds a tester from tester-cli.mjs for PM tests (row 110). Read at bus start. ++ "bus.tester": def(["business"], boolean, { default: false }), + + // Project. + "tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)), +diff --git a/packages/business/tests/vars.test.mjs b/packages/business/tests/vars.test.mjs +index ab7d2b3cd..b5db7ef8c 100644 +--- a/packages/business/tests/vars.test.mjs ++++ b/packages/business/tests/vars.test.mjs +@@ -33,7 +33,7 @@ test("unknown keys and wrong layers refuse", () => { + + test("types", () => { + const ok = (vars, layer) => assert.deepEqual({ ...checkVars(vars, layer, "v") }, vars); +- ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678" }, "business"); ++ ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678", "bus.tester": true }, "business"); + ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack", "tracker.descendants": true }, "project"); + ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent"); + +@@ -53,6 +53,11 @@ test("types", () => { + [{ "tracker.descendants": "true" }, "project", /true or false/], + [{ "tracker.descendants": 1 }, "project", /true or false/], + [{ "tracker.descendants": true }, "business", /business layer \(allowed: project\)/], ++ [{ "bus.tester": "true" }, "business", /true or false/], ++ [{ "bus.tester": 1 }, "business", /true or false/], ++ [{ "bus.tester": true }, "project", /project layer \(allowed: business\)/], ++ [{ "bus.tester": true }, "agent", /agent layer \(allowed: business\)/], ++ [{ "bus.tester": true }, "system", /system layer \(allowed: business\)/], + [{ "git.workingBranch": "-x" }, "project", /git branch name/], + [{ "git.workingBranch": "a..b" }, "project", /git branch name/], + [{ "git.workingBranch": "x.lock" }, "project", /git branch name/], +@@ -83,6 +88,8 @@ test("merge: defaults, then the most specific layer wins", () => { + assert.equal(vars["tracker.kind"], "vikunja"); + assert.equal(vars["tracker.descendants"], false); + assert.equal(provenance["tracker.descendants"], "default"); ++ assert.equal(vars["bus.tester"], false); ++ assert.equal(provenance["bus.tester"], "default"); + assert.equal(vars.harness, undefined); + assert.equal(provenance.harness, undefined); + }); +diff --git a/packages/cli/README.md b/packages/cli/README.md +index 3111fe03d..e23d8fb0d 100644 +--- a/packages/cli/README.md ++++ b/packages/cli/README.md +@@ -80,6 +80,16 @@ mosaic launches list [--json] + the bus (see "Sessions" below). `stop` refuses inside an agent run; + `launches list` does not, because the file is readable to the same user + anyway. ++- `--tester` (row 110, #1604) runs `inbox`, `tasks`, `agents`, `trail`, ++ `talk` and `follow` as the business's tester through ++ `packages/bus/src/tester-cli.mjs` instead of as the human. It is the one ++ exception to the agent check above: Mos or a T3 seat may run it for a PM ++ test, and the broker still refuses it under a managed run ++ (`tester-required`) or while the business var `bus.tester` isn't `true` ++ (`tester-off`), each exit 3. `talk --tester` text must start with ++ `[mos test]`, else exit 2 before any call. The role's reply comes back to ++ the tester, not to the human's inbox. `decide`, `stop` and `launches` ++ take no `--tester` (exit 4). + + ## The app + +diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs +index fcd7c0132..a547e36c7 100644 +--- a/packages/cli/src/cli.mjs ++++ b/packages/cli/src/cli.mjs +@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url"; + import { BINDING_NAME } from "../../discord/src/binding.mjs"; + import { CliError } from "./errors.mjs"; + import { bootConfig, loadSystem, socketPath } from "./config.mjs"; +-import { humanTransport, refuseInsideAgent } from "./transport.mjs"; ++import { humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs"; + import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs"; + import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs"; + import { createLauncher } from "./launcher.mjs"; +@@ -24,13 +24,13 @@ import { readSessions, stopSession } from "../../seat/src/session.mjs"; + import { SeatError } from "../../seat/src/seat.mjs"; + + export const USAGE = `usage: +- mosaic inbox [--business ] [--json] ++ mosaic inbox [--business ] [--json] [--tester] + mosaic decide