bus: tester identity for PM tests (row 110, #1604)
A tester binds with a process proof from the bus's own tester-cli.mjs when the business var bus.tester is true. It may send [mos test] messages to a role, receive replies on the tester address and use the read verbs; every other verb refuses. Messages store from_role tester and write no human.input event. turnRequest() marks them as never Jason's instruction. human.mjs is unchanged. Candidate manifest bdaf7f476abd0b2b5095ab2f7f43e38540e6ba48ee14a02a4f4995a1469b79df (Darkwing round 2, packet9f1563ec1), BASEbadfa52b9. Approved by Filbert (comment 28360) and Sage (comment 28361). Sage gate rerun passed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+26
-17
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {} } }]);
|
||||
});
|
||||
Reference in New Issue
Block a user