From 179697a8a34c992af0844c04479249c3ebd2703a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 13 Oct 2026 20:43:40 -0500 Subject: [PATCH] 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, packet 9f1563ec1), BASE badfa52b9. Approved by Filbert (comment 28360) and Sage (comment 28361). Sage gate rerun passed. Co-Authored-By: Claude Opus 5.5 --- packages/bus/README.md | 2 + packages/bus/src/broker.mjs | 53 +++- packages/bus/src/client.mjs | 9 +- packages/bus/src/runtime.mjs | 10 + packages/bus/src/server.mjs | 11 +- packages/bus/src/tester-cli.mjs | 40 +++ packages/bus/src/tester.mjs | 73 +++++ packages/bus/tests/tester.test.mjs | 392 +++++++++++++++++++++++++ packages/business/README.md | 4 + packages/business/src/vars.mjs | 2 + packages/business/tests/vars.test.mjs | 9 +- packages/cli/README.md | 10 + packages/cli/src/cli.mjs | 43 +-- packages/cli/src/transport.mjs | 12 + packages/cli/tests/stack-copy.test.mjs | 3 +- packages/cli/tests/tester.test.mjs | 128 ++++++++ packages/harness/src/runner.mjs | 7 +- packages/harness/tests/runner.test.mjs | 10 + packages/seat/tests/sandbox.test.mjs | 45 ++- 19 files changed, 822 insertions(+), 41 deletions(-) create mode 100644 packages/bus/src/tester-cli.mjs create mode 100644 packages/bus/src/tester.mjs create mode 100644 packages/bus/tests/tester.test.mjs create mode 100644 packages/cli/tests/tester.test.mjs diff --git a/packages/bus/README.md b/packages/bus/README.md index b0c469263..18f3039dd 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -324,6 +324,8 @@ refuses `message-not-found`; malformed arguments refuse `invalid-request`. Roles get `human-required`. A successful read records nothing, so Console history reads never prevent a later `message.receive` from delivering it. +A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs ` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`. + ## Shared reads and adapter boundary ```js diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index ddc18f21c..64a544d4b 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -50,6 +50,10 @@ const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', export const TASK_PAGE = 50; export const TASK_PAGE_MAX = 200; export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.'))); +// Row 110 (#1604): what a tester session may do. Send to a role, receive and read the replies +// sent to `tester`, and the human's read views. Every other verb refuses tester-refused. +const TESTER_VERBS = ['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies']; +const TESTER_PREFIX = '[mos test]'; const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); const POLL_KINDS = new Set([ 'task.changed.external', @@ -133,7 +137,7 @@ export class Broker { if (!id(name) || b.id !== name || !id(b.human) || !object(b.roles) || !object(b.arbiters)) fail('invalid-business'); for (const role of Object.keys(b.roles)) { - if (!id(role) || role === 'human') fail('invalid-business'); + if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business'); const a = b.roles[role].authority; if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole)) fail('invalid-authority'); @@ -164,6 +168,7 @@ export class Broker { ); } #agent(s) { + if (s.tester) fail('tester-refused'); if (s.reader) fail('read-only'); if (s.human) fail('agent-required'); const h = this.#holder(s.business, s.role); @@ -254,6 +259,14 @@ export class Broker { fail('human-required'); return this.#cap({ ...record, role: null, run: null, human: b.human }); } + // Row 110 (#1604): the trusted tester transport invokes this only after checking its CLI process + // and that no managed run is among its ancestors. The business turns testing on with the business + // var `bus.tester`, exactly true; anything else refuses tester-off. + bindTester(record) { + keys(record, ['business'], ['business']); + if (this.#business(record.business).vars?.['bus.tester'] !== true) fail('tester-off'); + return this.#cap({ business: record.business, tester: true, role: 'tester', run: null, human: false }); + } bindReader({ business }) { this.#business(business); return this.#cap({ business, reader: true, role: null, run: null, human: false }); @@ -656,7 +669,10 @@ export class Broker { return this.#store.transaction(() => { if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state'].includes(request.verb)) fail('read-only'); - const result = this.#dispatch(s, request.verb, args); + if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused'); + // A tester reads the human's views, as a reader does, and its message verbs are its own. + const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s; + const result = this.#dispatch(view, request.verb, args); this.#secretCheck(result); return result; }); @@ -877,14 +893,29 @@ export class Broker { } case 'message.send': { keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']); - if (!s.human) { + if (s.tester) { + // A tester writes to a role only, says so in its first words, and cites no decision. + // It consumes no authority and writes no human.input. + keys(a, ['to', 'body', 'class', 'in_reply_to'], ['to', 'body']); + if (!Object.hasOwn(b.roles, a.to)) fail('unknown-role'); + if (typeof a.body !== 'string' || !a.body.startsWith(TESTER_PREFIX)) fail('tester-prefix'); + } else if (!s.human) { const withinRole = this.#classification(s, 'message.send') === 'within-role'; this.#consumeAuthority(s, 'message.send', { decision: withinRole ? null : (a.decision ?? null), target: a.to, }); - } else this.#human(s); - if (a.to !== 'human' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); + // A role answers a tester only in reply to a tester's message. + if ( + a.to === 'tester' && + !(id(a.in_reply_to) && this.#store.get("SELECT 1 FROM messages WHERE id=? AND business=? AND from_role='tester'", a.in_reply_to, s.business)) + ) + fail('tester-reply-required'); + } else { + this.#human(s); + if (a.to === 'tester') fail('unknown-role'); + } + if (a.to !== 'human' && a.to !== 'tester' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role'); string(a.body, 32768); if ( a.class !== undefined && @@ -915,7 +946,7 @@ export class Broker { this.#now(), s.business, s.human ? 'human' : s.role, - s.human ? 'human-cli' : s.run, + s.human ? 'human-cli' : s.tester ? 'tester-cli' : s.run, a.to, a.class ?? 'INFO', a.in_reply_to ?? null, @@ -933,7 +964,7 @@ export class Broker { case 'message.receive': { keys(a, []); if (s.human) this.#human(s); - else this.#agent(s); + else if (!s.tester) this.#agent(s); const role = s.human ? 'human' : s.role; const rows = this.#store.all( "SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) ORDER BY seq LIMIT 100", @@ -944,8 +975,8 @@ export class Broker { this.#delivery( m.id, 'delivered', - s.human ? null : this.#holder(s.business, s.role), - s.human ? 'cli' : 'broker', + s.human || s.tester ? null : this.#holder(s.business, s.role), + s.human || s.tester ? 'cli' : 'broker', ); return rows.map((m) => ({ ...m, @@ -960,7 +991,7 @@ export class Broker { case 'message.read': { keys(a, ['id'], ['id']); if (s.human) this.#human(s); - else this.#agent(s); + else if (!s.tester) this.#agent(s); const m = this.#store.get('SELECT * FROM messages WHERE business=? AND id=?', s.business, a.id); if (!m || m.to_role !== (s.human ? 'human' : s.role)) fail('message-not-found'); const delivery = this.#store.get( @@ -969,7 +1000,7 @@ export class Broker { ); if (!delivery || delivery.holder_run !== (s.run ?? null)) fail('not-recipient'); if (!this.#store.get("SELECT seq FROM deliveries WHERE message=? AND op='read'", m.id)) - this.#delivery(m.id, 'read', s.human ? null : this.#holder(s.business, s.role)); + this.#delivery(m.id, 'read', s.human || s.tester ? null : this.#holder(s.business, s.role)); return { read: true }; } // Row 93 (#1587): the coder asks a reviewer role to review the task's latest pushed commit. diff --git a/packages/bus/src/client.mjs b/packages/bus/src/client.mjs index 52d46be3d..11543b5b3 100644 --- a/packages/bus/src/client.mjs +++ b/packages/bus/src/client.mjs @@ -1,15 +1,20 @@ import { connect } from 'node:net'; import { BusError } from './broker.mjs'; export class Client { - constructor({ path, cap, human, timeout = 5000 }) { + constructor({ path, cap, human, tester, timeout = 5000 }) { this.path = path; this.cap = cap; this.human = human; + this.tester = tester; this.timeout = timeout; } call(verb, args = {}) { const request = - JSON.stringify({ ...(this.human ? { human: this.human } : { cap: this.cap }), verb, args }) + '\n'; + JSON.stringify({ + ...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : { cap: this.cap }), + verb, + args, + }) + '\n'; if (Buffer.byteLength(request) > 65536) return Promise.reject(new BusError('request-too-large')); return new Promise((resolve, reject) => { const socket = connect(this.path); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index a4912853f..dbb09e9b6 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -5,6 +5,7 @@ import { Broker, BusError } from './broker.mjs'; import { Credentials } from './credentials.mjs'; import { serve } from './server.mjs'; import { verifyHuman } from './human.mjs'; +import { verifyTester } from './tester.mjs'; // Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them. // Neither a business-file writer nor a socket-accessible configuration endpoint. // `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}. @@ -72,6 +73,15 @@ export async function startBroker({ if (!human) throw new BusError('unknown-business'); return broker.bindHuman({ business, human, via: 'cli', outsideAgent: true }); }, + // Row 110 (#1604): a tester proves its CLI process and that no managed run is above it. + // The broker then binds only if the business sets `bus.tester` to true. + authenticateTester: (proof) => { + const { business } = verifyTester(proof, { + cliPath: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)), + launches: launchRecords, + }); + return broker.bindTester({ business }); + }, }); // S6 launcher operations. The host decides capacity and the instance list; the broker // authorizes role.launch and keeps the evidence, refusals included. diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index 860d88bd0..b6517bfc3 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -4,7 +4,8 @@ import { BusError, TASK_VERBS } from './broker.mjs'; const LIMIT = 65536; // One request per connection. There is deliberately no reconnect/retry or SQL verb. // `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it. -export async function serve({ broker, path, authenticateHuman = null, timeout = 5000, tasks = null }) { +// A request carries exactly one of cap, human (row S4) and tester (row 110) proof. +export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, timeout = 5000, tasks = null }) { try { lstatSync(path); throw new BusError('socket-exists'); @@ -44,9 +45,9 @@ export async function serve({ broker, path, authenticateHuman = null, timeout = !r || Array.isArray(r) || typeof r !== 'object' || - Object.keys(r).some((k) => !['cap', 'human', 'verb', 'args'].includes(k)) || + Object.keys(r).some((k) => !['cap', 'human', 'tester', 'verb', 'args'].includes(k)) || typeof r.verb !== 'string' || - Boolean(r.cap) === Boolean(r.human) + [r.cap, r.human, r.tester].filter(Boolean).length !== 1 ) throw new BusError('invalid-envelope'); let cap = r.cap; @@ -54,6 +55,10 @@ export async function serve({ broker, path, authenticateHuman = null, timeout = if (!authenticateHuman) throw new BusError('human-required'); transient = cap = authenticateHuman(r.human); } + if (r.tester) { + if (!authenticateTester) throw new BusError('tester-required'); + transient = cap = authenticateTester(r.tester); + } if (typeof cap !== 'string') throw new BusError('unauthenticated'); if (tasks && TASK_VERBS.includes(r.verb)) { const own = transient; diff --git a/packages/bus/src/tester-cli.mjs b/packages/bus/src/tester-cli.mjs new file mode 100644 index 000000000..49a47fe51 --- /dev/null +++ b/packages/bus/src/tester-cli.mjs @@ -0,0 +1,40 @@ +// Row 110 (#1604): the tester's transport shim, human-cli.mjs with its own nonce variable and a +// tester proof. Not a second product CLI. Input and proof never carry service tokens. +import { randomBytes } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { Client } from './client.mjs'; +import { readProcess } from './human.mjs'; +const file = fileURLToPath(import.meta.url); +if (!process.env.MOSAIC_BUS_TESTER_NONCE) { + const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], { + stdio: 'inherit', + env: { ...process.env, MOSAIC_BUS_TESTER_NONCE: randomBytes(32).toString('hex') }, + }); + process.exit(child.status ?? 2); +} +try { + const request = JSON.parse(readFileSync(0, 'utf8')); + if ( + !request || + typeof request !== 'object' || + Object.keys(request).some((k) => !['business', 'verb', 'args'].includes(k)) + ) + throw Error(); + const me = readProcess(process.pid); + const client = new Client({ + path: process.argv[2], + tester: { + business: request.business, + pid: me.pid, + startTime: me.startTime, + nonce: process.env.MOSAIC_BUS_TESTER_NONCE, + }, + }); + const result = await client.call(request.verb, request.args ?? {}); + process.stdout.write(JSON.stringify(result) + '\n'); +} catch (e) { + process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n'); + process.exitCode = 2; +} diff --git a/packages/bus/src/tester.mjs b/packages/bus/src/tester.mjs new file mode 100644 index 000000000..01dea2eb6 --- /dev/null +++ b/packages/bus/src/tester.mjs @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { BusError } from './broker.mjs'; +import { readProcess } from './human.mjs'; +// Row 110 (#1604): the tester's proof. Same shape as the human's (pid, startTime, nonce, business), +// from its own CLI and its own nonce variable, so neither CLI's nonce proves the other. The walk +// refuses only a managed run: a launch record or a managed run's marker. An agent's markers and +// binaries are allowed, since a tester is meant to run from inside Mos or a T3 seat. +// Like verifyHuman, these are cooperative same-UID process checks, not a hostile-process wall. +const RUN_MARKERS = ['MOSAIC_BUS_CAP', 'MOSAIC_RUN_ID', 'MOSAIC_AGENT_RUN']; +const NONCE = 'MOSAIC_BUS_TESTER_NONCE='; +const refuse = () => { + throw new BusError('tester-required'); +}; +// readProcess keeps only the human's nonce and the markers, so the tester's nonce is read here. +export function testerNonce(pid, { readFile = readFileSync } = {}) { + try { + const entry = readFile(`/proc/${pid}/environ`, 'utf8') + .split('\0') + .find((e) => e.startsWith(NONCE)); + return entry === undefined ? null : entry.slice(NONCE.length); + } catch { + return null; + } +} +export function verifyTester( + proof, + { cliPath, launches = [], readProcess: read = readProcess, readNonce = testerNonce }, +) { + if ( + !proof || + typeof proof !== 'object' || + Object.keys(proof).some((k) => !['pid', 'startTime', 'nonce', 'business'].includes(k)) || + !Number.isSafeInteger(proof.pid) || + proof.pid < 2 || + typeof proof.business !== 'string' || + typeof proof.nonce !== 'string' || + !/^[a-f0-9]{64}$/.test(proof.nonce) + ) + refuse(); + try { + const nonce = readNonce(proof.pid); + const p = read(proof.pid); + if ( + p.uid !== process.getuid() || + p.startTime !== proof.startTime || + nonce !== proof.nonce || + p.argv[1] !== resolve(cliPath) + ) + refuse(); + const seen = new Set(); + let current = p; + for (let depth = 0; depth < 128; depth++) { + if (seen.has(current.pid)) refuse(); + seen.add(current.pid); + if ( + (current.env !== null && RUN_MARKERS.some((k) => current.env[k])) || + launches.some((r) => r.pid === current.pid && r.startTime === current.startTime) + ) + refuse(); + if (current.ppid === 1) { + const again = read(proof.pid); + if (again.startTime !== proof.startTime || again.ppid !== p.ppid) refuse(); + return { business: proof.business }; + } + if (current.ppid < 2) refuse(); + current = read(current.ppid); + } + } catch { + refuse(); + } + refuse(); +} diff --git a/packages/bus/tests/tester.test.mjs b/packages/bus/tests/tester.test.mjs new file mode 100644 index 000000000..244129537 --- /dev/null +++ b/packages/bus/tests/tester.test.mjs @@ -0,0 +1,392 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { connect } from 'node:net'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Store } from '../src/store.mjs'; +import { Broker, TASK_VERBS } from '../src/broker.mjs'; +import { serve } from '../src/server.mjs'; +import { Client } from '../src/client.mjs'; +import { startBroker } from '../src/runtime.mjs'; +import { readProcess, verifyHuman } from '../src/human.mjs'; +import { verifyTester, testerNonce } from '../src/tester.mjs'; + +// Row 110 (#1604): the bus tester identity for PM tests. +const roles = { + pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, + cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, +}; +const business = (vars) => ({ + demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles, ...(vars ? { vars } : {}) }, +}); +function setup(t, vars = { 'bus.tester': true }) { + const root = mkdtempSync(join(tmpdir(), 'bus-tester-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const b = new Broker({ store, businesses: business(vars) }); + const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); + b.request(pm, { verb: 'role.claim' }); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const reader = b.bindReader({ business: 'demo' }); + const call = (cap, verb, args = {}) => b.request(cap, { verb, args }); + return { b, store, pm, human, reader, call }; +} +const say = (body = 'is the plan current?') => `[mos test] ${body}`; + +test('the tester binds only when the business sets bus.tester to exactly true', (t) => { + for (const vars of [null, {}, { 'bus.tester': false }, { 'bus.tester': 'true' }, { 'bus.tester': 1 }]) { + const { b } = setup(t, vars); + assert.throws(() => b.bindTester({ business: 'demo' }), { code: 'tester-off' }, JSON.stringify(vars)); + } + const { b } = setup(t); + assert.match(b.bindTester({ business: 'demo' }), /^[0-9a-f]{64}$/); + assert.throws(() => b.bindTester({ business: 'other' }), { code: 'unknown-business' }); + assert.throws(() => b.bindTester({ business: 'demo', role: 'pm' }), { code: 'invalid-request' }); +}); + +test('a business may not name a role tester, so the tester address is never a role', (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-tester-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const businesses = business(); + businesses.demo.roles = { ...roles, tester: roles.pm }; + assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' }); + // No session can claim role tester: a launch must name a business role, and the tester + // session's own role.claim refuses. So #holder(business, 'tester') is always empty. + const s = setup(t); + assert.throws(() => s.b.bindLaunch({ business: 'demo', role: 'tester', run: 'tester-run', harness: 'pi' }), { code: 'unknown-role' }); + assert.throws(() => s.call(s.b.bindTester({ business: 'demo' }), 'role.claim'), { code: 'tester-refused' }); + assert.equal(s.store.get("SELECT count(*) n FROM role_claims WHERE role='tester'").n, 0); + assert.equal(s.store.get("SELECT count(*) n FROM role_claims WHERE role='pm'").n, 1); +}); + +test('every verb outside the tester list refuses tester-refused and is recorded against the tester', async (t) => { + const { b, store, call } = setup(t); + const tester = b.bindTester({ business: 'demo' }); + const refused = [ + ['role.claim', {}], + ['role.release', {}], + ['role.revoke', { role: 'pm' }], + ['decision.raise', { action: 'message.send', question: 'q', options: [], recommendation: 'no', blocking: false }], + ['decision.resolve', { id: 'd-1', choice: 'yes' }], + ['decision.seen', { id: 'd-1' }], + ['decision.withdraw', { id: 'd-1' }], + ['review.request', { task_ref: 'vikunja:1/1', role: 'cto' }], + ['review.verdict', { task_ref: 'vikunja:1/1', verdict: 'approve' }], + ['launch.revoke', {}], + ['launch.restore', {}], + ['event.emit', { kind: 'action.allowed', body: { action: 'routine' } }], + ['state', {}], + ['credentials', {}], + ['requirements', {}], + ]; + for (const [verb, args] of refused) assert.throws(() => call(tester, verb, args), { code: 'tester-refused' }, verb); + let handled = 0; + for (const verb of TASK_VERBS) + await assert.rejects( + b.requestTask(tester, { verb, args: {} }, async () => handled++), + { code: 'tester-refused' }, + verb, + ); + assert.equal(handled, 0); + const rows = store.all("SELECT actor_role, actor_run, body FROM events WHERE kind='action.refused'"); + assert.equal(rows.length, refused.length + TASK_VERBS.length); + assert.ok(rows.every((r) => r.actor_role === 'tester' && r.actor_run === null && JSON.parse(r.body).code === 'tester-refused')); + assert.equal(store.get("SELECT count(*) n FROM events WHERE kind IN ('launch.revoked','launch.restored','human.input')").n, 0); + // The tester gate runs before any argument check, so malformed arguments refuse tester-refused too. + for (const [verb] of refused) assert.throws(() => call(tester, verb, { bogus: 1 }), { code: 'tester-refused' }, `${verb} bogus`); +}); + +test('a tester message is from tester, starts [mos test], and writes no human.input or authority', (t) => { + const { b, store, pm, call } = setup(t); + const tester = b.bindTester({ business: 'demo' }); + assert.throws(() => call(tester, 'message.send', { to: 'pm', body: 'is the plan current?' }), { code: 'tester-prefix' }); + assert.throws(() => call(tester, 'message.send', { to: 'pm', body: ' [mos test] late prefix' }), { code: 'tester-prefix' }); + for (const to of ['human', 'tester', 'nobody']) + assert.throws(() => call(tester, 'message.send', { to, body: say() }), { code: 'unknown-role' }, to); + for (const extra of [{ corrects: 'm-1' }, { decision: 'd-1' }]) + assert.throws(() => call(tester, 'message.send', { to: 'pm', body: say(), ...extra }), { code: 'invalid-request' }); + const events = store.get('SELECT count(*) n FROM events WHERE kind<>?', 'action.refused').n; + const sent = call(tester, 'message.send', { to: 'pm', body: say(), class: 'REQUEST' }); + assert.equal(sent.request, null); + const row = store.get('SELECT * FROM messages WHERE id=?', sent.id); + assert.equal(row.from_role, 'tester'); + assert.equal(row.from_run, 'tester-cli'); + assert.equal(row.to_role, 'pm'); + assert.equal(store.get('SELECT count(*) n FROM events WHERE kind<>?', 'action.refused').n, events, 'no event at all'); + const got = call(pm, 'message.receive'); + assert.equal(got.length, 1); + assert.equal(got[0].from_role, 'tester'); + assert.equal(got[0].request, null); + // task.create needs a human.input event as its request (packages/tasks/src/verbs.mjs); a tester + // message gives none, so it can't back one. + assert.equal(b.taskView('demo', 'input', sent.id), false); + assert.equal(store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n, 0); +}); + +test('a role answers a tester only in reply to a tester message, and the answer never reaches the human', (t) => { + const { b, store, pm, human, reader, call } = setup(t); + const tester = b.bindTester({ business: 'demo' }); + const ask = call(tester, 'message.send', { to: 'pm', body: say() }).id; + const jason = call(human, 'message.send', { to: 'pm', body: 'a real instruction' }).id; + call(pm, 'message.receive'); + assert.throws(() => call(pm, 'message.send', { to: 'tester', body: 'answer' }), { code: 'tester-reply-required' }); + assert.throws(() => call(pm, 'message.send', { to: 'tester', body: 'answer', in_reply_to: jason }), { code: 'tester-reply-required' }); + assert.throws(() => call(pm, 'message.send', { to: 'tester', body: 'answer', in_reply_to: 'missing' }), { code: 'tester-reply-required' }); + assert.throws(() => call(human, 'message.send', { to: 'tester', body: 'hi', in_reply_to: ask }), { code: 'unknown-role' }); + const answer = call(pm, 'message.send', { to: 'tester', body: 'the plan is current', in_reply_to: ask }).id; + assert.equal(store.get('SELECT to_role FROM messages WHERE id=?', answer).to_role, 'tester'); + // Not in the human's inbox or the Console's Messages page. + assert.deepEqual(call(human, 'message.receive'), []); + for (const cap of [human, reader]) assert.equal(call(cap, 'messages').total, 0); + const got = call(tester, 'message.receive'); + assert.deepEqual(got.map((m) => [m.id, m.from_role, m.in_reply_to]), [[answer, 'pm', ask]]); + assert.deepEqual(call(tester, 'message.receive'), []); + assert.throws(() => call(tester, 'message.read', { id: jason }), { code: 'message-not-found' }); + assert.deepEqual(call(tester, 'message.read', { id: answer }), { read: true }); + const delivered = store.all('SELECT op, holder_run, transport FROM deliveries WHERE message=? ORDER BY seq', answer); + assert.deepEqual(delivered.map((d) => [d.op, d.holder_run, d.transport]), [ + ['routed', null, null], + ['delivered', null, 'cli'], + ['read', null, null], + ]); + // A role's own inbox is not the tester's. + assert.throws(() => call(pm, 'message.read', { id: answer }), { code: 'message-not-found' }); +}); + +test('the tester reads the human views a reader reads', (t) => { + const { b, pm, reader, call } = setup(t); + const tester = b.bindTester({ business: 'demo' }); + const ask = call(tester, 'message.send', { to: 'pm', body: say() }).id; + call(pm, 'message.receive'); + call(pm, 'message.send', { to: 'tester', body: 'answer', in_reply_to: ask }); + call(pm, 'message.send', { to: 'human', body: 'for jason' }); + for (const [verb, args] of [ + ['inbox', {}], + ['agents', {}], + ['tasks', {}], + ['trail', { subject: ask }], + ['decisions', { list: 'roles' }], + ['decisions', { list: 'recent' }], + ['messages', {}], + ['replies', { id: ask }], + ]) + assert.deepEqual(call(tester, verb, args), call(reader, verb, args), verb); + assert.equal(call(tester, 'replies', { id: ask }).length, 1); + assert.equal(call(tester, 'messages').total, 1); +}); + +// The process proof, on a process table like human.test.mjs's. +const testerCli = '/opt/mosaic/tester-cli.mjs'; +const humanCli = '/opt/mosaic/human-cli.mjs'; +const nonce = 'b'.repeat(64); +function table(cli = testerCli, env = { MOSAIC_BUS_TESTER_NONCE: nonce }) { + return new Map([ + [50, { pid: 50, ppid: 40, startTime: '100', uid: process.getuid(), argv: ['node', cli], env }], + [40, { pid: 40, ppid: 1, startTime: '90', uid: process.getuid(), argv: ['bash'], env: {} }], + ]); +} +const proof = { pid: 50, startTime: '100', nonce, business: 'demo' }; +const reader = (t) => (pid) => { + if (!t.has(pid)) throw Error('gone'); + return t.get(pid); +}; +const tester = (t, extra = {}) => + verifyTester(proof, { + cliPath: testerCli, + readProcess: reader(t), + readNonce: (pid) => t.get(pid)?.env?.MOSAIC_BUS_TESTER_NONCE ?? null, + ...extra, + }); + +test('tester proof binds its own CLI, start time and nonce, refuses a managed run and allows an agent', () => { + assert.equal(tester(table()).business, 'demo'); + for (const change of [{ startTime: '101' }, { uid: process.getuid() + 1 }, { argv: ['node', 'other.mjs'] }, { env: { MOSAIC_BUS_TESTER_NONCE: 'c'.repeat(64) } }]) { + const t = table(); + Object.assign(t.get(50), change); + assert.throws(() => tester(t), { code: 'tester-required' }, JSON.stringify(change)); + } + for (const env of [{ MOSAIC_RUN_ID: 'run-x' }, { MOSAIC_BUS_CAP: 'cap' }, { MOSAIC_AGENT_RUN: '1' }]) { + const t = table(); + t.get(40).env = env; + assert.throws(() => tester(t), { code: 'tester-required' }, Object.keys(env)[0]); + const self = table(); + Object.assign(self.get(50).env, env); + assert.throws(() => tester(self), { code: 'tester-required' }, `${Object.keys(env)[0]} on the CLI`); + } + assert.throws(() => tester(table(), { launches: [{ pid: 40, startTime: '90' }] }), { code: 'tester-required' }); + assert.equal(tester(table(), { launches: [{ pid: 40, startTime: '89' }] }).business, 'demo'); + // An agent above the tester is the intended case: Mos or a T3 seat. + for (const [argv, env] of [ + [['bash'], { CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'sdk-ts' }], + [['bash'], { CODEX_THREAD_ID: 'thread', PI_AGENT_DIR: '/x' }], + [['claude'], {}], + [['node', '/usr/lib/node_modules/codex/bin/codex.js'], {}], + [['pi'], {}], + ]) { + const t = table(); + Object.assign(t.get(40), { argv, env }); + assert.equal(tester(t).business, 'demo', argv.join(' ')); + } + const broken = table(); + broken.delete(40); + assert.throws(() => tester(broken), { code: 'tester-required' }); + // A loop refuses when it comes round, not at the depth bound. + const loop = table(); + loop.get(40).ppid = 50; + let reads = 0; + assert.throws(() => tester(loop, { readProcess: (pid) => (reads++, reader(loop)(pid)) }), { code: 'tester-required' }); + assert.ok(reads < 5, `${reads} reads`); + // A parent pid under 2 refuses even when /proc would answer for it. + const gone = table(); + gone.get(40).ppid = 0; + gone.set(0, { pid: 0, ppid: 1, startTime: '1', uid: process.getuid(), argv: ['init'], env: {} }); + assert.throws(() => tester(gone), { code: 'tester-required' }); + // The CLI is read again at the top: a changed start time or parent refuses. + for (const change of [{ startTime: '101' }, { ppid: 1 }]) { + const t = table(); + let first = true; + const again = (pid) => (pid === 50 && !first ? { ...t.get(50), ...change } : ((first = pid === 50 ? false : first), t.get(pid))); + assert.throws(() => tester(t, { readProcess: again }), { code: 'tester-required' }, JSON.stringify(change)); + } + // The first read's start time is checked on its own, not only by the re-read. + const once = table(); + let fresh = true; + const first = (pid) => (pid === 50 && fresh ? ((fresh = false), { ...once.get(50), startTime: '101' }) : once.get(pid)); + assert.throws(() => tester(once, { readProcess: first }), { code: 'tester-required' }); + // Each bad shape sits on a table where every other check passes, so only the shape check refuses. + const pid1 = table(); + pid1.set(1, { ...pid1.get(50), pid: 1 }); + const nonHex = table(testerCli, { MOSAIC_BUS_TESTER_NONCE: 'x' }); + for (const [bad, t] of [ + [null, table()], + [{ ...proof, extra: 1 }, table()], + [{ ...proof, pid: 1 }, pid1], + [{ ...proof, nonce: 'x' }, nonHex], + [{ ...proof, business: 3 }, table()], + ]) + assert.throws( + () => verifyTester(bad, { cliPath: testerCli, readProcess: reader(t), readNonce: (pid) => t.get(pid)?.env?.MOSAIC_BUS_TESTER_NONCE ?? null }), + { code: 'tester-required' }, + JSON.stringify(bad), + ); +}); + +test("neither CLI's nonce proves the other: each proof is bound to its own CLI and nonce variable", () => { + // A human CLI process, offered as a tester proof. + const human = table(humanCli, { MOSAIC_BUS_CLI_NONCE: nonce }); + assert.throws(() => tester(human), { code: 'tester-required' }); + assert.throws(() => tester(human, { cliPath: humanCli }), { code: 'tester-required' }, 'right path, wrong variable'); + // A tester CLI process, offered as a human proof. + const t = table(); + const human_ = (extra = {}) => verifyHuman(proof, { cliPath: humanCli, readProcess: reader(t), ...extra }); + assert.throws(() => human_(), { code: 'human-required' }); + assert.throws(() => human_({ cliPath: testerCli }), { code: 'human-required' }, 'right path, wrong variable'); + // Both variables set: the path still decides. + const both = table(testerCli, { MOSAIC_BUS_TESTER_NONCE: nonce, MOSAIC_BUS_CLI_NONCE: nonce }); + assert.throws(() => verifyHuman(proof, { cliPath: humanCli, readProcess: reader(both) }), { code: 'human-required' }); + assert.throws(() => tester(both, { cliPath: humanCli }), { code: 'tester-required' }); +}); + +test("testerNonce reads only the tester's variable and gives null on any read error", () => { + const io = (text) => ({ readFile: () => text }); + assert.equal(testerNonce(5, io(`A=1\0MOSAIC_BUS_TESTER_NONCE=${nonce}\0`)), nonce); + assert.equal(testerNonce(5, io(`MOSAIC_BUS_CLI_NONCE=${nonce}\0`)), null); + assert.equal(testerNonce(5, { readFile: () => { throw Object.assign(Error(), { code: 'EACCES' }); } }), null); + assert.equal(testerNonce(process.pid), null); +}); + +test('an envelope carries exactly one of cap, human and tester; no tester authenticator refuses', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-tester-')); + const store = new Store(root); + const b = new Broker({ store, businesses: business({ 'bus.tester': true }) }); + const path = join(store.directory, 'broker.sock'); + let calls = 0; + let bound = null; + const server = await serve({ broker: b, path, authenticateHuman: () => (calls++, 'x'), authenticateTester: () => (calls++, (bound = b.bindTester({ business: 'demo' }))) }); + const bare = join(store.directory, 'bare.sock'); + const bareServer = await serve({ broker: b, path: bare }); + t.after(async () => { + await server.close(); + await bareServer.close(); + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const raw = (at, request) => + new Promise((resolve, reject) => { + const s = connect(at); + let out = ''; + s.on('data', (d) => (out += d)); + s.on('end', () => resolve(JSON.parse(out))); + s.on('error', reject); + s.end(JSON.stringify(request) + '\n'); + }); + const p = { business: 'demo', pid: 2, startTime: '1', nonce }; + const cap = b.bindReader({ business: 'demo' }); + const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' }); + for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }]) + assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+')); + assert.equal(calls, 0); + assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' }); + assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] }); + assert.equal(calls, 1); + // The tester's capability lasts one request, as the human's does. + assert.throws(() => b.request(bound, { verb: 'agents' }), { code: 'unauthenticated' }); + assert.deepEqual(await new Client({ path, tester: p }).call('agents'), []); +}); + +// The real shim against a real runtime. The test process is not a managed run, so the proof +// passes here; a managed run's marker on the shim itself refuses. +const shim = fileURLToPath(new URL('../src/tester-cli.mjs', import.meta.url)); +// Async: the broker runs in this process, so a synchronous spawn would block its answer. +function run(socket, request, env = {}) { + const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k))); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [shim, socket], { env: { ...clean, ...env }, timeout: 20000 }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (err += d)); + child.on('error', reject); + child.on('close', (status) => resolve({ status, out: out.trim(), err: err.trim() })); + child.stdin.end(JSON.stringify(request)); + }); +} +async function runtime(t, vars, launches = []) { + const root = mkdtempSync(join(tmpdir(), 'bus-tester-rt-')); + const rt = await startBroker({ dataRoot: root, businesses: business(vars), readers: ['demo'], launches }); + t.after(async () => { + await rt.close(); + rmSync(root, { recursive: true, force: true }); + }); + return rt; +} + +test('tester-cli.mjs binds through the runtime when the switch is on, and refuses off or under a run marker', async (t) => { + const on = await runtime(t, { 'bus.tester': true }); + const reader = new Client({ path: on.path, cap: on.readers[0].cap }); + const sent = await run(on.path, { business: 'demo', verb: 'message.send', args: { to: 'pm', body: say() } }); + assert.equal(sent.status, 0, sent.err); + const id = JSON.parse(sent.out).id; + assert.equal((await reader.call('trail', { subject: id })).find((r) => r.table === 'messages').from_role, 'tester'); + assert.deepEqual(await run(on.path, { business: 'demo', verb: 'state' }), { status: 2, out: '', err: 'tester-refused' }); + for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) + assert.deepEqual(await run(on.path, { business: 'demo', verb: 'agents' }, { [marker]: 'x' }), { status: 2, out: '', err: 'tester-required' }, marker); + assert.equal((await run(on.path, { business: 'demo', verb: 'agents' }, { CLAUDECODE: '1' })).status, 0); + assert.deepEqual(await run(on.path, { business: 'demo', verb: 'agents', cap: 'x' }), { status: 2, out: '', err: 'invalid-request' }); + // This test process as a managed run's launch record: the shim it spawns has it as an ancestor. + const me = readProcess(process.pid); + const under = await runtime(t, { 'bus.tester': true }, [{ business: 'demo', role: 'cto', run: 'cto-run', harness: 'pi', pid: me.pid, startTime: me.startTime }]); + assert.deepEqual(await run(under.path, { business: 'demo', verb: 'agents' }), { status: 2, out: '', err: 'tester-required' }); + const off = await runtime(t, null); + assert.deepEqual(await run(off.path, { business: 'demo', verb: 'agents' }), { status: 2, out: '', err: 'tester-off' }); +}); diff --git a/packages/business/README.md b/packages/business/README.md index 8c7a43350..f36ded9eb 100644 --- a/packages/business/README.md +++ b/packages/business/README.md @@ -141,6 +141,10 @@ each layer that sets one narrows it, and the result also narrows the role definition. `limits.authority` is an allowlist, so a role action it leaves out becomes gated. +`bus.tester` is business-only and defaults to false. Set to `true`, the bus +binds a tester from `tester-cli.mjs` for PM tests; it reads the value at bus +start, so a change takes a bus restart. + `resolve` prints `provenance` beside `vars`. For a plain key it's the layer that set the value (`default`, `system`, `business:`, `project:`, `project::roles.`, diff --git a/packages/business/src/vars.mjs b/packages/business/src/vars.mjs index 69b6ea575..c7e8d0a67 100644 --- a/packages/business/src/vars.mjs +++ b/packages/business/src/vars.mjs @@ -96,6 +96,8 @@ export const REGISTRY = Object.freeze({ return v; }), "gitea.baseUrl": def(["business"], baseUrl), + // On: the bus binds a tester from tester-cli.mjs for PM tests (row 110). Read at bus start. + "bus.tester": def(["business"], boolean, { default: false }), // Project. "tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)), diff --git a/packages/business/tests/vars.test.mjs b/packages/business/tests/vars.test.mjs index ab7d2b3cd..b5db7ef8c 100644 --- a/packages/business/tests/vars.test.mjs +++ b/packages/business/tests/vars.test.mjs @@ -33,7 +33,7 @@ test("unknown keys and wrong layers refuse", () => { test("types", () => { const ok = (vars, layer) => assert.deepEqual({ ...checkVars(vars, layer, "v") }, vars); - ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678" }, "business"); + ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678", "bus.tester": true }, "business"); ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack", "tracker.descendants": true }, "project"); ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent"); @@ -53,6 +53,11 @@ test("types", () => { [{ "tracker.descendants": "true" }, "project", /true or false/], [{ "tracker.descendants": 1 }, "project", /true or false/], [{ "tracker.descendants": true }, "business", /business layer \(allowed: project\)/], + [{ "bus.tester": "true" }, "business", /true or false/], + [{ "bus.tester": 1 }, "business", /true or false/], + [{ "bus.tester": true }, "project", /project layer \(allowed: business\)/], + [{ "bus.tester": true }, "agent", /agent layer \(allowed: business\)/], + [{ "bus.tester": true }, "system", /system layer \(allowed: business\)/], [{ "git.workingBranch": "-x" }, "project", /git branch name/], [{ "git.workingBranch": "a..b" }, "project", /git branch name/], [{ "git.workingBranch": "x.lock" }, "project", /git branch name/], @@ -83,6 +88,8 @@ test("merge: defaults, then the most specific layer wins", () => { assert.equal(vars["tracker.kind"], "vikunja"); assert.equal(vars["tracker.descendants"], false); assert.equal(provenance["tracker.descendants"], "default"); + assert.equal(vars["bus.tester"], false); + assert.equal(provenance["bus.tester"], "default"); assert.equal(vars.harness, undefined); assert.equal(provenance.harness, undefined); }); diff --git a/packages/cli/README.md b/packages/cli/README.md index 3111fe03d..e23d8fb0d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -80,6 +80,16 @@ mosaic launches list [--json] the bus (see "Sessions" below). `stop` refuses inside an agent run; `launches list` does not, because the file is readable to the same user anyway. +- `--tester` (row 110, #1604) runs `inbox`, `tasks`, `agents`, `trail`, + `talk` and `follow` as the business's tester through + `packages/bus/src/tester-cli.mjs` instead of as the human. It is the one + exception to the agent check above: Mos or a T3 seat may run it for a PM + test, and the broker still refuses it under a managed run + (`tester-required`) or while the business var `bus.tester` isn't `true` + (`tester-off`), each exit 3. `talk --tester` text must start with + `[mos test]`, else exit 2 before any call. The role's reply comes back to + the tester, not to the human's inbox. `decide`, `stop` and `launches` + take no `--tester` (exit 4). ## The app diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index fcd7c0132..a547e36c7 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url"; import { BINDING_NAME } from "../../discord/src/binding.mjs"; import { CliError } from "./errors.mjs"; import { bootConfig, loadSystem, socketPath } from "./config.mjs"; -import { humanTransport, refuseInsideAgent } from "./transport.mjs"; +import { humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs"; import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs"; import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs"; import { createLauncher } from "./launcher.mjs"; @@ -24,13 +24,13 @@ import { readSessions, stopSession } from "../../seat/src/session.mjs"; import { SeatError } from "../../seat/src/seat.mjs"; export const USAGE = `usage: - mosaic inbox [--business ] [--json] + mosaic inbox [--business ] [--json] [--tester] mosaic decide