diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 2e862737d..6c44e8101 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -277,7 +277,8 @@ scripts/bus-service.sh uninstall | status The human commands read and resolve through the broker's human transport (`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside -an agent run. `bus start` is the host the systemd user unit +an agent run, except the forms that take `--tester` (rows 110 and 175), which +run as the business's tester; `packages/cli/README.md` lists them. `bus start` is the host the systemd user unit `mosaic-bus@` runs. It boots the broker, starts the Discord notifier, and needs `/notify//notify.json`. That directory must be 0700: it also holds the notifier's journal `sent.jsonl` diff --git a/packages/bus/README.md b/packages/bus/README.md index d4a884321..f8dc20c9a 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -192,8 +192,8 @@ an operator inspect content and delivery evidence after an uncertain result. | `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. | | `role.release` | No args; only its current holder releases. | | `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). | -| `organization.set` | `{revision,limits?,models?,instances?,assistants?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `.`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits` and `limits.set` stay human-only. `assistants` (row 155, Q169) is described in the business README; a refusal from it passes through as its `organization-*` code. The chief of staff learns the revision from `organization.revision` (below); a stale one is `revision-conflict`. | -| `organization.revision` | No args (row 134, #1669). Answers `{revision}`, the organization file's current revision, and nothing else the human's `limits` read returns: no limits, models, instances or tokens. Needs `organization.change` in the caller's role file, with the same refusals as `organization.set` (`not-holder`, `read-only`, `tester-refused`, `agent-required` for the human, `decision-required` for a role whose only classification is cross-role; no shipped role is). A read never spends the grant and writes no receipt. Any argument is `invalid-request`. `limits` stays human-only. | +| `organization.set` | `{revision,limits?,models?,instances?,assistants?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `.`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits.set` stays human-only, and a tester gets `tester-refused` from `organization.set` (row 175, #1673). `assistants` (row 155, Q169) is described in the business README; a refusal from it passes through as its `organization-*` code. The chief of staff learns the revision from `organization.revision` (below); a stale one is `revision-conflict`. | +| `organization.revision` | No args (row 134, #1669). Answers `{revision}`, the organization file's current revision, and nothing else the human's `limits` read returns: no limits, models, instances or tokens. Needs `organization.change` in the caller's role file, with the same refusals as `organization.set` (`not-holder`, `read-only`, `tester-refused`, `agent-required` for the human, `decision-required` for a role whose only classification is cross-role; no shipped role is). A read never spends the grant and writes no receipt. Any argument is `invalid-request`. `limits` stays human-only, apart from the tester's read (row 175). | | `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and an uncited gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. | | `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). | | `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. | @@ -201,7 +201,7 @@ an operator inspect content and delivery evidence after an uncertain result. | `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`, plus `waiting` when no run holds the target (Launch notices, below). Human instruction's `request` is its `human.input` event ID. `to` is a role instance, its name (row 141, #1635, REQ-MSG-2), `human` or `tester`. A name resolves to its instance before any check; the message stores the instance in `to_role` and the name in `to_name`, null for a send by instance. An instance key, `human`, `tester` and a role definition the business knows win over a name equal to them, so an instance added later with a key some instance holds as a name still gets its messages, and a send to a later definition's name refuses `unknown-role`. A name the business doesn't hold refuses `unknown-role`. | | `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. A human decision close's message carries the close's `human.input` answer event as its `request`; a role close's has `null`. | | `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. | -| `name.set` | `{instance,name}`; action `instance.name`, routed under REQ-MSG-2 (row 141, #1635). The outside-agent human CLI may; an agent only when its role file grants `instance.name` within-role, which the chief of staff's does, and the write logs `action.allowed` with the instance as target. Any other class refuses `not-authorized`, so no decision sets a name. `name` matches the business ID pattern. It refuses `name-is-instance` when it equals an instance key, `human` or `tester`, `name-is-role` when it equals a role definition the loaded business knows (one an instance uses or one it defines), and `name-taken` when another instance of the business holds it now. The `instance_names` trigger backs up the last. Naming an instance again replaces its name. Returns `{instance,name}`. | +| `name.set` | `{instance,name}`; action `instance.name`, routed under REQ-MSG-2 (row 141, #1635). The outside-agent human CLI may; an agent only when its role file grants `instance.name` within-role, which the chief of staff's does, and the write logs `action.allowed` with the instance as target. A tester may too (row 175, #1673): every name check runs, no authority is spent and `by` is `tester`. Any other class refuses `not-authorized`, so no decision sets a name. `name` matches the business ID pattern. It refuses `name-is-instance` when it equals an instance key, `human` or `tester`, `name-is-role` when it equals a role definition the loaded business knows (one an instance uses or one it defines), and `name-taken` when another instance of the business holds it now. The `instance_names` trigger backs up the last. Naming an instance again replaces its name. Returns `{instance,name}`. | | `assistant.stop` | `{instance}`; outside-agent human CLI only (`human-required`), row 155 (#1649, Q169). `instance` must be a known instance (`unknown-role`) of `THREAD_DEFINITION` (`not-assistant`). Revokes the current claim of every thread of that instance the human made (REQ-USER-2), with `by` the human and reason `stopped by the human`; none held is `not-held`. Each run gets `not-holder` at its next bus call and exits. The launcher starts a run again only for a thread with pending messages or an open. Returns `{stopped:[{thread,run}]}`. | | `names` | No args; human and agent sessions. Each named instance's current `{instance,name,at}`, by instance. | | `staff.request` | `{task_ref,roles,note?,decision?}`; action `message.send` (row 140, #1634, REQ-COS-1). `roles` is 1 to 10 distinct `{role,count}` entries, `count` an integer 1 to 20; any other shape is `invalid-request`. Only an instance of the `pm` definition sends it (`staff-request-refused`), and it goes to the business's launcher, which must be an instance of `chief-of-staff` (`no-chief-of-staff`); the definitions' `messaging` lists apply as for `message.send`. Sends that instance a STAFF-REQUEST message naming the task and the roles with counts, and writes an `action.allowed` event with `operation: staff.request`, `staff_for` and `roles`. `message.send` and `thread.send` refuse the class `invalid-class`, so no other path writes one. It grants nothing: the chief of staff launches through `role.launch` within the organization's limits and replies to the message with the instances it launched or the refusal. Returns `{message,to}`. | @@ -582,21 +582,23 @@ 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. -`instances` and `instances.set` (row 165, #1662, Q171, REQ-ROLE-3) are human-only. -A role session, a reader and a tester each get `human-required`, and the chief of staff +`instances` and `instances.set` (row 165, #1662, Q171, REQ-ROLE-3) are the human's and, +since row 175 (#1673), the tester's. A role session and a reader get `human-required`, and the chief of staff does too: neither verb is in `ORGANIZATION_KEYS`, so `organization.set` refuses the instance keys with `invalid-request` before the writer runs (sage-0918). `instances` takes no args and answers the business's instances, their definitions, vars and launch membership, plus the definitions `instances.set` accepts. `instances.set` takes `{revision,instance,op,definition?, vars?,launch?}`. `op` is `add` or `change`, and every other key is `invalid-request`. The business package's `instance-settings.mjs` writes through the organization writer, which -logs the human as the actor. Its refusals pass through as `organization-*` codes, among them +logs the human as the actor, or `tester` for a tester's write. Its refusals pass through as `organization-*` codes, among them `organization-credentials-out-of-scope` for a definition that declares credentials. The answer carries `restart: true` after an add or a definition change: the broker keeps the roles it loaded at start, so the change counts only after a restart that needs Mos's go or Jason's hand. Tests: `packages/bus/tests/instances-verb.test.mjs`. -`routes` and `routes.set` (row 65, #1558, mos-0840) are human-only the same way, and outside +`routes` and `routes.set` (row 65, #1558, mos-0840) are human-only the same way, except that a +tester may send `routes.set` (row 175), logged with actor `tester`; its `routes` read refuses +`tester-refused`. Both are outside `ORGANIZATION_KEYS`, so the chief of staff's `change_organization` can't send a `routes` key. The chief reaches a route only through the assistant's whole vars (Q169), and that write runs the same route checks. `routes` takes no args and answers the routing table; @@ -607,7 +609,7 @@ writes them with the human as actor (`packages/business/README.md`, "Routes"). ` from `harnessLevels()`, which loads each harness module in the tree once at start, switched on or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mjs`. -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`. +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`. Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`, `limits`, `plugins`, `instances`, `project.list` and `thread.list`, each answering what the human's answers (a tester has no threads of its own, so `thread.list` lists the business human's), and the writes `name.set` (`by` `tester`), `project.create` (`created_by` `tester`), `plugins.set`, `instances.set` and `routes.set`, each with every check the human's write runs and the organization receipt's actor `tester`. `TESTER_VERBS` and `TESTER_ORGANIZATION_VERBS` in `src/broker.mjs` list them; every other verb refuses `tester-refused`, among them `limits.set`, `organization.set`, `organization.revision`, `routes`, `decision.resolve`, `launch.revoke`, `launch.restore`, the credential verbs, `project.file.save`, `assistant.stop` and the thread verbs other than `thread.list`. Tests: `tests/tester-writes.test.mjs`. It sends only to a role, with any body 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 @@ -801,8 +803,8 @@ transient proof until completion. Other request paths stay synchronous. Row 148 (#1642) adds three human-only verbs for the Console's spec and docs editor: `project.files {project}`, `project.file {project,path}` and `project.file.save -{project,path,base,content}`. Each refuses an agent session, a reader and a tester -(`human-required`) and takes exactly those keys; the two file verbs refuse a path +{project,path,base,content}`. Each refuses an agent session and a reader +(`human-required`), refuses a tester `tester-refused` (row 175, #1673), and takes exactly those keys; the two file verbs refuse a path other than spec/north-star.md or docs/NAME.md. `project.file` answers `{path,head,commit,content, author,at}`; `project.file.save` answers `{path,commit}` after the project package's `writeHumanFile` made its one commit, or a `stale-base` refusal that wrote nothing. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index c327feed2..571c55260 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -92,8 +92,13 @@ export const WEB_UNTRUSTED = 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]'; +// Row 175 (#1673, Q173): the test system's reads and two writes, name.set and project.create, +// each recorded with `tester` as its actor. The organization branch keeps its own list below. +export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies', + 'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create']); +// Row 175: the organization verbs a tester may call. Its writes run every check the human's do +// and log actor `tester`; limits.set, organization.set, routes and organization.revision refuse it. +export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set']); const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); const POLL_KINDS = new Set([ 'task.changed.external', @@ -1230,7 +1235,9 @@ export class Broker { // to a role whose file lists the same grant. A read never spends the grant. const revisionRead = request.verb === "organization.revision"; const role = request.verb === "organization.set" || revisionRead; - if (!role && !s.human) fail("human-required"); + // Row 175 (#1673, Q173): the tester gate runs before any argument check, as at the store below. + if (s.tester && !TESTER_ORGANIZATION_VERBS.includes(request.verb)) fail("tester-refused"); + if (!role && !s.human && !s.tester) fail("human-required"); if (!this.#organization) fail("not-configured"); if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []); else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]); @@ -1243,7 +1250,7 @@ export class Broker { // runs the same route checks. else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]); else keys(args, ORGANIZATION_KEYS, ["revision"]); - let actor = s.human; + let actor = s.tester ? "tester" : s.human; // This branch runs outside the store transaction below, so the grant is checked before the writer // and consumed only once the writer has committed: a refused change logs action.refused alone, // as a refused human limits.set does. @@ -1401,6 +1408,9 @@ export class Broker { const { verb, args } = request; let ProjectFailure; try { + // Row 175 (#1673): these verbs dispatch before the store's tester gate, so they refuse a + // tester here, tester-refused like every other human-only verb. + if (s.tester) fail('tester-refused'); this.#human(s); if (!this.#project?.listFiles) fail('project-not-configured'); const { listFiles, readFile, writeFile, ProjectError } = this.#project; @@ -1805,11 +1815,10 @@ export class Broker { ? this.#named(s.business, a.to) : null; const to = named ?? a.to; 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. + // A tester writes to a role only and cites no decision. It consumes no authority and + // writes no human.input. Row 175 (mos-0872 Q1): the test system needs no body prefix. keys(a, ['to', 'body', 'class', 'in_reply_to'], ['to', 'body']); if (!Object.hasOwn(b.roles, 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', { @@ -2207,9 +2216,11 @@ export class Broker { // refuses here, so neither a cross-role grant nor a decision can set a name. A name is unique // in the business (the store's trigger backs this up) and equals no instance key, no role // definition, "human" or "tester", so `to` never means two things. + // Row 175 (#1673, Q173): the tester names an instance too, with every name check, no + // authority consumed and `by` recorded as tester. case 'name.set': { keys(a, ['instance', 'name'], ['instance', 'name']); - if (!s.human && this.#classification(s, 'instance.name', a.instance) !== 'within-role') { + if (!s.human && !s.tester && this.#classification(s, 'instance.name', a.instance) !== 'within-role') { this.#agent(s); fail('not-authorized'); } @@ -2220,14 +2231,14 @@ export class Broker { if (this.#definitions(b).has(a.name)) fail('name-is-role'); const holder = this.#named(s.business, a.name); if (holder && holder !== a.instance) fail('name-taken'); - if (!s.human) this.#consumeAuthority(s, 'instance.name', { target: a.instance }); + if (!s.human && !s.tester) this.#consumeAuthority(s, 'instance.name', { target: a.instance }); this.#store.run( 'INSERT INTO instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)', this.#now(), s.business, a.instance, a.name, - s.human || s.run, + s.tester ? 'tester' : s.human || s.run, ); return { instance: a.instance, name: a.name }; } @@ -2246,7 +2257,7 @@ export class Broker { } case 'names': { keys(a, []); - if (!s.human) this.#agent(s); + if (!s.human && !s.tester) this.#agent(s); return this.#store.all( 'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance', s.business, @@ -2254,8 +2265,9 @@ export class Broker { } // Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a // project from the business file; the rename to repos is a P19 follow-up. + // Row 175 (#1673, Q173): the tester creates and lists projects too, created_by tester. case 'project.create': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, ['name', 'repo'], ['name']); if (!name(a.name)) fail('invalid-request'); if (a.repo !== undefined && !Object.hasOwn(b.projects ?? {}, identifier(a.repo))) fail('unknown-repo'); @@ -2269,12 +2281,12 @@ export class Broker { s.business, a.name, a.repo ?? null, - s.human, + s.tester ? 'tester' : s.human, ); return { id: pid }; } case 'project.list': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, []); return this.#store.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business); } @@ -2303,8 +2315,10 @@ export class Broker { ); return { id: tid, instance: instances[0] }; } + // Row 175: a tester has no threads of its own (row 177 adds them), so it lists the + // business human's, the list the human sees. case 'thread.list': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, ['project']); if (a.project !== undefined) identifier(a.project); return this.#store.all( @@ -2312,7 +2326,7 @@ export class Broker { (a.project === undefined ? '' : ' AND project=?') + ' ORDER BY seq', s.business, - s.human, + s.tester ? b.human : s.human, ...(a.project === undefined ? [] : [a.project]), ); } diff --git a/packages/bus/tests/instances-verb.test.mjs b/packages/bus/tests/instances-verb.test.mjs index a4950b8e1..3f5546369 100644 --- a/packages/bus/tests/instances-verb.test.mjs +++ b/packages/bus/tests/instances-verb.test.mjs @@ -21,14 +21,15 @@ const biz = () => ({ }); const ADD = { revision: 'a'.repeat(64), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }; -test('row 165: instances and instances.set refuse every non-human session, the chief of staff included, and pass the verified actor', (t) => { +test('row 165: instances and instances.set refuse every non-human session but the tester, the chief of staff included, and pass the verified actor', (t) => { const root = mkdtempSync(join(tmpdir(), 'instances-verb-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const calls = []; const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); - const caps = { reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }) }; + const tester = b.bindTester({ business: 'demo' }); + const caps = { reader: b.bindReader({ business: 'demo' }) }; for (const role of ['chief', 'pm']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' }); b.request(caps[role], { verb: 'role.claim' }); @@ -50,7 +51,11 @@ test('row 165: instances and instances.set refuse every non-human session, the c assert.equal(calls.length, 0); b.request(human, { verb: 'instances' }); b.request(human, { verb: 'instances.set', args: ADD }); - assert.deepEqual(calls, [['demo', 'instances', {}, 'jason'], ['demo', 'instances.set', ADD, 'jason']]); + // Row 175 (#1673): the tester reads and writes instances through the same checks, as `tester`. + assert.throws(() => b.request(tester, { verb: 'instances.set', args: { ...ADD, actor: 'forged' } }), /invalid-request/); + b.request(tester, { verb: 'instances' }); + b.request(tester, { verb: 'instances.set', args: ADD }); + assert.deepEqual(calls, [['demo', 'instances', {}, 'jason'], ['demo', 'instances.set', ADD, 'jason'], ['demo', 'instances', {}, 'tester'], ['demo', 'instances.set', ADD, 'tester']]); }); test('row 165 runtime: an add and a change reach the organization file with the human as actor; each refusal keeps the bytes and logs nothing', async (t) => { diff --git a/packages/bus/tests/limits.test.mjs b/packages/bus/tests/limits.test.mjs index eaa8e7059..4dc548c6c 100644 --- a/packages/bus/tests/limits.test.mjs +++ b/packages/bus/tests/limits.test.mjs @@ -6,13 +6,15 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {Broker} from '../src/broker.mjs'; import {Store} from '../src/store.mjs'; -test('limits binds actor from human proof and rejects role, reader, tester and forged actor',t=>{ +test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{ const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[]; const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}}); const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}}; - for(const cap of [role,reader,tester])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/); + for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/); + // Row 175 (#1673): the tester reads limits; limits.set stays the human's. + assert.throws(()=>b.request(tester,{verb:'limits.set',args:patch}),/tester-refused/); assert.equal(calls.length,0);assert.throws(()=>b.request(human,{verb:'limits.set',args:{...patch,actor:'forged'}}),/invalid-request/); - b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason']]); + b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});b.request(tester,{verb:'limits'});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason'],['demo','limits',{},'tester']]); }); test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{ const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {readFileSync}=await import('node:fs'); diff --git a/packages/bus/tests/names.test.mjs b/packages/bus/tests/names.test.mjs index fe80499da..c4885a40a 100644 --- a/packages/bus/tests/names.test.mjs +++ b/packages/bus/tests/names.test.mjs @@ -96,8 +96,10 @@ test('REQ-MSG-2: a role without the grant, or holding it cross-role, is refused /invalid-request/, ); assert.throws(() => call('reader', 'name.set', { instance: 'dev', name: 'rex' }), /read-only/); - assert.throws(() => call('tester', 'name.set', { instance: 'dev', name: 'rex' }), /tester-refused/); assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0); + // Row 175 (#1673): the tester sets a name like the human, recorded by `tester`. + call('tester', 'name.set', { instance: 'dev', name: 'rex' }); + assert.equal(store.get('SELECT by FROM instance_names').by, 'tester'); }); test('REQ-MSG-2: a name is unique in the business and is never an instance key or a role name', (t) => { diff --git a/packages/bus/tests/routes-verb.test.mjs b/packages/bus/tests/routes-verb.test.mjs index 192e2bb05..aebe29d50 100644 --- a/packages/bus/tests/routes-verb.test.mjs +++ b/packages/bus/tests/routes-verb.test.mjs @@ -30,17 +30,20 @@ function scratch(t, prefix) { return root; } -test('row 65: routes and routes.set refuse every non-human session, the chief of staff included, and pass the verified actor', (t) => { +test('row 65: routes and routes.set refuse every non-human session, the chief of staff included, the tester may only set, and each passes the verified actor', (t) => { const store = new Store(scratch(t, 'routes-verb-')); t.after(() => store.close()); const calls = []; const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) }); const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); - const caps = { reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }) }; + const tester = b.bindTester({ business: 'demo' }); + const caps = { reader: b.bindReader({ business: 'demo' }) }; for (const role of ['chief', 'pm']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' }); b.request(caps[role], { verb: 'role.claim' }); } + // Row 175 (#1673): the tester sets routes but never reads them (routes stays the human's). + assert.throws(() => b.request(tester, { verb: 'routes', args: {} }), /tester-refused/); for (const [who, cap] of Object.entries(caps)) for (const verb of ['routes', 'routes.set']) assert.throws(() => b.request(cap, { verb, args: verb === 'routes' ? {} : SET }), /human-required/, `${who} ${verb}`); @@ -57,7 +60,9 @@ test('row 65: routes and routes.set refuse every non-human session, the chief of assert.equal(calls.length, 0); b.request(human, { verb: 'routes' }); b.request(human, { verb: 'routes.set', args: SET }); - assert.deepEqual(calls, [['demo', 'routes', {}, 'jason'], ['demo', 'routes.set', SET, 'jason']]); + assert.throws(() => b.request(tester, { verb: 'routes.set', args: { ...SET, actor: 'forged' } }), /invalid-request/); + b.request(tester, { verb: 'routes.set', args: SET }); + assert.deepEqual(calls, [['demo', 'routes', {}, 'jason'], ['demo', 'routes.set', SET, 'jason'], ['demo', 'routes.set', SET, 'tester']]); }); test('row 65: a new launch binds only on a switched-on harness with a module name, and a run an earlier broker bound binds again after its harness is switched off', (t) => { diff --git a/packages/bus/tests/tester-writes.test.mjs b/packages/bus/tests/tester-writes.test.mjs new file mode 100644 index 000000000..a9d1bce36 --- /dev/null +++ b/packages/bus/tests/tester-writes.test.mjs @@ -0,0 +1,201 @@ +// Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the test system's tester. It reads what the human +// reads, makes seven writes recorded with actor `tester`, and every other human action still +// refuses it tester-refused. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../src/store.mjs'; +import { Broker, TESTER_VERBS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs'; +import { startBroker } from '../src/runtime.mjs'; +import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs'; + +const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; +const READS = [['usage', {}], ['names', {}], ['limits', {}], ['plugins', {}], ['instances', {}], ['project.list', {}], ['thread.list', {}]]; + +test('row 175: TESTER_VERBS and the organization branch tester set are pinned', () => { + assert.deepEqual([...TESTER_VERBS], [ + 'message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies', + 'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', + ]); + assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set']); + assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS)); +}); + +// A real organization file and writer behind the bus, as routes-verb.test.mjs's runtime test. +async function runtime(t) { + const root = mkdtempSync(join(tmpdir(), 'tester-writes-')); + const rolesDir = rolesCopy(root); + const doc = businessDoc(root, 'demo'); + doc.plugins = { ...doc.plugins, harness: ['pi'] }; + for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars }; + const file = join(root, 'businesses/demo.json'); + writeJson(file, doc); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + const bus = await startBroker({ + dataRoot: join(root, 'data'), + businesses: { + demo: { + id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, + roles: { coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } }, + }, + }, + organization: { dir: root, rolesDir, system: systemFor(root) }, + }); + t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); + const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const tester = bus.broker.bindTester({ business: 'demo' }); + const call = (cap, verb, args = {}) => bus.broker.request(cap, { verb, args }); + const code = (cap, verb, args = {}) => { try { call(cap, verb, args); } catch (e) { return e.code; } return null; }; + const history = join(root, 'businesses/.history/demo/changes.jsonl'); + const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []); + const bytes = () => readFileSync(file, 'utf8'); + return { root, file, bus, human, tester, call, code, log, bytes }; +} + +test('row 175: each of the seven reads as the tester deep-equals the human read', async (t) => { + const r = await runtime(t); + r.call(r.human, 'name.set', { instance: 'coder', name: 'rex' }); + const project = r.call(r.human, 'project.create', { name: 'alpha' }).id; + r.call(r.human, 'thread.create', { project, name: 'one' }); + for (const [verb, args] of READS) assert.deepEqual(r.call(r.tester, verb, args), r.call(r.human, verb, args), verb); + // Not empty, so the tester's thread.list owner is the business human's and names has a row. + assert.equal(r.call(r.tester, 'thread.list').length, 1); + assert.deepEqual(r.call(r.tester, 'thread.list', { project }).map((x) => x.name), ['one']); + assert.equal(r.call(r.tester, 'names').length, 1); + assert.equal(r.call(r.tester, 'project.list').length, 1); +}); + +test('row 175: W1-W3 as the tester land with receipt actor tester; the human still logs the human', async (t) => { + const r = await runtime(t); + const p = r.call(r.tester, 'plugins.set', { revision: r.call(r.tester, 'plugins').revision, plugins: { 'search-source': 'searxng-url' } }); + assert.deepEqual(JSON.parse(r.bytes()).plugins['search-source'], 'searxng-url'); + assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).state, r.log().at(-1).transaction], ['tester', 'committed', p.transaction]); + const i = r.call(r.tester, 'instances.set', { revision: p.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer' }); + assert.deepEqual(JSON.parse(r.bytes()).roles['sec-1'].definition, 'security-reviewer'); + assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).transaction], ['tester', i.transaction]); + const route = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'minimal', fallback: null }; + const s = r.call(r.tester, 'routes.set', { revision: i.revision, routes: { coder: route } }); + assert.deepEqual(s.instances.coder, route); + assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).transaction], ['tester', s.transaction]); + const h = r.call(r.human, 'routes.set', { revision: s.revision, routes: { coder: { ...route, thinking: 'low' } } }); + assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).transaction], ['jason', h.transaction]); +}); + +test('row 175: W2 as the tester refuses credentials, protected definitions, human, tester and bus.tester, and changes nothing', async (t) => { + const r = await runtime(t); + const revision = r.call(r.tester, 'instances').revision; + const before = r.bytes(); + const entries = r.log().length; + const refused = [ + [{ instance: 'reviewer-2', op: 'add', definition: 'reviewer' }, 'organization-credentials-out-of-scope'], + [{ instance: 'thread-2', op: 'add', definition: 'assistant' }, 'organization-definition-refused'], + [{ instance: 'human', op: 'add', definition: 'merge-gate' }, 'invalid-request'], + [{ instance: 'tester', op: 'add', definition: 'merge-gate' }, 'invalid-request'], + // bus.tester is a business-only var (business/src/vars.mjs), so no instance carries it. + [{ instance: 'gate-2', op: 'add', definition: 'merge-gate', vars: { 'bus.tester': true } }, 'invalid-request'], + [{ instance: 'coder', op: 'change', vars: { 'bus.tester': true } }, 'invalid-request'], + ]; + for (const [args, want] of refused) { + assert.equal(r.code(r.tester, 'instances.set', { revision, ...args }), want, JSON.stringify(args)); + assert.equal(r.bytes(), before, JSON.stringify(args)); + assert.equal(r.call(r.tester, 'instances').revision, revision); + } + assert.equal(r.log().length, entries, 'a refused write logs nothing'); + // The control: the same add with an instance var lands, so the refusal above is the var. + r.call(r.tester, 'instances.set', { revision, instance: 'gate-2', op: 'add', definition: 'merge-gate', vars: { model: 'claude-sonnet-5-5' } }); + assert.ok(r.log().length > entries); + assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).state], ['tester', 'committed']); +}); + +test('row 175: the human-only actions refuse a tester tester-refused, and the file and store stay as they were', async (t) => { + const r = await runtime(t); + const revision = r.call(r.human, 'limits').revision; + const project = r.call(r.human, 'project.create', { name: 'alpha' }).id; + const thread = r.call(r.human, 'thread.create', { project, name: 'one' }).id; + const before = r.bytes(); + // The runtime keeps its store to itself, so the human's own reads stand in for it. + const views = () => ['thread.list', 'project.list', 'names', 'messages', 'instances', 'limits', 'plugins', 'agents'].map((verb) => r.call(r.human, verb)); + const seen = views(); + const opened = r.call(r.human, 'thread.messages', { thread }); + for (const [verb, args] of [ + ['limits.set', { revision, limits: { total: 0 } }], + ['organization.set', { revision, limits: { total: 0 } }], + ['routes', {}], + ['organization.revision', {}], + // The gate runs before the argument checks: a bad key still refuses tester-refused. + ['organization.set', { revision, nope: 1 }], + ['organization.revision', { nope: 1 }], + ['decision.resolve', { id: 'd-1', choice: 'yes' }], + ['launch.revoke', {}], + ['launch.restore', {}], + ['credentials', {}], + ['assistant.stop', { thread }], + ['thread.create', { project, name: 'two' }], + ['thread.open', { thread }], + ['thread.send', { thread, body: 'hello' }], + ['thread.messages', { thread }], + ]) assert.equal(r.code(r.tester, verb, args), 'tester-refused', verb); + // The project file verbs answer a promise. + for (const [verb, args] of [['project.file.save', { project: 'stack', path: 'README.md', content: 'x', head: 'a'.repeat(40) }], ['project.files', { project: 'stack' }]]) + await assert.rejects(Promise.resolve().then(() => r.call(r.tester, verb, args)), { code: 'tester-refused' }, verb); + assert.equal(r.bytes(), before); + assert.equal(r.call(r.human, 'limits').revision, revision); + assert.deepEqual(views(), seen); + assert.deepEqual(r.call(r.human, 'thread.messages', { thread }), opened); +}); + +// The plain broker, as names.test.mjs: the in-store writes W4-W6. +function broker(t, vars = { 'bus.tester': true }) { + const root = mkdtempSync(join(tmpdir(), 'tester-writes-store-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const b = new Broker({ + store, + businesses: { + demo: { + id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}), + roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } }, + }, + }, + }); + const call = (cap, verb, args = {}) => b.request(cap, { verb, args }); + return { b, store, call }; +} + +test('row 175: W4 as the tester writes by tester and keeps every name check', (t) => { + const { b, store, call } = broker(t); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const tester = b.bindTester({ business: 'demo' }); + const events = store.get('SELECT count(*) n FROM events').n; + assert.deepEqual(call(tester, 'name.set', { instance: 'dev', name: 'rex' }), { instance: 'dev', name: 'rex' }); + assert.deepEqual({ ...store.get('SELECT instance, name, by FROM instance_names') }, { instance: 'dev', name: 'rex', by: 'tester' }); + assert.equal(store.get('SELECT count(*) n FROM events').n, events, 'no authority consumed, no event'); + call(human, 'name.set', { instance: 'lead', name: 'patty' }); + assert.equal(store.get("SELECT by FROM instance_names WHERE instance='lead'").by, 'jason'); + assert.throws(() => call(tester, 'name.set', { instance: 'dev', name: 'patty' }), { code: 'name-taken' }); + for (const name of ['lead', 'human', 'tester']) assert.throws(() => call(tester, 'name.set', { instance: 'dev', name }), { code: 'name-is-instance' }, name); + for (const name of ['coder', 'pm']) assert.throws(() => call(tester, 'name.set', { instance: 'dev', name }), { code: 'name-is-role' }, name); + assert.equal(store.get('SELECT count(*) n FROM instance_names').n, 2); + assert.deepEqual(call(tester, 'names').map(({ instance, name }) => [instance, name]), [['dev', 'rex'], ['lead', 'patty']]); +}); + +test('row 175: W6 as the tester records created_by tester; the human records the human', (t) => { + const { b, store, call } = broker(t); + const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const tester = b.bindTester({ business: 'demo' }); + call(tester, 'project.create', { name: 'alpha' }); + call(human, 'project.create', { name: 'beta' }); + assert.deepEqual(store.all('SELECT name, created_by FROM projects ORDER BY seq').map((p) => [p.name, p.created_by]), [['alpha', 'tester'], ['beta', 'jason']]); + assert.throws(() => call(tester, 'project.create', { name: 'alpha' }), { code: 'project-exists' }); + assert.deepEqual(call(tester, 'project.list'), call(human, 'project.list')); +}); + +test('row 175: with bus.tester unset or not exactly true no tester binds, so no read or write runs', (t) => { + for (const vars of [null, {}, { 'bus.tester': false }, { 'bus.tester': 'true' }, { 'bus.tester': 1 }]) { + const { b, store } = broker(t, vars); + assert.throws(() => b.bindTester({ business: 'demo' }), { code: 'tester-off' }, JSON.stringify(vars)); + for (const table of ['instance_names', 'projects', 'messages']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table); + } +}); diff --git a/packages/bus/tests/tester.test.mjs b/packages/bus/tests/tester.test.mjs index 962649b8c..a7de36fb1 100644 --- a/packages/bus/tests/tester.test.mjs +++ b/packages/bus/tests/tester.test.mjs @@ -38,7 +38,8 @@ function setup(t, vars = { 'bus.tester': true }) { 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}`; +// Row 175 (#1673, mos-0872 Q1): the test system lifted the [mos test] prefix, so a body is plain text. +const say = (body = 'is the plan current?') => 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 }]) { @@ -107,11 +108,9 @@ test('every verb outside the tester list refuses tester-refused and is recorded 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) => { +test('a tester message is from tester, needs no prefix, 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' }]) @@ -123,6 +122,7 @@ test('a tester message is from tester, starts [mos test], and writes no human.in assert.equal(row.from_role, 'tester'); assert.equal(row.from_run, 'tester-cli'); assert.equal(row.to_role, 'pm'); + assert.equal(row.body, 'is the plan current?'); 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); @@ -148,8 +148,6 @@ test('a tester message to a role no run holds refuses tester-target-not-running assert.throws(() => call(tester, 'message.send', { to: 'cto', body: say() }), { code: 'tester-target-not-running' }); assert.deepEqual(counts(store), before); assert.equal(before.notices, 0); - // The prefix check still comes first. - assert.throws(() => call(tester, 'message.send', { to: 'cto', body: 'no prefix' }), { code: 'tester-prefix' }); }); test('a tester message is refused while the run is only launched, and routes once it claims', (t) => { diff --git a/packages/cli/README.md b/packages/cli/README.md index acd2b3df0..d80ce66d9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -32,16 +32,16 @@ mosaic agents [--business ] [--json] mosaic trail [--business ] [--json] mosaic talk [--wait | --follow] [--business ] mosaic follow [--business ] -mosaic project create [--repo ] [--business ] -mosaic project list [--business ] [--json] -mosaic threads [--project ] [--business ] [--json] +mosaic project create [--repo ] [--business ] [--tester] +mosaic project list [--business ] [--json] [--tester] +mosaic threads [--project ] [--business ] [--json] [--tester] mosaic thread create [--business ] mosaic thread open [--business ] mosaic thread read [--business ] [--json] mosaic talk --thread [--wait | --follow] [--business ] mosaic follow --thread [--business ] -mosaic name [--business ] -mosaic names [--business ] [--json] +mosaic name [--business ] [--tester] +mosaic names [--business ] [--json] [--tester] mosaic stop mosaic launches off|on [--business ] mosaic launches list [--json] @@ -171,14 +171,26 @@ mosaic launches list [--json] (`tester-required`) or while the business var `bus.tester` isn't `true` (`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. When no run holds the role + Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`, + `limits`, `plugins`, `threads`, `project list` and `business instances`, + which print what the human's forms print, and the writes `plugins set`, + `business instance add` and `set`, `name`, `project create` and + `project init` (below), each recorded as `tester`. + `routes.set` has no CLI form yet. `talk --tester` sends any text. When no run holds the role (row 170, #1667), the bus refuses the message with `tester-target-not-running` and stores nothing, and talk prints `pm isn't running, so the tester's message was refused and nothing was stored` and exits 3. A run launched but not yet claimed counts as not running. The - role's reply comes back to the tester, not to the human's inbox. `decide`, `stop`, `launches` and - `inbox --read` or `--read-all` take no `--tester` (exit 4). + role's reply comes back to the tester, not to the human's inbox. `decide`, `stop`, `launches`, + `inbox --read` or `--read-all`, `limits set`, `reset` and `migrate`, + `project check`, the `thread` commands and `talk` or `follow --thread` + take no `--tester` (exit 4). +- `project init --tester` (row 175, mos-0872 Q2) may run inside an agent. + Before anything touches the disk it refuses a repository outside + `/projects//` (`tester-project-area`, exit 2), then + binds the tester with one `agents` read, so `tester-off` and + `tester-required` refuse (exit 3), then commits the layout with `tester` + as author and committer. Tests: `tests/tester-reads.test.mjs`. ## The app @@ -872,7 +884,7 @@ The WebUI (S5). ## Agent limits and models -`mosaic limits [--business ID] [--json]` reads the model table, each role's +`mosaic limits [--business ID] [--json] [--tester]` reads the model table, each role's selection, limits and running counts. `mosaic limits set --business ID --file PATCH` submits JSON with the read's `revision` and any of `limits`, `models`, `instances`. `limits` and `models` replace those entire objects; @@ -883,8 +895,8 @@ The definition stays an assistant one, and `holder` and `credentials` stay hand edits (row 155, Q169). The chief of staff sends the same patch with `change_organization` and reads the revision itself with `organization_revision` (row 134). Both commands use -the existing human proof and broker; neither is available to a managed role -or tester. The Console Settings form uses the same write path and requires +the existing human proof and broker; neither is available to a managed role. +A tester may read (row 175), but `limits set` refuses `--tester` with exit 4. The Console Settings form uses the same write path and requires signed-in send mode, named Host, exact Origin and same-origin Fetch Metadata. Unset limits mean unlimited; zero blocks new launches. Limits are checked @@ -940,9 +952,9 @@ the re-pin follows in the same window (decision 145). Row 165 (#1662, Q171, REQ-ROLE-3). ``` -mosaic business instances BUSINESS [--json] -mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV -mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV +mosaic business instances BUSINESS [--json] [--tester] +mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] +mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] ``` `scripts/mosaic` sends `business instances` and `business instance` here and @@ -988,7 +1000,7 @@ The Console Settings page has the same read and write (`packages/webui`). ## Token usage -`mosaic usage [--business ID] [--json]` reads the bus's accounting, without +`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without opening its database or transcripts. Text lists the total and run, instance, provider/model, provider and account groups, with input/output/cacheWrite/ cacheRead and unknown turns beside every total. JSON retains the full view. diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index ee9e866de..b487576de 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -52,8 +52,8 @@ export const USAGE = `usage: mosaic talk [--wait | --follow] [--business ] [--tester] mosaic follow [--business ] [--tester] ${THREAD_TALK_USAGE} - mosaic name [--business ] - mosaic names [--business ] [--json] + mosaic name [--business ] [--tester] + mosaic names [--business ] [--json] [--tester] mosaic stop mosaic launches off|on [--business ] mosaic launches list [--json] @@ -307,9 +307,10 @@ async function human(verb, rest, io, deps) { } // Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an -// instance; `mosaic names` lists the current ones. Both are the human's. +// instance; `mosaic names` lists the current ones. Both are the human's, and since row 175 +// (#1673, Q173) the tester's too. async function names(verb, rest, io, deps) { - const parsed = parse(rest, { flags: verb === "names" ? ["--json"] : [], values: ["--business"] }); + const parsed = parse(rest, { flags: verb === "names" ? ["--json", "--tester"] : ["--tester"], values: ["--business"] }); if (parsed.positional.length !== (verb === "name" ? 2 : 0)) throw usage(verb === "name" ? "name takes an instance and a name" : "names takes no arguments"); const call = callFor(parsed, io, deps); if (verb === "name") { @@ -322,7 +323,6 @@ async function names(verb, rest, io, deps) { } const TALK_WAIT_S = 900; -const TESTER_PREFIX = "[mos test]"; const TALK_POLL_MS = 2000; function formatMessage(m) { @@ -365,8 +365,6 @@ async function talk(rest, io, deps, receiveOnly = false) { if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more"); let [to, body] = parsed.positional; if (threadRef !== undefined) [to, body] = [threadRef, to]; - // 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 = threadRef === undefined ? callFor(parsed, io, deps) : channelCallFor(parsed, io, deps); let sent; let thread = null; @@ -568,7 +566,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, if (argv.length === 0) return app(io, deps); const [verb, ...rest] = argv; if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps); - if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps); + if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps)); if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps)); if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage); if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps)); diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs index e64cb17ae..db7d7f052 100644 --- a/packages/cli/src/instances.mjs +++ b/packages/cli/src/instances.mjs @@ -1,14 +1,14 @@ // `mosaic business instances` and `mosaic business instance add|set` (row 165, #1662, Q171, -// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's human-only -// instances and instances.set verbs. The broker refuses an agent, a reader and a tester; the -// business package's instance writer refuses a stale revision and every document -// `mosaic business validate` would refuse, and logs the change with its actor. +// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's instances +// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker +// refuses an agent and a reader; the business package's instance writer refuses a stale revision +// and every document `mosaic business validate` would refuse, and logs the change with its actor. import { readFileSync } from "node:fs"; import { CliError } from "./errors.mjs"; -export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] | " + - "business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV | " + - "business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV"; +export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " + + "business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " + + "business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]"; // The bus answers a refusal with its code alone, so the reason a human reads is here. export const REASONS = { @@ -32,7 +32,7 @@ function parse(args) { const out = { positional: [], flags: new Set(), values: {} }; for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (["--json", "--launch", "--no-launch"].includes(arg)) { + if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) { if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4); out.flags.add(arg); } else if (["--definition", "--vars", "--revision"].includes(arg)) { diff --git a/packages/cli/src/limits.mjs b/packages/cli/src/limits.mjs index e1243ffa3..a442b06f5 100644 --- a/packages/cli/src/limits.mjs +++ b/packages/cli/src/limits.mjs @@ -7,7 +7,7 @@ import { CliError } from "./errors.mjs"; import { migrateLaunch } from "../../business/src/migrate-launch.mjs"; // Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition. -export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file PATCH [--business ID] | " + +export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " + "reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " + "PATCH keys: revision, limits, models, instances, reset, assistants"; @@ -35,7 +35,7 @@ export function parseLimits(args) { const out = { positional: [], flags: new Set(), values: {} }; for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === "--json") out.flags.add(arg); + if (arg === "--json" || arg === "--tester") out.flags.add(arg); else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) { if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4); out.values[arg] = args[++i]; @@ -50,6 +50,8 @@ export async function limitsCommand(args, io, callFor) { const verb = parsed.positional[0] ?? "view"; const values = parsed.values; if (parsed.positional.length > 1 && verb !== "reset") throw new CliError(LIMITS_USAGE, 4); + // Row 175 (#1673, Q173): the tester reads limits; set, reset and migrate stay the human's. + if (parsed.flags.has("--tester") && verb !== "view") throw new CliError(`--tester takes only the read\n${LIMITS_USAGE}`, 4); try { if (verb === "migrate") { if (!values["--file"] || !values["--models"] || values["--business"]) { diff --git a/packages/cli/src/plugins.mjs b/packages/cli/src/plugins.mjs index fc55e624f..fb7acc2e5 100644 --- a/packages/cli/src/plugins.mjs +++ b/packages/cli/src/plugins.mjs @@ -1,17 +1,18 @@ // `mosaic plugins` (vikunja:19/305, REQ-PLUG-1): read and switch the organization file's plug-in -// modules through the broker's human-only plugins and plugins.set verbs. The broker refuses an -// agent, a reader and a tester, a stale revision, a bad shape and a module that doesn't exist, and -// the change is logged with its actor in the organization's changes.jsonl. +// modules through the broker's plugins and plugins.set verbs, the human's and, with --tester, the +// tester's (row 175, #1673, Q173). The broker refuses an agent and a reader, a stale revision, a bad +// shape and a module that doesn't exist, and the change is logged with its actor (the human or +// `tester`) in the organization's changes.jsonl. import { readFileSync } from "node:fs"; import { CliError } from "./errors.mjs"; -export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] | set --file PATCH [--business ID]"; +export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]"; function parse(args) { const out = { positional: [], flags: new Set(), values: {} }; for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === "--json") out.flags.add(arg); + if (arg === "--json" || arg === "--tester") out.flags.add(arg); else if (["--file", "--business"].includes(arg)) { if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4); out.values[arg] = args[++i]; diff --git a/packages/cli/src/project.mjs b/packages/cli/src/project.mjs index ee2056ddd..76cb3e6c4 100644 --- a/packages/cli/src/project.mjs +++ b/packages/cli/src/project.mjs @@ -1,25 +1,26 @@ import { configDir, loadBusiness } from '../../business/src/index.mjs'; -import { checkProject, initProject, ProjectError } from '../../project/src/index.mjs'; +import { checkProject, initProject, ProjectError, testerProjectArea } from '../../project/src/index.mjs'; import { loadSystem, rolesDir } from './config.mjs'; import { CliError } from './errors.mjs'; import { shortId } from './format.mjs'; import { parseArgs } from './thread.mjs'; import { refuseInsideAgent } from './transport.mjs'; -export const PROJECT_USAGE = 'mosaic project init|check '; +export const PROJECT_USAGE = 'mosaic project init [--tester] | check '; // Row 128 (#1622): create and list work on the bus record (project.create, project.list); init -// and check above work on the repository on disk and never contact the bus. +// and check above work on the repository on disk and never contact the bus, except that +// init --tester (row 175, #1673) binds the tester once before it writes. export const PROJECT_BUS_USAGE = [ - 'mosaic project create [--repo ] [--business ]', - 'mosaic project list [--business ] [--json]', + 'mosaic project create [--repo ] [--business ] [--tester]', + 'mosaic project list [--business ] [--json] [--tester]', 'A thread project named for a business repository must carry that repo: --repo .', ].join('\n '); export async function projectBusCommand(args, io, callFor) { const [sub, ...rest] = args; const parsed = parseArgs(rest, PROJECT_BUS_USAGE, sub === 'create' - ? { values: ['--repo', '--business'] } - : { flags: ['--json'], values: ['--business'] }); + ? { flags: ['--tester'], values: ['--repo', '--business'] } + : { flags: ['--json', '--tester'], values: ['--business'] }); if (parsed.positional.length !== (sub === 'create' ? 1 : 0)) throw new CliError(`project ${sub} takes ${sub === 'create' ? 'one name' : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4); const call = callFor(parsed); @@ -40,17 +41,27 @@ export async function projectBusCommand(args, io, callFor) { io.stdout.write(projects.length === 0 ? 'no projects\n' : projects.map(p => `${p.name} ${p.repo ?? '-'} ${shortId(p.id)}\n`).join('')); } -export async function projectCommand(args, io, deps = {}) { - if (args.length !== 3 || !['init', 'check'].includes(args[0]) || args.some(arg => arg.startsWith('--'))) +// Row 175 (#1673, Q173, mos-0872 Q2): `init --tester` may run inside an agent. Before anything +// touches the disk it refuses a repository outside /projects// +// (tester-project-area, exit 2), then binds the tester with one agents read, so tester-off and +// tester-required refuse (exit 3), then commits the layout as `tester`. +export async function projectCommand(args, io, deps = {}, callFor) { + const tester = args[0] === 'init' && args.filter(arg => arg === '--tester').length === 1; + const words = tester ? args.filter(arg => arg !== '--tester') : args; + if (words.length !== 3 || !['init', 'check'].includes(words[0]) || words.some(arg => arg.startsWith('--'))) throw new CliError(PROJECT_USAGE, 4); - const [verb, id, project] = args; - if (verb === 'init') refuseInsideAgent(io.env); + const [verb, id, project] = words; + if (verb === 'init' && !tester) refuseInsideAgent(io.env); try { const system = deps.system ?? loadSystem({ env: io.env }); const business = loadBusiness(id, { dir: configDir(io.env), rolesDir: rolesDir(io.env) }); const context = { business, project, dataRoot: system.dataRoot }; + if (tester) { + testerProjectArea(context); + await callFor({ positional: [], flags: new Set(['--tester']), values: { '--business': id } })('agents', {}); + } if (verb === 'init') { - const result = await initProject(context); + const result = await initProject(context, tester ? { author: 'tester' } : {}); io.stdout.write(`initialized ${result.repo}\ncommit ${result.commit}\n`); } else { const result = await checkProject(context); diff --git a/packages/cli/src/thread.mjs b/packages/cli/src/thread.mjs index 5c55948ca..5c8579c49 100644 --- a/packages/cli/src/thread.mjs +++ b/packages/cli/src/thread.mjs @@ -9,7 +9,7 @@ import { shortId } from "./format.mjs"; import { configDir, loadBusiness } from "../../business/src/index.mjs"; import { rolesDir } from "./config.mjs"; -export const THREADS_USAGE = "mosaic threads [--project ] [--business ] [--json]"; +export const THREADS_USAGE = "mosaic threads [--project ] [--business ] [--json] [--tester]"; export const THREAD_USAGE = [ "mosaic thread create [--business ]", "mosaic thread open [--business ]", @@ -107,8 +107,9 @@ export function threadMembers(call, thread) { const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +// Row 175 (#1673, Q173): with --tester the tester lists the business human's threads. export async function threadsCommand(args, io, callFor) { - const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json"], values: ["--business", "--project"] }); + const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json", "--tester"], values: ["--business", "--project"] }); if (parsed.positional.length !== 0) throw new CliError(`threads takes no arguments\n${THREADS_USAGE}`, 4); const call = callFor(parsed); const project = parsed.values["--project"] === undefined ? null : await resolveProject(call, parsed.values["--project"], { business: call.business, env: io.env }); diff --git a/packages/cli/src/usage.mjs b/packages/cli/src/usage.mjs index ae212c2cd..f1c1f8bf5 100644 --- a/packages/cli/src/usage.mjs +++ b/packages/cli/src/usage.mjs @@ -1,10 +1,10 @@ import { CliError } from './errors.mjs'; -export const USAGE_USAGE = 'mosaic usage [--business ID] [--json]'; +export const USAGE_USAGE = 'mosaic usage [--business ID] [--json] [--tester]'; export async function usageCommand(args, io, callFor) { const parsed = { flags: new Set(), values: {} }; for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === '--json' && !parsed.flags.has(arg)) parsed.flags.add(arg); + if ((arg === '--json' || arg === '--tester') && !parsed.flags.has(arg)) parsed.flags.add(arg); else if (arg === '--business' && args[i + 1] && !args[i + 1].startsWith('--') && !parsed.values[arg]) parsed.values[arg] = args[++i]; else throw new CliError(USAGE_USAGE, 4); } diff --git a/packages/cli/tests/names.test.mjs b/packages/cli/tests/names.test.mjs index b2f749ec5..b716fe0e8 100644 --- a/packages/cli/tests/names.test.mjs +++ b/packages/cli/tests/names.test.mjs @@ -54,6 +54,6 @@ test("name and names refuse the wrong number of arguments with the usage", async assert.ok(r.error.endsWith(USAGE), r.error); } assert.equal(s.calls.length, 0); - assert.match(USAGE, /^ {2}mosaic name \[--business \]$/m); - assert.match(USAGE, /^ {2}mosaic names \[--business \] \[--json\]$/m); + assert.match(USAGE, /^ {2}mosaic name \[--business \] \[--tester\]$/m); + assert.match(USAGE, /^ {2}mosaic names \[--business \] \[--json\] \[--tester\]$/m); }); diff --git a/packages/cli/tests/tester-reads.test.mjs b/packages/cli/tests/tester-reads.test.mjs new file mode 100644 index 000000000..2f6413296 --- /dev/null +++ b/packages/cli/tests/tester-reads.test.mjs @@ -0,0 +1,205 @@ +// Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the CLI's tester forms. The seven reads print what +// the human's forms print, every write form outside W1-W7 refuses --tester before any call, and +// `project init --tester` checks the project area, binds the tester, then commits as `tester`. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { main } from "../src/cli.mjs"; +import { BusError } from "../../bus/src/broker.mjs"; +import { startBroker } from "../../bus/src/runtime.mjs"; +import { systemFor, writeJson } from "../../business/tests/helpers.mjs"; +import { fixture, humanEnv, io, tmp } from "./helpers.mjs"; + +const none = { authority: { withinRole: ["message.send"], crossRole: [] } }; +const inAgent = (env) => io({ ...env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" }); +const bus = (vars = { "bus.tester": true }) => ({ + acme: { + id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, ...(vars ? { vars } : {}), + roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } }, + }, +}); + +// The runtime in this process behind stand-in transports, as instances.test.mjs. +async function setup(t) { + const root = tmp(t, "mosaic-cli-tester-reads-"); + const f = fixture(root, "acme"); + const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(), organization: { dir: join(root, "config"), rolesDir: f.roles, system: systemFor(root) } }); + t.after(() => rt.close()); + const human = rt.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }); + const tester = rt.broker.bindTester({ business: "acme" }); + const calls = { human: [], tester: [] }; + const via = (cap, list) => (business) => async (verb, args = {}) => { + list.push(verb); + try { + return structuredClone(rt.broker.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: f.dataRoot }, transport: via(human, calls.human), testerTransport: via(tester, calls.tester), talkPollMs: 5 }; + const call = (verb, args = {}) => rt.broker.request(human, { verb, args }); + return { root, f, rt, human, tester, calls, deps, call }; +} + +const READS = [ + ["usage"], + ["names"], + ["limits"], + ["plugins"], + ["threads"], + ["project", "list"], +]; + +test("row 175: each read with --tester inside an agent prints the human form's output, and refuses without --tester", async (t) => { + const s = await setup(t); + s.call("plugins.set", { revision: s.call("plugins").revision, plugins: { channel: ["cli", "webui"] } }); + s.call("name.set", { instance: "coder", name: "rex" }); + const project = s.call("project.create", { name: "alpha" }).id; + s.call("thread.create", { project, name: "one" }); + const forms = [...READS.map((argv) => [...argv, "--business", "acme"]), ["business", "instances", "acme"]]; + for (const argv of forms) { + const name = argv.slice(0, 2).join(" "); + const h = io(s.f.env); + await main(argv, h, s.deps); + const humanCalls = s.calls.human.length; + const testerCalls = s.calls.tester.length; + const x = inAgent(s.f.env); + await main([...argv, "--tester"], x, s.deps); + assert.equal(x.out.text, h.out.text, name); + assert.ok(x.out.text.length > 0, name); + assert.equal(s.calls.human.length, humanCalls, `${name}: the tester form made no human call`); + assert.ok(s.calls.tester.length > testerCalls, `${name}: the tester form called the tester transport`); + await assert.rejects(main(argv, inAgent(s.f.env), s.deps), (e) => e.exitCode === 3, `${name} without --tester`); + assert.equal(s.calls.human.length, humanCalls, `${name}: the refusal came before any call`); + } + // Not empty: the names, the thread and the project the human made are in the tester's view. + const x = inAgent(s.f.env); + await main(["threads", "--business", "acme", "--json", "--tester"], x, s.deps); + assert.deepEqual(JSON.parse(x.out.text).map((r) => r.name), ["one"]); +}); + +test("row 175: --tester on limits set, reset and migrate and every write form outside W1-W7 exits 4 before any call", async (t) => { + const s = await setup(t); + for (const argv of [ + ["limits", "set", "--file", "patch.json", "--business", "acme"], + ["limits", "reset", "total", "--business", "acme"], + ["limits", "migrate", "--file", "org.json", "--models", "map.json"], + ["decide", "d1", "yes", "--business", "acme"], + ["inbox", "--read", "abcdefgh", "--business", "acme"], + ["inbox", "--read-all", "--business", "acme"], + ["thread", "create", "alpha", "one", "--business", "acme"], + ["thread", "open", "t-1", "--business", "acme"], + ["thread", "read", "t-1", "--business", "acme"], + ["talk", "--thread", "t-1", "hello", "--business", "acme"], + ["follow", "--thread", "t-1", "--business", "acme"], + ["stop", "run-1"], + ["launches", "off", "--business", "acme"], + ["launches", "on", "--business", "acme"], + ["launches", "list"], + ["project", "check", "acme", "stack"], + ["project", "init", "acme", "stack", "--tester"], + ]) { + const x = inAgent(s.f.env); + await assert.rejects(main([...argv, "--tester"], x, s.deps), (e) => e.exitCode === 4, argv.join(" ")); + } + assert.deepEqual(s.calls, { human: [], tester: [] }); + assert.equal(existsSync(join(s.f.dataRoot, "projects")), false); +}); + +// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it +// spawns can reach the runtime in this one. +const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url)); +function mosaic(args, env) { + const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k))); + return new Promise((resolve, reject) => { + const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 }); + 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, err })); + }); +} +// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1 +// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli. +test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => { + const root = tmp(t, "mosaic-cli-route-"); + const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") }; + for (const [args, message] of [ + [["plugins", "x"], /^mosaic: mosaic plugins \[--business ID\]/], + [["name"], /^mosaic: name takes an instance and a name\n/], + [["names", "x"], /^mosaic: names takes no arguments\n/], + [["follow", "x"], /^mosaic: follow takes no arguments\n/], + [["threads", "x"], /^mosaic: threads takes no arguments\n/], + [["thread"], /^mosaic: usage:\n mosaic thread create /], + ]) { + const r = spawnSync(MOSAIC, args, { env, encoding: "utf8" }); + assert.equal(r.status, 4, args.join(" ")); + assert.match(r.stderr, message, args.join(" ")); + assert.doesNotMatch(r.stderr, /mosaic launch readdirSync(dir, { recursive: true }).map(String).sort(); +async function w7(t, vars, edit) { + const root = tmp(t, "mosaic-cli-tester-init-"); + const f = fixture(root, "acme", edit); + const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(vars) }); + t.after(() => rt.close()); + return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } }; +} + +test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => { + const { f, agent } = await w7(t); + const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent); + assert.equal(r.status, 0, r.err); + const repo = join(f.dataRoot, "projects", "acme", "stack"); + assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`)); + const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" }); + assert.equal(log.stdout, "tester|tester\n"); + // Without --tester the same init inside an agent refuses, as before. + const again = await mosaic(["project", "init", "acme", "stack"], agent); + assert.equal(again.status, 3); + assert.match(again.err, /outside any agent run/); +}); + +test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => { + // The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first. + const { f, agent } = await w7(t, null, (doc) => { + doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records"); + return doc; + }); + const records = f.doc.projects.stack.repo; + const data = listing(f.dataRoot); + const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent); + assert.equal(r.status, 2, r.err); + assert.match(r.err, /tester-project-area/); + assert.equal(existsSync(records), false); + assert.deepEqual(listing(f.dataRoot), data); +}); + +test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => { + const off = await w7(t, null); + const data = listing(off.f.dataRoot); + const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent); + assert.equal(r.status, 3, r.err); + assert.match(r.err, /tester-off/); + assert.deepEqual(listing(off.f.dataRoot), data); + assert.equal(existsSync(join(off.f.dataRoot, "projects")), false); + const on = await w7(t); + const before = listing(on.f.dataRoot); + for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) { + const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" }); + assert.equal(under.status, 3, `${marker}: ${under.err}`); + assert.match(under.err, /tester-required/, marker); + } + assert.deepEqual(listing(on.f.dataRoot), before); + assert.equal(existsSync(join(on.f.dataRoot, "projects")), false); +}); diff --git a/packages/cli/tests/tester.test.mjs b/packages/cli/tests/tester.test.mjs index 0c0bb85a3..5141e4aaf 100644 --- a/packages/cli/tests/tester.test.mjs +++ b/packages/cli/tests/tester.test.mjs @@ -38,7 +38,7 @@ function setup(t, extra = {}) { // 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) => { +test("talk --tester sends a message as tester from inside an agent and prints the role's reply", async (t) => { const s = setup(t); const x = inAgent(); const deps = { @@ -57,7 +57,7 @@ test("talk --tester sends a [mos test] message as tester from inside an agent an }; }, }; - await main(["talk", "coder", "[mos test] is the plan current?", "--tester", "--business", "demo"], x, deps); + await main(["talk", "coder", "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, []); @@ -69,11 +69,13 @@ test("talk --tester sends a [mos test] message as tester from inside an agent an 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) => { +// Row 175 (#1673, mos-0872 Q1): the test system needs no "[mos test]" prefix; the body goes as typed. +test("talk --tester sends text with no prefix, and follow --tester reads", 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. + await main(["talk", "coder", "is the plan current?", "--tester", "--wait", "0", "--business", "demo"], inAgent(), s.deps); + assert.deepEqual(s.calls.tester.map((c) => c.verb), ["message.send"]); + assert.equal(s.store.get("SELECT body FROM messages WHERE from_role='tester'").body, "is the plan current?"); + s.calls.tester.length = 0; 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); @@ -116,7 +118,6 @@ test("the tester transport runs tester-cli.mjs, and the tester's refusals exit 3 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) => { diff --git a/packages/plugins/README.md b/packages/plugins/README.md index cf2046703..9b6cf91f6 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -53,8 +53,9 @@ calls the kind. `mosaic plugins set --file PATCH` (`{ "revision": ..., "plugins": {...} }`) changes it, one kind at a time: the patch names the kinds it changes, a kind with `null` is removed, and a kind it doesn't name keeps its setting. The - change goes through the broker's human-only `plugins` and `plugins.set` - verbs. The setter refuses a bad shape, a name with no module in this tree + change goes through the broker's `plugins` and `plugins.set` verbs, the + human's and, since row 175 (#1673), the tester's (`--tester`, actor + `tester`). The setter refuses a bad shape, a name with no module in this tree (`unknown-plugin`), an unknown kind, an empty patch and a stale revision, and logs actor and old and new names in `changes.jsonl`. A running broker reads the setting at bus start (REQ-WEB-3's reload will change that), so restart the bus after a switch. diff --git a/packages/project/src/index.mjs b/packages/project/src/index.mjs index 98419800e..30a0cce86 100644 --- a/packages/project/src/index.mjs +++ b/packages/project/src/index.mjs @@ -4,10 +4,10 @@ import { } from 'node:fs'; import { dirname, join, normalize } from 'node:path'; import { randomUUID } from 'node:crypto'; -import { ProjectError, checkedId, documentPath, noLinks, projectRepo } from './paths.mjs'; +import { ProjectError, checkedId, documentPath, noLinks, projectRepo, testerProjectArea } from './paths.mjs'; import { git, identity, inspectGit, requireClean } from './git.mjs'; import { validateRecord } from './validate.mjs'; -export { ProjectError, projectRepo, validateRecord }; +export { ProjectError, projectRepo, testerProjectArea, validateRecord }; const DIRECTORIES = ['spec', 'docs', 'requirements', 'decisions', 'templates']; const TEMPLATES = ['project.json', 'requirement.json', 'decision.json', 'document.md', 'north-star.md']; @@ -92,8 +92,10 @@ export async function checkProject(context) { return result; } -export async function initProject(context) { - const repo = projectRepo(context), human = checkedId(context.business.human); +// Row 175 (#1673): `author` names the layout commit's author and committer; without it the +// business human commits, as before. The tester's init passes "tester". +export async function initProject(context, { author } = {}) { + const repo = projectRepo(context), committer = checkedId(author ?? context.business.human); if (existsSync(repo) && readdirSync(repo).length) throw new ProjectError('project-not-empty', 'refusing existing non-empty directory'); const parents = []; for (let path = repo; !existsSync(path); path = dirname(path)) parents.unshift(path); @@ -130,7 +132,7 @@ export async function initProject(context) { const result = await checkLayout(context); if (!result.ok) throw new ProjectError('project-invalid', result.problems.join('\n')); await git(repo, ['add', '--', 'project.json', ...DIRECTORIES]); - await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(human)); + await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(committer)); return { repo, commit: await git(repo, ['rev-parse', 'HEAD']) }; } catch (error) { for (const entry of created.reverse()) { diff --git a/packages/project/src/paths.mjs b/packages/project/src/paths.mjs index 1ebee955b..83f567bbf 100644 --- a/packages/project/src/paths.mjs +++ b/packages/project/src/paths.mjs @@ -27,6 +27,10 @@ export function noLinks(path) { if (part === dirname(part)) break; } } +const contains = (base, child) => { + const rel = relative(base, child); + return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)); +}; export function projectRepo({ business, project, dataRoot }) { checkedId(business?.id); checkedId(project); if (!Object.hasOwn(business?.projects ?? {}, project)) @@ -34,10 +38,6 @@ export function projectRepo({ business, project, dataRoot }) { absolute(dataRoot); const entry = business.projects[project]; const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project)); - const contains = (base, child) => { - const rel = relative(base, child); - return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)); - }; for (const project of Object.values(business.projects)) { const code = absolute(project.root); if (contains(code, repo) || contains(repo, code)) @@ -46,3 +46,11 @@ export function projectRepo({ business, project, dataRoot }) { noLinks(repo); return repo; } +// Row 175 (#1673, mos-0872 Q2): the tester initializes only a repository in the data root's +// project area, /projects//. It writes nothing; projectRepo only lstats the path. +export function testerProjectArea(context) { + const repo = projectRepo(context); + if (!contains(join(context.dataRoot, 'projects', context.business.id), repo)) + throw new ProjectError('tester-project-area', `the tester initializes only a repository under ${join(context.dataRoot, 'projects', context.business.id)}`); + return repo; +} diff --git a/packages/project/tests/tester-init.test.mjs b/packages/project/tests/tester-init.test.mjs new file mode 100644 index 000000000..28f2b0785 --- /dev/null +++ b/packages/project/tests/tester-init.test.mjs @@ -0,0 +1,62 @@ +// Row 175 (#1673, mos-0872 Q2): `project init --tester` initializes only a repository under +// /projects//, and its layout commit names `tester` as author and committer. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { initProject, testerProjectArea } from '../src/index.mjs'; + +const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim(); +function context(t, repo) { + const root = mkdtempSync(join(tmpdir(), 'project-tester-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const dataRoot = join(root, 'data'); + return { root, business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), ...(repo ? { repo: repo(root, dataRoot) } : {}) } } }, project: 'stack', dataRoot }; +} + +test('row 175: init with author tester commits the layout as tester; without it, as the human', async (t) => { + const c = context(t); + const { repo, commit } = await initProject(c, { author: 'tester' }); + assert.equal(repo, join(c.dataRoot, 'projects/acme/stack')); + assert.equal(git(repo, 'show', '-s', '--format=%an|%cn', commit), 'tester|tester'); + assert.equal(git(repo, 'rev-list', '--count', 'HEAD'), '1'); + const h = context(t); + const human = await initProject(h); + assert.equal(git(human.repo, 'show', '-s', '--format=%an|%cn', human.commit), 'jason|jason'); +}); + +test('row 175: init refuses an author that is no id before it makes any directory', async (t) => { + const c = context(t); + for (const author of ['', 'two words', '../x', 7]) + await assert.rejects(initProject(c, { author }), { code: 'project-id' }, String(author)); + assert.equal(existsSync(c.dataRoot), false); +}); + +test('row 175: testerProjectArea passes a repository in the business area and refuses any other, touching nothing', (t) => { + const inside = [ + null, + (root, dataRoot) => join(dataRoot, 'projects', 'acme', 'records'), + (root, dataRoot) => join(dataRoot, 'projects', 'acme', 'deep', 'records'), + ]; + for (const repo of inside) { + const c = context(t, repo); + assert.equal(testerProjectArea(c), repo ? repo(c.root, c.dataRoot) : join(c.dataRoot, 'projects', 'acme', 'stack')); + } + const outside = [ + (root) => join(root, 'records'), + (root, dataRoot) => join(dataRoot, 'records'), + (root, dataRoot) => join(dataRoot, 'projects', 'other', 'stack'), + // A sibling whose name starts with the business id is not inside it. + (root, dataRoot) => join(dataRoot, 'projects', 'acme-2', 'stack'), + ]; + for (const repo of outside) { + const c = context(t, repo); + assert.throws(() => testerProjectArea(c), { code: 'tester-project-area' }, repo(c.root, c.dataRoot)); + assert.equal(existsSync(c.dataRoot), false); + assert.equal(existsSync(repo(c.root, c.dataRoot)), false); + } + // projectRepo's own checks still come first. + assert.throws(() => testerProjectArea({ ...context(t), project: 'missing' }), { code: 'project-not-declared' }); +}); diff --git a/scripts/mosaic b/scripts/mosaic index 827c6ec29..34944880c 100755 --- a/scripts/mosaic +++ b/scripts/mosaic @@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then exec node "$REPO/packages/business/src/cli.mjs" "$@" fi case "${1:-}" in - usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; + usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; esac exec node "$REPO/packages/seat/src/cli.mjs" "$@"