Files
stack/agents/darkwing/work/queue-110/r1/build.md
T
jason.woltjeandClaude Opus 5.5 0bfc166542 work: row 110 round 1 packet, bus tester identity for PM tests (#1604)
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]>
2026-10-13 20:11:16 -05:00

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 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.