Files
stack/agents/darkwing/work/queue-110/r1/build.patch
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

1276 lines
69 KiB
Diff

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 <socket>` 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:<id>`,
`project:<id>`, `project:<id>:roles.<instance>`,
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 <id>] [--json]
+ mosaic inbox [--business <id>] [--json] [--tester]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
- mosaic tasks [--business <id>] [--json]
- mosaic agents [--business <id>] [--json]
- mosaic trail <task|decision> [--business <id>] [--json]
- mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
- mosaic follow [--business <id>]
+ mosaic tasks [--business <id>] [--json] [--tester]
+ mosaic agents [--business <id>] [--json] [--tester]
+ mosaic trail <task|decision> [--business <id>] [--json] [--tester]
+ mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
+ mosaic follow [--business <id>] [--tester]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
@@ -155,13 +155,22 @@ export async function resolveDecision({ call, ref, choice, note, write, ask }) {
return d;
}
-async function human(verb, rest, io, deps) {
- const spec = verb === "decide" ? { flags: ["--yes"], values: ["--business", "--note"] } : { flags: ["--json"], values: ["--business"] };
- const parsed = parse(rest, spec);
- refuseInsideAgent(io.env);
+// The bus call for one command. --tester (row 110) binds as the business's
+// tester through tester-cli.mjs instead of as the human, and may run inside
+// an agent; the broker refuses it under a managed run or with bus.tester off.
+function callFor(parsed, io, deps) {
+ const tester = parsed.flags.has("--tester");
+ if (!tester) refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const business = businessFor(parsed, system.dataRoot);
- const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
+ if (tester) return deps.testerTransport ? deps.testerTransport(business) : testerTransport({ socket: socketPath(system.dataRoot), business, env: io.env, spawn: deps.testerSpawn });
+ return deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
+}
+
+async function human(verb, rest, io, deps) {
+ const spec = verb === "decide" ? { flags: ["--yes"], values: ["--business", "--note"] } : { flags: ["--json", "--tester"], values: ["--business"] };
+ const parsed = parse(rest, spec);
+ const call = callFor(parsed, io, deps);
if (verb === "decide") return decide(parsed, call, io);
const json = parsed.flags.has("--json");
const print = (value, text) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
@@ -189,6 +198,7 @@ async function human(verb, rest, io, deps) {
}
const TALK_WAIT_S = 900;
+const TESTER_PREFIX = "[mos test]";
const TALK_POLL_MS = 2000;
function formatMessage(m) {
@@ -200,18 +210,17 @@ function formatMessage(m) {
// until the reply arrives or --wait runs out. --follow keeps receiving after
// that reply; follow starts the same receive loop without sending.
async function talk(rest, io, deps, receiveOnly = false) {
- const parsed = parse(rest, { flags: ["--follow"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] });
+ const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", ...(receiveOnly ? [] : ["--wait"])] });
if (parsed.positional.length !== (receiveOnly ? 0 : 2)) throw usage(receiveOnly ? "follow takes no arguments" : "talk takes a role and one quoted text");
if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows");
const follow = receiveOnly || parsed.flags.has("--follow");
if (follow && parsed.values["--wait"] !== undefined) throw usage("--follow and --wait cannot be combined");
const wait = parsed.values["--wait"] === undefined ? TALK_WAIT_S : Number(parsed.values["--wait"]);
if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
- refuseInsideAgent(io.env);
- const system = deps.system ?? loadSystem({ env: io.env });
- const business = businessFor(parsed, system.dataRoot);
- const call = deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
const [to, body] = parsed.positional;
+ // The broker refuses it too (tester-prefix); this is the clear early message.
+ if (!receiveOnly && parsed.flags.has("--tester") && !body.startsWith(TESTER_PREFIX)) throw new CliError(`a tester message starts with "${TESTER_PREFIX}"`, 2);
+ const call = callFor(parsed, io, deps);
let sent;
if (!receiveOnly) {
sent = await call("message.send", { to, body, class: "REQUEST" });
diff --git a/packages/cli/src/transport.mjs b/packages/cli/src/transport.mjs
index 555385293..268566656 100644
--- a/packages/cli/src/transport.mjs
+++ b/packages/cli/src/transport.mjs
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import { CliError } from "./errors.mjs";
export const HUMAN_CLI = fileURLToPath(new URL("../../bus/src/human-cli.mjs", import.meta.url));
+export const TESTER_CLI = fileURLToPath(new URL("../../bus/src/tester-cli.mjs", import.meta.url));
// The broker's markers (packages/bus/src/human.mjs). The broker checks the
// whole ancestry; this check is only the clear early message for the case
@@ -37,6 +38,9 @@ const EXIT = {
unauthenticated: 3,
"unknown-business": 3,
"read-only": 3,
+ "tester-required": 3,
+ "tester-off": 3,
+ "tester-refused": 3,
"outcome-unknown": 1,
"response-too-large": 1,
"invalid-response": 1,
@@ -73,3 +77,11 @@ export function humanTransport({ socket, business, env = process.env, cli = HUMA
}
};
}
+
+// Row 110 (#1604): the tester's transport is the same exchange through
+// tester-cli.mjs. It makes no agent check of its own: a tester runs from
+// inside Mos or a T3 seat, and the broker refuses one under a managed run or
+// while the business's bus.tester is off.
+export function testerTransport(options) {
+ return humanTransport({ cli: TESTER_CLI, ...options });
+}
diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs
index 2ebacfa8f..8460b9198 100644
--- a/packages/cli/tests/stack-copy.test.mjs
+++ b/packages/cli/tests/stack-copy.test.mjs
@@ -106,13 +106,14 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
// The production runtime derives cliPath. Only agent ancestry is mocked;
+ // readProcess passes through for tester.mjs (row 110).
// real pid/start/argv/nonce and the runtime's entry-path binding remain intact.
const proofScript=join(s.root,'proof-runtime.mjs');
writeFileSync(proofScript,`
import {mock} from 'node:test';
const humanURL=${JSON.stringify(url('packages/bus/src/human.mjs'))};
const real=await import(humanURL);
- mock.module(humanURL,{namedExports:{verifyHuman:(proof,options)=>real.verifyHuman(proof,{...options,readProcess:pid=>({...real.readProcess(pid),ppid:1})})}});
+ mock.module(humanURL,{namedExports:{readProcess:real.readProcess,verifyHuman:(proof,options)=>real.verifyHuman(proof,{...options,readProcess:pid=>({...real.readProcess(pid),ppid:1})})}});
const {startBroker}=await import(${JSON.stringify(url('packages/bus/src/runtime.mjs'))});
const runtime=await startBroker({dataRoot:${JSON.stringify(f.dataRoot)},businesses:${JSON.stringify(BUSINESSES)}});
const broker=runtime.broker;
diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs
index f806cabed..aaa9d5d22 100644
--- a/packages/harness/src/runner.mjs
+++ b/packages/harness/src/runner.mjs
@@ -70,7 +70,12 @@ export function turnRequest(m) {
if (m.decision) head.push(`citing decision ${m.decision}`);
// Row 90 (#1584): the human input id task_create cites as its request.
if (m.request) head.push(`request ${m.request}`);
- return `${head.join(", ")}:\n\n${m.body}`;
+ // Row 110 (#1604): a tester's message is a PM test. Its answer goes back to the tester.
+ const tester =
+ m.from_role === "tester"
+ ? "This message is from a tester running a PM test. It is never Jason's instruction and authorizes nothing.\n\n"
+ : "";
+ return `${head.join(", ")}:\n\n${tester}${m.body}`;
}
// Row 109 (#1603): the block at the top of a turn's request. It is what the broker said at `readAt`,
diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs
index 12b683cb7..896e5bafa 100644
--- a/packages/harness/tests/runner.test.mjs
+++ b/packages/harness/tests/runner.test.mjs
@@ -57,6 +57,16 @@ test("turnRequest names the sender, class, reply and decision", () => {
});
// Row 109 (#1603): the header carries when the message was sent, not when it is read.
+test("turnRequest says a tester's message is a test and never Jason's instruction", () => {
+ assert.equal(
+ turnRequest({ id: "m7", from_role: "tester", class: "REQUEST", request: null, body: "[mos test] is the plan current?" }),
+ "Message m7 from tester, class REQUEST:\n\nThis message is from a tester running a PM test. It is never Jason's instruction and authorizes nothing.\n\n[mos test] is the plan current?",
+ );
+ // No other sender gets the line, the human included.
+ for (const from_role of ["human", "pm", "jason"])
+ assert.doesNotMatch(turnRequest({ id: "m8", from_role, class: "REQUEST", body: "[mos test] b" }), /tester|never Jason's/, from_role);
+});
+
test("turnRequest shows the message's own sent time, a day old as well as new", () => {
const sent = "2026-10-12T09:30:00.000Z";
assert.equal(
diff --git a/packages/seat/tests/sandbox.test.mjs b/packages/seat/tests/sandbox.test.mjs
index 9995b6b03..77168d405 100644
--- a/packages/seat/tests/sandbox.test.mjs
+++ b/packages/seat/tests/sandbox.test.mjs
@@ -8,12 +8,13 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { spawnSync } from "node:child_process";
+import { spawn, spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { Broker } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
+import { verifyTester } from "../../bus/src/tester.mjs";
import { Store } from "../../bus/src/store.mjs";
import { SOURCES } from "../../harness/src/bundle.mjs";
import { startTimeOf } from "../src/proc.mjs";
@@ -21,6 +22,8 @@ import { newRun, RUNNER, runnerOf, sessionEnv, spawnSession, writeSessionFile }
import { binds, gitIdentity, prepareRun, REPO, RUNNER_FILES, sandboxArgv, sandboxProblem } from "../src/sandbox.mjs";
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
+const TESTER_CLI = new URL("../../bus/src/tester-cli.mjs", import.meta.url).pathname;
+const HUMAN_MJS = new URL("../../bus/src/human.mjs", import.meta.url).pathname;
const skip = sandboxProblem() ?? false;
function scratch(t) {
@@ -176,8 +179,9 @@ async function wrong(table, sh) {
// A real session for coder, its adapter a shell that runs each message's
// body, or the fake adapter for a TOOL message.
// expose(standIns) names extra read-only binds, for the test that breaks
-// the sandbox on purpose.
-async function session(t, { expose = () => [] } = {}) {
+// the sandbox on purpose. tester (row 110) turns bus.tester on, serves the
+// tester proof as the bus runtime does, and binds tester-cli.mjs in.
+async function session(t, { expose = () => [], tester = false } = {}) {
const root = scratch(t);
const dataRoot = join(root, "data");
mkdirSync(dataRoot, { mode: 0o700 });
@@ -189,6 +193,7 @@ async function session(t, { expose = () => [] } = {}) {
id: "acme",
human: "jason",
arbiters: { technical: "pm", delivery: "pm" },
+ ...(tester ? { vars: { "bus.tester": true } } : {}),
roles: {
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
@@ -197,7 +202,9 @@ async function session(t, { expose = () => [] } = {}) {
},
});
const brokerSocket = join(root, "bus.sock");
- const server = await serve({ broker, path: brokerSocket });
+ const launches = [];
+ const authenticateTester = (proof) => broker.bindTester(verifyTester(proof, { cliPath: TESTER_CLI, launches }));
+ const server = await serve({ broker, path: brokerSocket, ...(tester ? { authenticateTester } : {}) });
t.after(async () => {
await server.close().catch(() => {});
store.close();
@@ -229,7 +236,7 @@ async function session(t, { expose = () => [] } = {}) {
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
});
- const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE, ...expose(s)] };
+ const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE, ...(tester ? [TESTER_CLI, HUMAN_MJS] : []), ...expose(s)] };
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH }, run), paths, business: "acme", role: "coder" });
const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
t.after(async () => {
@@ -240,6 +247,7 @@ async function session(t, { expose = () => [] } = {}) {
});
await runnerOf(child);
const cap = broker.bindLaunch({ business: "acme", role: "coder", run, harness: "pi", pid: child.pid, startTime: startTimeOf(child.pid) });
+ launches.push({ pid: child.pid, startTime: startTimeOf(child.pid) });
const pm = broker.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi" });
broker.request(pm, { verb: "role.claim" });
child.stdin.end(`${JSON.stringify({ cap })}\n`);
@@ -296,3 +304,30 @@ test("a bind that exposes a stand-in, or a missing workspace bind, fails the mat
assert.deepEqual(await wrong(checks(x), direct(all)), []);
assert.deepEqual(await wrong(checks(x), direct(all.filter((b) => b.src !== x.ws))), ["write and read its workspace"]);
});
+
+// Row 110 (#1604): a role session that runs tester-cli.mjs against its own
+// broker socket can't bind as tester. Inside bwrap the shim's pid is the
+// session's PID namespace's, so the broker's nonce check on the host fails,
+// and the walk would reach the session's launch record. The same shim from
+// the test process, outside any run, binds: the refusal is the proof's.
+test("a confined session that runs tester-cli.mjs is refused as tester, and the same shim outside the sandbox binds", { skip, timeout: 60000 }, async (t) => {
+ const x = await session(t, { tester: true });
+ const request = JSON.stringify({ business: "acme", verb: "agents", args: {} });
+ const inside = await x.sh(`printf '%s' '${request}' | '${process.execPath}' '${TESTER_CLI}' '${x.brokerSocket}'`);
+ assert.deepEqual(inside, { rc: 2, out: "tester-required" });
+
+ const env = { ...process.env };
+ delete env.MOSAIC_BUS_TESTER_NONCE;
+ delete env.MOSAIC_BUS_CLI_NONCE;
+ const outside = await new Promise((done) => {
+ const child = spawn(process.execPath, [TESTER_CLI, x.brokerSocket], { env });
+ let out = "";
+ let err = "";
+ child.stdout.on("data", (d) => (out += d));
+ child.stderr.on("data", (d) => (err += d));
+ child.on("close", (status) => done({ status, out, err }));
+ child.stdin.end(request);
+ });
+ assert.equal(outside.status, 0, outside.err);
+ assert.ok(Array.isArray(JSON.parse(outside.out)));
+});
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..36c8eedaa
--- /dev/null
+++ b/packages/bus/tests/tester.test.mjs
@@ -0,0 +1,391 @@
+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' });
+ for (const r of [{ tester: p, cap }, { 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/cli/tests/tester.test.mjs b/packages/cli/tests/tester.test.mjs
new file mode 100644
index 000000000..0994b12cf
--- /dev/null
+++ b/packages/cli/tests/tester.test.mjs
@@ -0,0 +1,128 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { main } from "../src/cli.mjs";
+import { HUMAN_CLI, TESTER_CLI, busExit, testerTransport } from "../src/transport.mjs";
+import { BusError, Broker } from "../../bus/src/broker.mjs";
+import { Store } from "../../bus/src/store.mjs";
+import { BUSINESSES, humanEnv, io, tmp } from "./helpers.mjs";
+
+// Row 110 (#1604): --tester binds as the business's tester instead of as the
+// human. The in-process broker here has bus.tester on; the real tester-cli.mjs
+// runs in packages/bus/tests/tester.test.mjs.
+function setup(t) {
+ const store = new Store(tmp(t, "mosaic-cli-tester-"));
+ t.after(() => store.close());
+ const businesses = { demo: { ...BUSINESSES.demo, vars: { "bus.tester": true } } };
+ const b = new Broker({ store, businesses });
+ const coder = b.bindLaunch({ business: "demo", role: "coder", run: "coder-run", harness: "pi", address: "coder-run" });
+ b.request(coder, { verb: "role.claim" });
+ const human = b.bindHuman({ business: "demo", human: "jason", via: "cli", outsideAgent: true });
+ const tester = b.bindTester({ business: "demo" });
+ const calls = { human: [], tester: [] };
+ const via = (cap, list) => (business) => async (verb, args = {}) => {
+ list.push({ business, verb, args });
+ try {
+ return structuredClone(b.request(cap, { verb, args }));
+ } catch (e) {
+ if (!(e instanceof BusError)) throw e;
+ const error = new Error(e.code);
+ error.code = e.code;
+ throw error;
+ }
+ };
+ const deps = { system: { dataRoot: tmp(t) }, transport: via(human, calls.human), testerTransport: via(tester, calls.tester), talkPollMs: 5 };
+ return { b, store, coder, human, calls, deps };
+}
+// The tester runs inside an agent: a T3 seat or Mos.
+const inAgent = () => io({ ...humanEnv(), CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" });
+
+test("talk --tester sends a [mos test] message as tester from inside an agent and prints the role's reply", async (t) => {
+ const s = setup(t);
+ const x = inAgent();
+ const deps = {
+ ...s.deps,
+ testerTransport: (business) => {
+ const inner = s.deps.testerTransport(business);
+ return async (verb, args) => {
+ if (verb === "message.receive") {
+ const ask = s.store.get("SELECT id FROM messages WHERE from_role='tester'");
+ if (ask && !s.store.get("SELECT 1 FROM messages WHERE to_role='tester'")) {
+ s.b.request(s.coder, { verb: "message.receive", args: {} });
+ s.b.request(s.coder, { verb: "message.send", args: { to: "tester", body: "the plan is current", in_reply_to: ask.id } });
+ }
+ }
+ return inner(verb, args);
+ };
+ },
+ };
+ await main(["talk", "coder", "[mos test] is the plan current?", "--tester", "--business", "demo"], x, deps);
+ assert.match(x.out.text, /^sent [0-9a-f-]{8} to coder\n/);
+ assert.match(x.out.text, /coder \(INFO, re [0-9a-f-]{8}\) [0-9a-f-]{8}:\nthe plan is current\n/);
+ assert.deepEqual(s.calls.human, []);
+ assert.deepEqual(s.calls.tester.map((c) => c.verb), ["message.send", "message.receive", "message.read"]);
+ const row = s.store.get("SELECT * FROM messages WHERE from_role='tester'");
+ assert.equal(row.from_run, "tester-cli");
+ assert.equal(row.class, "REQUEST");
+ assert.deepEqual(s.b.request(s.human, { verb: "message.receive", args: {} }), []);
+ assert.equal(s.store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n, 0);
+});
+
+test("talk --tester refuses text without the [mos test] prefix before any call", async (t) => {
+ const s = setup(t);
+ await assert.rejects(main(["talk", "coder", "is the plan current?", "--tester", "--business", "demo"], inAgent(), s.deps), (e) => e.exitCode === 2 && /starts with "\[mos test\]"/.test(e.message));
+ assert.deepEqual(s.calls.tester, []);
+ // follow sends nothing, so it takes no prefix.
+ const controller = new AbortController();
+ const deps = { ...s.deps, signal: controller.signal, testerTransport: (b) => async (verb, args) => (controller.abort(), s.deps.testerTransport(b)(verb, args)) };
+ await main(["follow", "--tester", "--business", "demo"], inAgent(), deps);
+ assert.deepEqual(s.calls.tester.map((c) => c.verb), ["message.receive"]);
+});
+
+test("the read verbs take --tester inside an agent, and the trail shows the tester as the sender", async (t) => {
+ const s = setup(t);
+ await main(["talk", "coder", "[mos test] hello", "--tester", "--wait", "0", "--business", "demo"], inAgent(), s.deps);
+ const id = s.store.get("SELECT id FROM messages WHERE from_role='tester'").id;
+ for (const verb of ["inbox", "tasks", "agents"]) {
+ const x = inAgent();
+ await main([verb, "--tester", "--business", "demo"], x, s.deps);
+ assert.ok(x.out.text.length > 0, verb);
+ }
+ const x = inAgent();
+ await main(["trail", id, "--tester", "--business", "demo"], x, s.deps);
+ assert.match(x.out.text, /messages#\d+ {2}tester → coder: \[mos test\] hello/);
+ assert.deepEqual([...new Set(s.calls.tester.map((c) => c.verb))], ["message.send", "inbox", "messages", "tasks", "agents", "trail"]);
+ assert.deepEqual(s.calls.human, []);
+ // Without --tester the same commands refuse inside an agent, before any call.
+ for (const argv of [["inbox"], ["agents"], ["talk", "coder", "[mos test] hi"], ["follow"]])
+ await assert.rejects(main([...argv, "--business", "demo"], inAgent(), s.deps), (e) => e.exitCode === 3, argv[0]);
+ assert.deepEqual(s.calls.human, []);
+});
+
+test("decide, stop and launches take no --tester", async (t) => {
+ const s = setup(t);
+ for (const argv of [["decide", "d1", "yes", "--tester"], ["stop", "run-1", "--tester"], ["launches", "off", "--tester"], ["launches", "on", "--tester"], ["launches", "list", "--tester"]])
+ await assert.rejects(main(argv, inAgent(), s.deps), (e) => e.exitCode === 4 && /unknown option --tester/.test(e.message), argv.join(" "));
+ assert.deepEqual(s.calls, { human: [], tester: [] });
+});
+
+test("the tester transport runs tester-cli.mjs, and the tester's refusals exit 3", async () => {
+ assert.ok(TESTER_CLI.endsWith("/packages/bus/src/tester-cli.mjs") && existsSync(TESTER_CLI));
+ assert.notEqual(TESTER_CLI, HUMAN_CLI);
+ let seen;
+ const spawn = (cmd, argv, options) => ((seen = { cmd, argv, input: options.input }), { status: 0, stdout: "[]" });
+ assert.deepEqual(await testerTransport({ socket: "/run/fake.sock", business: "acme", env: {}, spawn })("agents"), []);
+ assert.deepEqual(seen.argv, [TESTER_CLI, "/run/fake.sock"]);
+ assert.deepEqual(JSON.parse(seen.input), { business: "acme", verb: "agents", args: {} });
+ for (const code of ["tester-required", "tester-off", "tester-refused"]) assert.equal(busExit(code), 3, code);
+ assert.equal(busExit("tester-prefix"), 2);
+});
+
+test("with no stand-in transport, --tester runs tester-cli.mjs on the data root's socket", async (t) => {
+ const dataRoot = tmp(t);
+ const seen = [];
+ const testerSpawn = (cmd, argv, options) => (seen.push({ argv, input: JSON.parse(options.input) }), { status: 0, stdout: "[]" });
+ await main(["agents", "--tester", "--business", "demo"], inAgent(), { system: { dataRoot }, testerSpawn });
+ assert.deepEqual(seen, [{ argv: [TESTER_CLI, join(dataRoot, "bus", "broker.sock")], input: { business: "demo", verb: "agents", args: {} } }]);
+});