diff --git a/agents/darkwing/work/queue-110/r2/build.md b/agents/darkwing/work/queue-110/r2/build.md new file mode 100644 index 000000000..f128ce047 --- /dev/null +++ b/agents/darkwing/work/queue-110/r2/build.md @@ -0,0 +1,68 @@ +# Row 110 (#1604) round 2: one more envelope case + +Sage asked for changes in comment 28355 (rev 1093) and folded Filbert's note N1 into this round. Round 1 is agents/darkwing/work/queue-110/r1/. Everything in it still holds except the one test file below. + +BASE: badfa52b9d5b956392bd9cb141ff1f4ce9f402f0, unchanged. Since BASE, the branch has changed only packet files, docs/SESSIONS.md, docs/plans/QUEUE.md and docs/plans/queue.json. No manifest path moved. +Candidate: build.patch against BASE. candidate-manifest.sha256 lists the same 19 files as round 1. I applied the patch to a fresh `git archive badfa52b9` export and every digest matched. + +## What changed + +One file, packages/bus/tests/tester.test.mjs, in the test "an envelope carries exactly one of cap, human and tester; no tester authenticator refuses". The envelope loop gains a role cap beside the reader cap. r1-to-r2.diff is the whole change between the two candidates' applied trees: + +``` + const cap = b.bindReader({ business: 'demo' }); +- for (const r of [{ tester: p, cap }, { tester: p, human: p }, { tester: p, human: p, cap }]) ++ const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); ++ for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }]) +``` + +The manifest line for that file goes from 39c225c5... to 167aa918.... The other 18 lines match round 1 byte for byte. + +## The narrowing mutant + +mutant-n1.py in this packet. It narrows the conflict check at packages/bus/src/server.mjs:50 so a cap counts only when it is the envelope's sole credential or is a reader cap (`broker.identity` gives role null and no human). With the mutant, a role cap sent with a tester proof passes the envelope, and the request runs as the tester. The script swaps that one line, runs the whole bus suite (`node --test tests/*.test.mjs` in packages/bus), and puts server.mjs back from its `.orig` copy. Both runs report `restored=True`. + +| Candidate | Bus suite under the mutant | Result | +|---|---|---| +| round 1 (01:21:34Z) | 147 pass, 0 fail | survived | +| round 2 (01:21:50Z) | 146 pass, 1 fail | killed | + +On round 1 it survives every bus test, which is the gap Sage named. On round 2 the only failure is the envelope test. The new tuple gets `{ ok: true, result: [] }` where the test expects `{ ok: false, error: 'invalid-envelope' }` (mutant-n1-r2.log, the assertion at tester.test.mjs:337). The reader-cap tuple still refuses under the mutant, so the failure comes from the role-cap case. + +The assertion message is `Object.keys(r).join('+')`, so both cap tuples print `tester+cap`. Sage's scope said nothing else changes, so I left the label alone. The `actual` value in the log shows which tuple failed. + +## Gate + +gate.sh in this packet, run on the round-2 candidate with the slot from Sage, 01:22:27-01:27:55Z, on BASE badfa52b9 with `DOCKER_HOST` unset and docker 29.7.2. It runs every package's node suite and every scripts/test-*.sh. GATE_PASSED, exit 0. gate-run.log has the summary, and gate/ has one log per step. The worktree still matched the manifest after the run. + +| 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 | + +The counts match round 1. Bus stays at 147 because the new case sits inside an existing test. + +The real bwrap tester case ran 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 (262.780721ms) +``` + +Neither the review.test.mjs:123 flake (#261) nor the release `active.json` flake (#258) came back. The release step made `wt_default` at 01:27:13Z with no containers, and I removed it after the run. + +## Mutants from round 1 + +I didn't rerun the 96 round-1 mutants. The only change adds one more assertion to an existing test. A test with an extra assertion fails on everything it failed on before, so this round can only kill more mutants, never fewer. Two of them, s00 and s01, touch server.mjs:50, and round 1 killed both. Round 1's mutants-run.log (94 killed, b44 and b45 equivalent) ran on the round-1 candidate. diff --git a/agents/darkwing/work/queue-110/r2/build.patch b/agents/darkwing/work/queue-110/r2/build.patch new file mode 100644 index 000000000..474ecbe5d --- /dev/null +++ b/agents/darkwing/work/queue-110/r2/build.patch @@ -0,0 +1,1276 @@ +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/bus/src/tester-cli.mjs b/packages/bus/src/tester-cli.mjs +new file mode 100644 +index 000000000..49a47fe51 +--- /dev/null ++++ b/packages/bus/src/tester-cli.mjs +@@ -0,0 +1,40 @@ ++// Row 110 (#1604): the tester's transport shim, human-cli.mjs with its own nonce variable and a ++// tester proof. Not a second product CLI. Input and proof never carry service tokens. ++import { randomBytes } from 'node:crypto'; ++import { spawnSync } from 'node:child_process'; ++import { fileURLToPath } from 'node:url'; ++import { readFileSync } from 'node:fs'; ++import { Client } from './client.mjs'; ++import { readProcess } from './human.mjs'; ++const file = fileURLToPath(import.meta.url); ++if (!process.env.MOSAIC_BUS_TESTER_NONCE) { ++ const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], { ++ stdio: 'inherit', ++ env: { ...process.env, MOSAIC_BUS_TESTER_NONCE: randomBytes(32).toString('hex') }, ++ }); ++ process.exit(child.status ?? 2); ++} ++try { ++ const request = JSON.parse(readFileSync(0, 'utf8')); ++ if ( ++ !request || ++ typeof request !== 'object' || ++ Object.keys(request).some((k) => !['business', 'verb', 'args'].includes(k)) ++ ) ++ throw Error(); ++ const me = readProcess(process.pid); ++ const client = new Client({ ++ path: process.argv[2], ++ tester: { ++ business: request.business, ++ pid: me.pid, ++ startTime: me.startTime, ++ nonce: process.env.MOSAIC_BUS_TESTER_NONCE, ++ }, ++ }); ++ const result = await client.call(request.verb, request.args ?? {}); ++ process.stdout.write(JSON.stringify(result) + '\n'); ++} catch (e) { ++ process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n'); ++ process.exitCode = 2; ++} +diff --git a/packages/bus/src/tester.mjs b/packages/bus/src/tester.mjs +new file mode 100644 +index 000000000..01dea2eb6 +--- /dev/null ++++ b/packages/bus/src/tester.mjs +@@ -0,0 +1,73 @@ ++import { readFileSync } from 'node:fs'; ++import { resolve } from 'node:path'; ++import { BusError } from './broker.mjs'; ++import { readProcess } from './human.mjs'; ++// Row 110 (#1604): the tester's proof. Same shape as the human's (pid, startTime, nonce, business), ++// from its own CLI and its own nonce variable, so neither CLI's nonce proves the other. The walk ++// refuses only a managed run: a launch record or a managed run's marker. An agent's markers and ++// binaries are allowed, since a tester is meant to run from inside Mos or a T3 seat. ++// Like verifyHuman, these are cooperative same-UID process checks, not a hostile-process wall. ++const RUN_MARKERS = ['MOSAIC_BUS_CAP', 'MOSAIC_RUN_ID', 'MOSAIC_AGENT_RUN']; ++const NONCE = 'MOSAIC_BUS_TESTER_NONCE='; ++const refuse = () => { ++ throw new BusError('tester-required'); ++}; ++// readProcess keeps only the human's nonce and the markers, so the tester's nonce is read here. ++export function testerNonce(pid, { readFile = readFileSync } = {}) { ++ try { ++ const entry = readFile(`/proc/${pid}/environ`, 'utf8') ++ .split('\0') ++ .find((e) => e.startsWith(NONCE)); ++ return entry === undefined ? null : entry.slice(NONCE.length); ++ } catch { ++ return null; ++ } ++} ++export function verifyTester( ++ proof, ++ { cliPath, launches = [], readProcess: read = readProcess, readNonce = testerNonce }, ++) { ++ if ( ++ !proof || ++ typeof proof !== 'object' || ++ Object.keys(proof).some((k) => !['pid', 'startTime', 'nonce', 'business'].includes(k)) || ++ !Number.isSafeInteger(proof.pid) || ++ proof.pid < 2 || ++ typeof proof.business !== 'string' || ++ typeof proof.nonce !== 'string' || ++ !/^[a-f0-9]{64}$/.test(proof.nonce) ++ ) ++ refuse(); ++ try { ++ const nonce = readNonce(proof.pid); ++ const p = read(proof.pid); ++ if ( ++ p.uid !== process.getuid() || ++ p.startTime !== proof.startTime || ++ nonce !== proof.nonce || ++ p.argv[1] !== resolve(cliPath) ++ ) ++ refuse(); ++ const seen = new Set(); ++ let current = p; ++ for (let depth = 0; depth < 128; depth++) { ++ if (seen.has(current.pid)) refuse(); ++ seen.add(current.pid); ++ if ( ++ (current.env !== null && RUN_MARKERS.some((k) => current.env[k])) || ++ launches.some((r) => r.pid === current.pid && r.startTime === current.startTime) ++ ) ++ refuse(); ++ if (current.ppid === 1) { ++ const again = read(proof.pid); ++ if (again.startTime !== proof.startTime || again.ppid !== p.ppid) refuse(); ++ return { business: proof.business }; ++ } ++ if (current.ppid < 2) refuse(); ++ current = read(current.ppid); ++ } ++ } catch { ++ refuse(); ++ } ++ refuse(); ++} +diff --git a/packages/bus/tests/tester.test.mjs b/packages/bus/tests/tester.test.mjs +new file mode 100644 +index 000000000..244129537 +--- /dev/null ++++ b/packages/bus/tests/tester.test.mjs +@@ -0,0 +1,392 @@ ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { spawn } from 'node:child_process'; ++import { connect } from 'node:net'; ++import { mkdtempSync, 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, TASK_VERBS } from '../src/broker.mjs'; ++import { serve } from '../src/server.mjs'; ++import { Client } from '../src/client.mjs'; ++import { startBroker } from '../src/runtime.mjs'; ++import { readProcess, verifyHuman } from '../src/human.mjs'; ++import { verifyTester, testerNonce } from '../src/tester.mjs'; ++ ++// Row 110 (#1604): the bus tester identity for PM tests. ++const roles = { ++ pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++ cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++}; ++const business = (vars) => ({ ++ demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, ...(vars ? { vars } : {}) }, ++}); ++function setup(t, vars = { 'bus.tester': true }) { ++ const root = mkdtempSync(join(tmpdir(), 'bus-tester-')); ++ const store = new Store(root); ++ t.after(() => { ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const b = new Broker({ store, businesses: business(vars) }); ++ const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); ++ b.request(pm, { verb: 'role.claim' }); ++ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const reader = b.bindReader({ business: 'demo' }); ++ const call = (cap, verb, args = {}) => b.request(cap, { verb, args }); ++ return { b, store, pm, human, reader, call }; ++} ++const say = (body = 'is the plan current?') => `[mos test] ${body}`; ++ ++test('the tester binds only when the business sets bus.tester to exactly true', (t) => { ++ for (const vars of [null, {}, { 'bus.tester': false }, { 'bus.tester': 'true' }, { 'bus.tester': 1 }]) { ++ const { b } = setup(t, vars); ++ assert.throws(() => b.bindTester({ business: 'demo' }), { code: 'tester-off' }, JSON.stringify(vars)); ++ } ++ const { b } = setup(t); ++ assert.match(b.bindTester({ business: 'demo' }), /^[0-9a-f]{64}$/); ++ assert.throws(() => b.bindTester({ business: 'other' }), { code: 'unknown-business' }); ++ assert.throws(() => b.bindTester({ business: 'demo', role: 'pm' }), { code: 'invalid-request' }); ++}); ++ ++test('a business may not name a role tester, so the tester address is never a role', (t) => { ++ const root = mkdtempSync(join(tmpdir(), 'bus-tester-')); ++ const store = new Store(root); ++ t.after(() => { ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const businesses = business(); ++ businesses.demo.roles = { ...roles, tester: roles.pm }; ++ assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' }); ++ // No session can claim role tester: a launch must name a business role, and the tester ++ // session's own role.claim refuses. So #holder(business, 'tester') is always empty. ++ const s = setup(t); ++ assert.throws(() => s.b.bindLaunch({ business: 'demo', role: 'tester', run: 'tester-run', harness: 'pi' }), { code: 'unknown-role' }); ++ assert.throws(() => s.call(s.b.bindTester({ business: 'demo' }), 'role.claim'), { code: 'tester-refused' }); ++ assert.equal(s.store.get("SELECT count(*) n FROM role_claims WHERE role='tester'").n, 0); ++ assert.equal(s.store.get("SELECT count(*) n FROM role_claims WHERE role='pm'").n, 1); ++}); ++ ++test('every verb outside the tester list refuses tester-refused and is recorded against the tester', async (t) => { ++ const { b, store, call } = setup(t); ++ const tester = b.bindTester({ business: 'demo' }); ++ const refused = [ ++ ['role.claim', {}], ++ ['role.release', {}], ++ ['role.revoke', { role: 'pm' }], ++ ['decision.raise', { action: 'message.send', question: 'q', options: [], recommendation: 'no', blocking: false }], ++ ['decision.resolve', { id: 'd-1', choice: 'yes' }], ++ ['decision.seen', { id: 'd-1' }], ++ ['decision.withdraw', { id: 'd-1' }], ++ ['review.request', { task_ref: 'vikunja:1/1', role: 'cto' }], ++ ['review.verdict', { task_ref: 'vikunja:1/1', verdict: 'approve' }], ++ ['launch.revoke', {}], ++ ['launch.restore', {}], ++ ['event.emit', { kind: 'action.allowed', body: { action: 'routine' } }], ++ ['state', {}], ++ ['credentials', {}], ++ ['requirements', {}], ++ ]; ++ for (const [verb, args] of refused) assert.throws(() => call(tester, verb, args), { code: 'tester-refused' }, verb); ++ let handled = 0; ++ for (const verb of TASK_VERBS) ++ await assert.rejects( ++ b.requestTask(tester, { verb, args: {} }, async () => handled++), ++ { code: 'tester-refused' }, ++ verb, ++ ); ++ assert.equal(handled, 0); ++ const rows = store.all("SELECT actor_role, actor_run, body FROM events WHERE kind='action.refused'"); ++ assert.equal(rows.length, refused.length + TASK_VERBS.length); ++ assert.ok(rows.every((r) => r.actor_role === 'tester' && r.actor_run === null && JSON.parse(r.body).code === 'tester-refused')); ++ assert.equal(store.get("SELECT count(*) n FROM events WHERE kind IN ('launch.revoked','launch.restored','human.input')").n, 0); ++ // The tester gate runs before any argument check, so malformed arguments refuse tester-refused too. ++ for (const [verb] of refused) assert.throws(() => call(tester, verb, { bogus: 1 }), { code: 'tester-refused' }, `${verb} bogus`); ++}); ++ ++test('a tester message is from tester, starts [mos test], and writes no human.input or authority', (t) => { ++ const { b, store, pm, call } = setup(t); ++ const tester = b.bindTester({ business: 'demo' }); ++ assert.throws(() => call(tester, 'message.send', { to: 'pm', body: 'is the plan current?' }), { code: 'tester-prefix' }); ++ assert.throws(() => call(tester, 'message.send', { to: 'pm', body: ' [mos test] late prefix' }), { code: 'tester-prefix' }); ++ for (const to of ['human', 'tester', 'nobody']) ++ assert.throws(() => call(tester, 'message.send', { to, body: say() }), { code: 'unknown-role' }, to); ++ for (const extra of [{ corrects: 'm-1' }, { decision: 'd-1' }]) ++ assert.throws(() => call(tester, 'message.send', { to: 'pm', body: say(), ...extra }), { code: 'invalid-request' }); ++ const events = store.get('SELECT count(*) n FROM events WHERE kind<>?', 'action.refused').n; ++ const sent = call(tester, 'message.send', { to: 'pm', body: say(), class: 'REQUEST' }); ++ assert.equal(sent.request, null); ++ const row = store.get('SELECT * FROM messages WHERE id=?', sent.id); ++ assert.equal(row.from_role, 'tester'); ++ assert.equal(row.from_run, 'tester-cli'); ++ assert.equal(row.to_role, 'pm'); ++ assert.equal(store.get('SELECT count(*) n FROM events WHERE kind<>?', 'action.refused').n, events, 'no event at all'); ++ const got = call(pm, 'message.receive'); ++ assert.equal(got.length, 1); ++ assert.equal(got[0].from_role, 'tester'); ++ assert.equal(got[0].request, null); ++ // task.create needs a human.input event as its request (packages/tasks/src/verbs.mjs); a tester ++ // message gives none, so it can't back one. ++ assert.equal(b.taskView('demo', 'input', sent.id), false); ++ assert.equal(store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n, 0); ++}); ++ ++test('a role answers a tester only in reply to a tester message, and the answer never reaches the human', (t) => { ++ const { b, store, pm, human, reader, call } = setup(t); ++ const tester = b.bindTester({ business: 'demo' }); ++ const ask = call(tester, 'message.send', { to: 'pm', body: say() }).id; ++ const jason = call(human, 'message.send', { to: 'pm', body: 'a real instruction' }).id; ++ call(pm, 'message.receive'); ++ assert.throws(() => call(pm, 'message.send', { to: 'tester', body: 'answer' }), { code: 'tester-reply-required' }); ++ assert.throws(() => call(pm, 'message.send', { to: 'tester', body: 'answer', in_reply_to: jason }), { code: 'tester-reply-required' }); ++ assert.throws(() => call(pm, 'message.send', { to: 'tester', body: 'answer', in_reply_to: 'missing' }), { code: 'tester-reply-required' }); ++ assert.throws(() => call(human, 'message.send', { to: 'tester', body: 'hi', in_reply_to: ask }), { code: 'unknown-role' }); ++ const answer = call(pm, 'message.send', { to: 'tester', body: 'the plan is current', in_reply_to: ask }).id; ++ assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', answer).to_role, 'tester'); ++ // Not in the human's inbox or the Console's Messages page. ++ assert.deepEqual(call(human, 'message.receive'), []); ++ for (const cap of [human, reader]) assert.equal(call(cap, 'messages').total, 0); ++ const got = call(tester, 'message.receive'); ++ assert.deepEqual(got.map((m) => [m.id, m.from_role, m.in_reply_to]), [[answer, 'pm', ask]]); ++ assert.deepEqual(call(tester, 'message.receive'), []); ++ assert.throws(() => call(tester, 'message.read', { id: jason }), { code: 'message-not-found' }); ++ assert.deepEqual(call(tester, 'message.read', { id: answer }), { read: true }); ++ const delivered = store.all('SELECT op, holder_run, transport FROM deliveries WHERE message=? ORDER BY seq', answer); ++ assert.deepEqual(delivered.map((d) => [d.op, d.holder_run, d.transport]), [ ++ ['routed', null, null], ++ ['delivered', null, 'cli'], ++ ['read', null, null], ++ ]); ++ // A role's own inbox is not the tester's. ++ assert.throws(() => call(pm, 'message.read', { id: answer }), { code: 'message-not-found' }); ++}); ++ ++test('the tester reads the human views a reader reads', (t) => { ++ const { b, pm, reader, call } = setup(t); ++ const tester = b.bindTester({ business: 'demo' }); ++ const ask = call(tester, 'message.send', { to: 'pm', body: say() }).id; ++ call(pm, 'message.receive'); ++ call(pm, 'message.send', { to: 'tester', body: 'answer', in_reply_to: ask }); ++ call(pm, 'message.send', { to: 'human', body: 'for jason' }); ++ for (const [verb, args] of [ ++ ['inbox', {}], ++ ['agents', {}], ++ ['tasks', {}], ++ ['trail', { subject: ask }], ++ ['decisions', { list: 'roles' }], ++ ['decisions', { list: 'recent' }], ++ ['messages', {}], ++ ['replies', { id: ask }], ++ ]) ++ assert.deepEqual(call(tester, verb, args), call(reader, verb, args), verb); ++ assert.equal(call(tester, 'replies', { id: ask }).length, 1); ++ assert.equal(call(tester, 'messages').total, 1); ++}); ++ ++// The process proof, on a process table like human.test.mjs's. ++const testerCli = '/opt/mosaic/tester-cli.mjs'; ++const humanCli = '/opt/mosaic/human-cli.mjs'; ++const nonce = 'b'.repeat(64); ++function table(cli = testerCli, env = { MOSAIC_BUS_TESTER_NONCE: nonce }) { ++ return new Map([ ++ [50, { pid: 50, ppid: 40, startTime: '100', uid: process.getuid(), argv: ['node', cli], env }], ++ [40, { pid: 40, ppid: 1, startTime: '90', uid: process.getuid(), argv: ['bash'], env: {} }], ++ ]); ++} ++const proof = { pid: 50, startTime: '100', nonce, business: 'demo' }; ++const reader = (t) => (pid) => { ++ if (!t.has(pid)) throw Error('gone'); ++ return t.get(pid); ++}; ++const tester = (t, extra = {}) => ++ verifyTester(proof, { ++ cliPath: testerCli, ++ readProcess: reader(t), ++ readNonce: (pid) => t.get(pid)?.env?.MOSAIC_BUS_TESTER_NONCE ?? null, ++ ...extra, ++ }); ++ ++test('tester proof binds its own CLI, start time and nonce, refuses a managed run and allows an agent', () => { ++ assert.equal(tester(table()).business, 'demo'); ++ for (const change of [{ startTime: '101' }, { uid: process.getuid() + 1 }, { argv: ['node', 'other.mjs'] }, { env: { MOSAIC_BUS_TESTER_NONCE: 'c'.repeat(64) } }]) { ++ const t = table(); ++ Object.assign(t.get(50), change); ++ assert.throws(() => tester(t), { code: 'tester-required' }, JSON.stringify(change)); ++ } ++ for (const env of [{ MOSAIC_RUN_ID: 'run-x' }, { MOSAIC_BUS_CAP: 'cap' }, { MOSAIC_AGENT_RUN: '1' }]) { ++ const t = table(); ++ t.get(40).env = env; ++ assert.throws(() => tester(t), { code: 'tester-required' }, Object.keys(env)[0]); ++ const self = table(); ++ Object.assign(self.get(50).env, env); ++ assert.throws(() => tester(self), { code: 'tester-required' }, `${Object.keys(env)[0]} on the CLI`); ++ } ++ assert.throws(() => tester(table(), { launches: [{ pid: 40, startTime: '90' }] }), { code: 'tester-required' }); ++ assert.equal(tester(table(), { launches: [{ pid: 40, startTime: '89' }] }).business, 'demo'); ++ // An agent above the tester is the intended case: Mos or a T3 seat. ++ for (const [argv, env] of [ ++ [['bash'], { CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'sdk-ts' }], ++ [['bash'], { CODEX_THREAD_ID: 'thread', PI_AGENT_DIR: '/x' }], ++ [['claude'], {}], ++ [['node', '/usr/lib/node_modules/codex/bin/codex.js'], {}], ++ [['pi'], {}], ++ ]) { ++ const t = table(); ++ Object.assign(t.get(40), { argv, env }); ++ assert.equal(tester(t).business, 'demo', argv.join(' ')); ++ } ++ const broken = table(); ++ broken.delete(40); ++ assert.throws(() => tester(broken), { code: 'tester-required' }); ++ // A loop refuses when it comes round, not at the depth bound. ++ const loop = table(); ++ loop.get(40).ppid = 50; ++ let reads = 0; ++ assert.throws(() => tester(loop, { readProcess: (pid) => (reads++, reader(loop)(pid)) }), { code: 'tester-required' }); ++ assert.ok(reads < 5, `${reads} reads`); ++ // A parent pid under 2 refuses even when /proc would answer for it. ++ const gone = table(); ++ gone.get(40).ppid = 0; ++ gone.set(0, { pid: 0, ppid: 1, startTime: '1', uid: process.getuid(), argv: ['init'], env: {} }); ++ assert.throws(() => tester(gone), { code: 'tester-required' }); ++ // The CLI is read again at the top: a changed start time or parent refuses. ++ for (const change of [{ startTime: '101' }, { ppid: 1 }]) { ++ const t = table(); ++ let first = true; ++ const again = (pid) => (pid === 50 && !first ? { ...t.get(50), ...change } : ((first = pid === 50 ? false : first), t.get(pid))); ++ assert.throws(() => tester(t, { readProcess: again }), { code: 'tester-required' }, JSON.stringify(change)); ++ } ++ // The first read's start time is checked on its own, not only by the re-read. ++ const once = table(); ++ let fresh = true; ++ const first = (pid) => (pid === 50 && fresh ? ((fresh = false), { ...once.get(50), startTime: '101' }) : once.get(pid)); ++ assert.throws(() => tester(once, { readProcess: first }), { code: 'tester-required' }); ++ // Each bad shape sits on a table where every other check passes, so only the shape check refuses. ++ const pid1 = table(); ++ pid1.set(1, { ...pid1.get(50), pid: 1 }); ++ const nonHex = table(testerCli, { MOSAIC_BUS_TESTER_NONCE: 'x' }); ++ for (const [bad, t] of [ ++ [null, table()], ++ [{ ...proof, extra: 1 }, table()], ++ [{ ...proof, pid: 1 }, pid1], ++ [{ ...proof, nonce: 'x' }, nonHex], ++ [{ ...proof, business: 3 }, table()], ++ ]) ++ assert.throws( ++ () => verifyTester(bad, { cliPath: testerCli, readProcess: reader(t), readNonce: (pid) => t.get(pid)?.env?.MOSAIC_BUS_TESTER_NONCE ?? null }), ++ { code: 'tester-required' }, ++ JSON.stringify(bad), ++ ); ++}); ++ ++test("neither CLI's nonce proves the other: each proof is bound to its own CLI and nonce variable", () => { ++ // A human CLI process, offered as a tester proof. ++ const human = table(humanCli, { MOSAIC_BUS_CLI_NONCE: nonce }); ++ assert.throws(() => tester(human), { code: 'tester-required' }); ++ assert.throws(() => tester(human, { cliPath: humanCli }), { code: 'tester-required' }, 'right path, wrong variable'); ++ // A tester CLI process, offered as a human proof. ++ const t = table(); ++ const human_ = (extra = {}) => verifyHuman(proof, { cliPath: humanCli, readProcess: reader(t), ...extra }); ++ assert.throws(() => human_(), { code: 'human-required' }); ++ assert.throws(() => human_({ cliPath: testerCli }), { code: 'human-required' }, 'right path, wrong variable'); ++ // Both variables set: the path still decides. ++ const both = table(testerCli, { MOSAIC_BUS_TESTER_NONCE: nonce, MOSAIC_BUS_CLI_NONCE: nonce }); ++ assert.throws(() => verifyHuman(proof, { cliPath: humanCli, readProcess: reader(both) }), { code: 'human-required' }); ++ assert.throws(() => tester(both, { cliPath: humanCli }), { code: 'tester-required' }); ++}); ++ ++test("testerNonce reads only the tester's variable and gives null on any read error", () => { ++ const io = (text) => ({ readFile: () => text }); ++ assert.equal(testerNonce(5, io(`A=1\0MOSAIC_BUS_TESTER_NONCE=${nonce}\0`)), nonce); ++ assert.equal(testerNonce(5, io(`MOSAIC_BUS_CLI_NONCE=${nonce}\0`)), null); ++ assert.equal(testerNonce(5, { readFile: () => { throw Object.assign(Error(), { code: 'EACCES' }); } }), null); ++ assert.equal(testerNonce(process.pid), null); ++}); ++ ++test('an envelope carries exactly one of cap, human and tester; no tester authenticator refuses', async (t) => { ++ const root = mkdtempSync(join(tmpdir(), 'bus-tester-')); ++ const store = new Store(root); ++ const b = new Broker({ store, businesses: business({ 'bus.tester': true }) }); ++ const path = join(store.directory, 'broker.sock'); ++ let calls = 0; ++ let bound = null; ++ const server = await serve({ broker: b, path, authenticateHuman: () => (calls++, 'x'), authenticateTester: () => (calls++, (bound = b.bindTester({ business: 'demo' }))) }); ++ const bare = join(store.directory, 'bare.sock'); ++ const bareServer = await serve({ broker: b, path: bare }); ++ t.after(async () => { ++ await server.close(); ++ await bareServer.close(); ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const raw = (at, request) => ++ new Promise((resolve, reject) => { ++ const s = connect(at); ++ let out = ''; ++ s.on('data', (d) => (out += d)); ++ s.on('end', () => resolve(JSON.parse(out))); ++ s.on('error', reject); ++ s.end(JSON.stringify(request) + '\n'); ++ }); ++ const p = { business: 'demo', pid: 2, startTime: '1', nonce }; ++ const cap = b.bindReader({ business: 'demo' }); ++ const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); ++ for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }]) ++ assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+')); ++ assert.equal(calls, 0); ++ assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' }); ++ assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] }); ++ assert.equal(calls, 1); ++ // The tester's capability lasts one request, as the human's does. ++ assert.throws(() => b.request(bound, { verb: 'agents' }), { code: 'unauthenticated' }); ++ assert.deepEqual(await new Client({ path, tester: p }).call('agents'), []); ++}); ++ ++// The real shim against a real runtime. The test process is not a managed run, so the proof ++// passes here; a managed run's marker on the shim itself refuses. ++const shim = fileURLToPath(new URL('../src/tester-cli.mjs', import.meta.url)); ++// Async: the broker runs in this process, so a synchronous spawn would block its answer. ++function run(socket, request, env = {}) { ++ const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k))); ++ return new Promise((resolve, reject) => { ++ const child = spawn(process.execPath, [shim, socket], { env: { ...clean, ...env }, timeout: 20000 }); ++ let out = ''; ++ let err = ''; ++ child.stdout.on('data', (d) => (out += d)); ++ child.stderr.on('data', (d) => (err += d)); ++ child.on('error', reject); ++ child.on('close', (status) => resolve({ status, out: out.trim(), err: err.trim() })); ++ child.stdin.end(JSON.stringify(request)); ++ }); ++} ++async function runtime(t, vars, launches = []) { ++ const root = mkdtempSync(join(tmpdir(), 'bus-tester-rt-')); ++ const rt = await startBroker({ dataRoot: root, businesses: business(vars), readers: ['demo'], launches }); ++ t.after(async () => { ++ await rt.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ return rt; ++} ++ ++test('tester-cli.mjs binds through the runtime when the switch is on, and refuses off or under a run marker', async (t) => { ++ const on = await runtime(t, { 'bus.tester': true }); ++ const reader = new Client({ path: on.path, cap: on.readers[0].cap }); ++ const sent = await run(on.path, { business: 'demo', verb: 'message.send', args: { to: 'pm', body: say() } }); ++ assert.equal(sent.status, 0, sent.err); ++ const id = JSON.parse(sent.out).id; ++ assert.equal((await reader.call('trail', { subject: id })).find((r) => r.table === 'messages').from_role, 'tester'); ++ assert.deepEqual(await run(on.path, { business: 'demo', verb: 'state' }), { status: 2, out: '', err: 'tester-refused' }); ++ for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) ++ assert.deepEqual(await run(on.path, { business: 'demo', verb: 'agents' }, { [marker]: 'x' }), { status: 2, out: '', err: 'tester-required' }, marker); ++ assert.equal((await run(on.path, { business: 'demo', verb: 'agents' }, { CLAUDECODE: '1' })).status, 0); ++ assert.deepEqual(await run(on.path, { business: 'demo', verb: 'agents', cap: 'x' }), { status: 2, out: '', err: 'invalid-request' }); ++ // This test process as a managed run's launch record: the shim it spawns has it as an ancestor. ++ const me = readProcess(process.pid); ++ const under = await runtime(t, { 'bus.tester': true }, [{ business: 'demo', role: 'cto', run: 'cto-run', harness: 'pi', pid: me.pid, startTime: me.startTime }]); ++ assert.deepEqual(await run(under.path, { business: 'demo', verb: 'agents' }), { status: 2, out: '', err: 'tester-required' }); ++ const off = await runtime(t, null); ++ assert.deepEqual(await run(off.path, { business: 'demo', verb: 'agents' }), { status: 2, out: '', err: 'tester-off' }); ++}); +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