diff --git a/docs/TOOLS.md b/docs/TOOLS.md index fc38cefab..13366b63a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -351,6 +351,11 @@ check is expected to refuse this restored mixed setup; #225 remains open. Later rollback repeats steps 2-6 with the prior shared copy's full id, each live step again approved by Mos. Nothing prunes copies or removes mosaic-old. +After a switch, the human commands and `--tester` work only through `mosaic` +on PATH; the checkout's `scripts/mosaic` exits 3 with `human-required` or +`tester-required`, and queue writes stay on it ([packages/cli/README.md, One +pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)). + ## Console service (`scripts/webui-service.sh`) ```bash diff --git a/packages/cli/README.md b/packages/cli/README.md index fb4532c84..1757ef1b6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -86,7 +86,9 @@ mosaic launches list [--json] 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 + (`tester-off`), each exit 3. Like the human commands, `--tester` works + only from the tree the bus runs from ("One pinned copy" below). + `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). @@ -494,6 +496,15 @@ Each live switch/unit change/stop/start requires Mos's go; agents build and test fixtures only. Restarting the bus ends its sessions, so no PM turn may be in flight. Queue writes stay on the canonical scripts/mosaic. +The human commands and `--tester` run only from the tree the bus runs from +(row 130, #1625). The broker checks the path of the calling human-cli.mjs or +tester-cli.mjs against its own. On a host with a pinned copy that tree is +the copy, and `mosaic` on PATH is the wrapper that runs it. The checkout's +`scripts/mosaic` exits 3 with `human-required` or `tester-required` and +prints one more line: "the bus accepts these commands only from the tree it +runs from; on a pinned host run `mosaic` on PATH". Queue writes still go +through the checkout's `scripts/mosaic`. + ## Notifier The notifier is a child of the host. Each poll (every 30 s) it reads the diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index a547e36c7..a95eabaab 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -388,12 +388,27 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, throw usage(); } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +// The broker's human and tester proofs accept only the human-cli.mjs and +// tester-cli.mjs of the tree the bus runs from. Since row 85 that is a +// pinned copy, so the checkout's own scripts/mosaic is refused with these +// codes (row 130). The message stays the bare code: app.mjs reads it. +export const OTHER_TREE = "the bus accepts these commands only from the tree it runs from; on a pinned host run `mosaic` on PATH"; +const OTHER_TREE_CODES = new Set(["human-required", "tester-required"]); + +// main, with a CliError printed to stderr. Returns the exit code. +export async function run(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) { try { - await main(process.argv.slice(2)); + await main(argv, io, deps); + return 0; } catch (error) { if (!(error instanceof CliError)) throw error; - process.stderr.write(`mosaic: ${error.message}\n`); - process.exitCode = error.exitCode; + io.stderr.write(`mosaic: ${error.message}\n`); + if (OTHER_TREE_CODES.has(error.code)) io.stderr.write(`mosaic: ${OTHER_TREE}\n`); + return error.exitCode; } } + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const code = await run(process.argv.slice(2)); + if (code !== 0) process.exitCode = code; +} diff --git a/packages/cli/tests/other-tree.test.mjs b/packages/cli/tests/other-tree.test.mjs new file mode 100644 index 000000000..d00d0a9c1 --- /dev/null +++ b/packages/cli/tests/other-tree.test.mjs @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { OTHER_TREE, run } from "../src/cli.mjs"; +import { humanTransport } from "../src/transport.mjs"; +import { humanEnv, io, tmp } from "./helpers.mjs"; + +// Row 130 (#1625): the broker accepts the human and tester proofs only from +// the tree the bus runs from, so a refusal with human-required or +// tester-required gets one more stderr line naming the command that works. +// The spawn stands in for human-cli.mjs and tester-cli.mjs: it exits 1 with +// the code on stderr, and the real transport parses it. +const refusing = (code) => () => ({ status: 1, stdout: "", stderr: `${code}\n` }); +const HINT = `mosaic: ${OTHER_TREE}\n`; + +test("a human command refused with human-required prints the other-tree line and exits 3", async (t) => { + const x = io(); + const transport = (business) => humanTransport({ socket: "/nonexistent/bus.sock", business, env: {}, spawn: refusing("human-required") }); + const code = await run(["inbox", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport }); + assert.equal(code, 3); + assert.equal(x.err.text, `mosaic: human-required\n${HINT}`); + assert.equal(x.out.text, ""); +}); + +test("a --tester command refused with tester-required prints the other-tree line and exits 3", async (t) => { + const x = io({ ...humanEnv(), CLAUDECODE: "1" }); + const code = await run(["agents", "--tester", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, testerSpawn: refusing("tester-required") }); + assert.equal(code, 3); + assert.equal(x.err.text, `mosaic: tester-required\n${HINT}`); +}); + +test("other exit-3 refusals print no other-tree line", async (t) => { + const off = io({ ...humanEnv(), CLAUDECODE: "1" }); + assert.equal(await run(["agents", "--tester", "--business", "demo"], off, { system: { dataRoot: tmp(t) }, testerSpawn: refusing("tester-off") }), 3); + assert.equal(off.err.text, "mosaic: tester-off\n"); + + const agent = io({ ...humanEnv(), CLAUDECODE: "1" }); + assert.equal(await run(["inbox", "--business", "demo"], agent, { system: { dataRoot: tmp(t) } }), 3); + assert.match(agent.err.text, /^mosaic: refused: this runs only from a human shell/); + assert.doesNotMatch(agent.err.text, /tree it runs from/); +}); + +test("the line names no path", () => { + assert.doesNotMatch(OTHER_TREE, /\//); +});