Build notes, patch and candidate manifest on BASE badfa52b9, the gate
logs (GATE_PASSED; the real bwrap tester case in seat sandbox.test.mjs
ran and passed) and the mutant runs (94 killed, b44 and b45 equivalent).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
14 KiB
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 f07964d914), with the placement ruling in 503a81b50 and the amended ruling 2 in 80bc9d618.
BASE: badfa52b9d. 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 withtester-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.
#agentrefuses 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-prefixotherwise), and takes nocorrectsordecision. Its message recordsfrom_role 'tester'andfrom_run 'tester-cli'and writes no event at all: nohuman.input, so it can't back atask_create, and no authority. - A role may send to
testeronly within_reply_tonaming a message from the tester (tester-reply-required). The human may not send totester(unknown-role). - The tester's
message.receiveandmessage.readreadto_role 'tester'only. A reply to the tester never shows in the human's inbox or in themessagesview. The deliveries recordholder_run nulland transportcli, as the human's do. - The runner already answers
m.from_rolewithin_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 carriesMOSAIC_RUN_ID,MOSAIC_BUS_CAPorMOSAIC_AGENT_RUN. It allowsCLAUDECODE,CLAUDE_CODE_ENTRYPOINT,CODEX_THREAD_IDand the agent binaries. At ppid 1 it re-reads the CLI and refuses if its start time or parent changed. testerNoncereads only the tester's variable. human.mjs is unchanged; itsreadProcesskeeps 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,humanandtesterper request (invalid-envelopeotherwise), and refusestester-requiredwhen noauthenticateTesteris wired. runtime.mjs wires it with the launch records. client.mjs sends atesterproof. - The shim refuses a request with any key beyond business, verb and args, so
capwith tester refusesinvalid-requestbefore 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
--testeron 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 --testerrefuses text that doesn't start[mos test], exit 2, before any call. follow sends nothing and takes no prefix.- transport.mjs adds
TESTER_CLI,testerTransportand exit 3 for tester-required, tester-off and tester-refused. cli.mjs addsdeps.testerSpawnfor 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),becomess.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:
- Only two statements write role_claims rows.
role.claim(broker.mjs:719) insertss.role, the claiming session's own role.#claimEnd(broker.mjs:1283) insertsh.role, the role of a holder that already has a claim row. - So a
testerrow needs a session with roletesterto 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. bindLaunchrefuses a role outside the business (broker.mjs:195). Mutant b46 lets a launch nametesterand the ROLE test kills it.- The tester session's own
role.claimrefuses at theTESTER_VERBSgate (broker.mjs:672). Mutant b02 letsrole.claimthrough, and the ROLE and REFUSE tests kill it.
- The constructor refuses a business role named
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/procread 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 (
--testeruses 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_CLIpoints at human-cli.mjs) was MIXED. The default-transport test compares argv with the importedTESTER_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_createlimit is tested at the bus only:taskView('demo', 'input', id)is false for a tester message and there is nohuman.input. The tasks package's own test oftask_createisn't in this row's files. - Going live needs two things outside this row:
"bus.tester": truein Jason's business file, and a bus restart under Mos's go. - human.mjs is unchanged.