bus, cli: tester reads and the W1-W7 tester writes, recorded as tester; scripts/mosaic sends plugins, name and names to the CLI (row 175, #1673)

TESTER_VERBS gains usage, names, limits, plugins, instances, project.list,
thread.list, name.set and project.create. A tester reads limits, plugins
and instances and sends plugins.set, instances.set and routes.set, each
logged with actor tester. The project file verbs refuse a tester
tester-refused. The broker and CLI drop the [mos test]
prefix rule (mos-0872 Q1). The CLI takes --tester on the seven reads and
the W1-W7 write forms and refuses it (exit 4) elsewhere, bus start and
stop included.

project init --tester checks the project area, refuses the area root
itself, binds the tester with one agents read, then commits the layout as
tester (mos-0872 Q2). scripts/mosaic sends plugins, name, names, follow,
threads and thread to packages/cli (sage-1081). The row 164 pins are
untouched.

Built by Dewey on 9e74925c2 (three commits to 989699f7c, manifest
2e30f2a1...29d5f), approved by Filbert in round 3 (#1673 comment 29279),
Sage's gate green on 716ddcf1e plus the patch.
This commit is contained in:
2026-10-19 19:04:50 -05:00
parent 1d81c54ff1
commit 98d29769e2
25 changed files with 663 additions and 119 deletions
+2 -1
View File
@@ -277,7 +277,8 @@ scripts/bus-service.sh uninstall | status <business>
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@<business>` runs. It boots the broker, starts the Discord
notifier, and needs `<dataRoot>/notify/<business>/notify.json`. That
directory must be 0700: it also holds the notifier's journal `sent.jsonl`
+12 -10
View File
@@ -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 `<definition>.<instance>`, 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 `<definition>.<instance>`, 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 <socket>` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`.
A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs <socket>` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`. 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.
+32 -16
View File
@@ -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.
@@ -1278,6 +1285,8 @@ export class Broker {
fail('read-only');
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
// A tester reads the human's views, as a reader does, and its message verbs are its own.
// Since row 175 the view also carries the writes name.set and project.create. Neither
// branch checks `reader`: each passes a tester by `s.tester` and records `tester`.
const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s;
const result = this.#dispatch(view, request.verb, args);
this.#secretCheck(result);
@@ -1401,6 +1410,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 +1817,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 +2218,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 +2233,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 +2259,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 +2267,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 +2283,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 +2317,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 +2328,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]),
);
}
+8 -3
View File
@@ -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) => {
+5 -3
View File
@@ -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');
+3 -1
View File
@@ -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) => {
+8 -3
View File
@@ -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) => {
+201
View File
@@ -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);
}
});
+4 -6
View File
@@ -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) => {
+30 -16
View File
@@ -32,16 +32,16 @@ mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>]
mosaic project create <name> [--repo <repo>] [--business <id>]
mosaic project list [--business <id>] [--json]
mosaic threads [--project <project>] [--business <id>] [--json]
mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
mosaic project list [--business <id>] [--json] [--tester]
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
mosaic thread create <project> <name> [--business <id>]
mosaic thread open <thread> [--business <id>]
mosaic thread read <thread> [--business <id>] [--json]
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow --thread <thread> [--business <id>]
mosaic name <instance> <name> [--business <id>]
mosaic names [--business <id>] [--json]
mosaic name <instance> <name> [--business <id>] [--tester]
mosaic names [--business <id>] [--json] [--tester]
mosaic stop <run>
mosaic launches off|on [--business <id>]
mosaic launches list [--json]
@@ -171,14 +171,28 @@ 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, `talk` or `follow --thread` and
`bus start` or `stop` 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
`<dataRoot>/projects/<business>/`, or that directory itself, since a
repository there would hold the other projects' default ones
(`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 +886,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 +897,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 +954,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 +1002,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.
+6 -8
View File
@@ -52,8 +52,8 @@ export const USAGE = `usage:
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
mosaic follow [--business <id>] [--tester]
${THREAD_TALK_USAGE}
mosaic name <instance> <name> [--business <id>]
mosaic names [--business <id>] [--json]
mosaic name <instance> <name> [--business <id>] [--tester]
mosaic names [--business <id>] [--json] [--tester]
mosaic stop <run>
mosaic launches off|on [--business <id>]
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));
+8 -8
View File
@@ -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)) {
+4 -2
View File
@@ -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"]) {
+6 -5
View File
@@ -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];
+23 -12
View File
@@ -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 <business> <project>';
export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester] | check <business> <project>';
// 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 <name> [--repo <repo>] [--business <id>]',
'mosaic project list [--business <id>] [--json]',
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
'mosaic project list [--business <id>] [--json] [--tester]',
'A thread project named for a business repository must carry that repo: --repo <name>.',
].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 <dataRoot>/projects/<business>/
// (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);
+3 -2
View File
@@ -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 <project>] [--business <id>] [--json]";
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json] [--tester]";
export const THREAD_USAGE = [
"mosaic thread create <project> <name> [--business <id>]",
"mosaic thread open <thread> [--business <id>]",
@@ -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 });
+2 -2
View File
@@ -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);
}
+2 -2
View File
@@ -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 <instance> <name> \[--business <id>\]$/m);
assert.match(USAGE, /^ {2}mosaic names \[--business <id>\] \[--json\]$/m);
assert.match(USAGE, /^ {2}mosaic name <instance> <name> \[--business <id>\] \[--tester\]$/m);
assert.match(USAGE, /^ {2}mosaic names \[--business <id>\] \[--json\] \[--tester\]$/m);
});
+207
View File
@@ -0,0 +1,207 @@
// 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"],
["bus", "start", "acme"],
["bus", "stop"],
["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 </, args.join(" "));
}
});
const listing = (dir) => 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);
});
+8 -7
View File
@@ -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) => {
+3 -2
View File
@@ -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.
+7 -5
View File
@@ -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()) {
+14 -4
View File
@@ -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,13 @@ 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, <dataRoot>/projects/<business id>/. It writes nothing; projectRepo only lstats the path.
// The area itself is refused too: a repository there would hold every other project's default one.
export function testerProjectArea(context) {
const repo = projectRepo(context);
const area = join(context.dataRoot, 'projects', context.business.id);
if (relative(area, repo) === '' || !contains(area, repo))
throw new ProjectError('tester-project-area', `the tester initializes only a repository under ${area}`);
return repo;
}
@@ -0,0 +1,64 @@
// Row 175 (#1673, mos-0872 Q2): `project init --tester` initializes only a repository under
// <dataRoot>/projects/<business>/, 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 inside the business area and refuses the area itself and 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'),
// The area itself: a repository there would hold the other projects' default repositories.
(root, dataRoot) => join(dataRoot, 'projects', 'acme'),
];
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' });
});
+1 -1
View File
@@ -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" "$@"