Files
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

147 lines
15 KiB
Diff

diff --git a/packages/bus/README.md b/packages/bus/README.md
index 236ba8178..06a93e33e 100644
--- a/packages/bus/README.md
+++ b/packages/bus/README.md
@@ -611,7 +611,7 @@ or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mj
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 (`thread.list` lists the tester's own threads since row 177), 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 and `project.file.save`. 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`.
-Row 177 (#1675, Q173) gives the tester threads of its own. `thread.create`, `thread.open`, `thread.send`, `thread.messages` and `assistant.stop` join `TESTER_VERBS`. A tester's thread has owner `tester` and its opens `by` `tester`; the human and the tester each reach only their own threads (`thread-not-owned`), and each `thread.list` lists only its own, so the human's list never shows a tester thread. A business whose `human` is `tester` refuses `invalid-business` at start, since the two would share threads. A tester's `thread.send` is stored `from_role 'tester'`, `from_run 'tester-cli'`, and returns `request: null` with no `human.input`, as its `message.send` does. Row 120's lists can't name the tester (`invalid-messaging`), so in a thread owned by `tester` the broker reads the tester as `human`: the tester's `thread.send` passes when the instance would hear the human, and the thread's run answers `to: 'tester'` (with `in_reply_to`, as above) when it could answer the human. The run's own `message.send` to `human` refuses `tester-thread-human`, and so does its `decision.raise` when the route comes out `human` (an uncited gated raise or a hard wait), before anything is stored. So neither its replies nor its decisions reach the human's inbox; the human sees its messages in the trail as `tester` traffic. That covers the run's own sends and raises only: the assistant's lists still let it message the chief of staff, a raise routed to the chief as arbiter stays allowed, and the chief can message the human. That is no new reach, since the chief has no lists and a tester's `message.send` already reaches it. The tester's `assistant.stop` revokes the runs of its own threads only, recorded `by` `tester`; the human's stop never touches them. Thread names are unique per project, not per owner, so a tester's `thread.create` with a name the human already used in that project answers `thread-exists`; that tells the tester the name exists and nothing more. A tester reads a reply with `message.receive` (what `talk --tester` and `follow --tester` use) or, in its own thread, `thread.messages`. `inbox --tester --json` prints decisions only, and the `messages` view reads the human's address, so neither shows a reply to the tester (sage-1148, #1673 comment 29334). Tests: `tests/tester-threads.test.mjs`.
+Row 177 (#1675, Q173) gives the tester threads of its own. `thread.create`, `thread.open`, `thread.send`, `thread.messages` and `assistant.stop` join `TESTER_VERBS`. A tester's thread has owner `tester` and its opens `by` `tester`; the human and the tester each reach only their own threads (`thread-not-owned`), and each `thread.list` lists only its own, so the human's list never shows a tester thread. Row 182 (#1680) holds a tester's `message.send` to the same rule. Its thread comes from the `in_reply_to` chain, or from a spec notice on that chain (row 143), and when that thread isn't the tester's the send refuses `thread-not-owned` and stores nothing. The code names a thread the tester never named, so the place to look is `in_reply_to`. A role's reply up a chain into a tester's thread still lands there, which is how the chief's answer comes back. A business whose `human` is `tester` refuses `invalid-business` at start, since the two would share threads. A tester's `thread.send` is stored `from_role 'tester'`, `from_run 'tester-cli'`, and returns `request: null` with no `human.input`, as its `message.send` does. Row 120's lists can't name the tester (`invalid-messaging`), so in a thread owned by `tester` the broker reads the tester as `human`: the tester's `thread.send` passes when the instance would hear the human, and the thread's run answers `to: 'tester'` (with `in_reply_to`, as above) when it could answer the human. The run's own `message.send` to `human` refuses `tester-thread-human`, and so does its `decision.raise` when the route comes out `human` (an uncited gated raise or a hard wait), before anything is stored. So neither its replies nor its decisions reach the human's inbox; the human sees its messages in the trail as `tester` traffic. That covers the run's own sends and raises only: the assistant's lists still let it message the chief of staff, a raise routed to the chief as arbiter stays allowed, and the chief can message the human. That is no new reach, since the chief has no lists and a tester's `message.send` already reaches it. The tester's `assistant.stop` revokes the runs of its own threads only, recorded `by` `tester`; the human's stop never touches them. Thread names are unique per project, not per owner, so a tester's `thread.create` with a name the human already used in that project answers `thread-exists`; that tells the tester the name exists and nothing more. A tester reads a reply with `message.receive` (what `talk --tester` and `follow --tester` use) or, in its own thread, `thread.messages`. `inbox --tester --json` prints decisions only, and the `messages` view reads the human's address, so neither shows a reply to the tester (sage-1148, #1673 comment 29334). Tests: `tests/tester-threads.test.mjs`.
## Shared reads and adapter boundary
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index b118452d8..f52a47386 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -334,18 +334,25 @@ export class Broker {
);
if (m?.thread) {
if (this.#store.get('SELECT instance FROM threads WHERE id=?', m.thread).instance !== to) fail('thread-instance-mismatch');
- return m.thread;
+ return this.#testerOwns(s, m.thread);
}
// Row 143: a chain that reaches a spec notice belongs to the user's thread in that notice's project.
if (m?.from_role === NOTICE_ROLE && m.body.startsWith(SPEC_NOTICE)) {
const spec = this.#specThread(s, to, m.body);
- if (spec) return spec;
+ if (spec) return this.#testerOwns(s, spec);
}
at = m?.in_reply_to ?? null;
}
// The code is the whole message a run sees (runner.mjs forwards codes only), so it says what to do.
fail('thread-required-reply-to-thread-message');
}
+ // Row 182 (#1680): a tester's message reaches only a thread the tester owns, as its thread verbs do
+ // (REQ-USER-2, row 177). A role's reply into a tester's thread is how the answer comes back, so
+ // only the tester is checked.
+ #testerOwns(s, thread) {
+ if (s.tester && this.#store.get('SELECT owner FROM threads WHERE id=?', thread).owner !== this.#owner(s)) fail('thread-not-owned');
+ return thread;
+ }
#thread(s, id) {
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found');
diff --git a/packages/bus/tests/messaging.test.mjs b/packages/bus/tests/messaging.test.mjs
index 883dccf66..d60401051 100644
--- a/packages/bus/tests/messaging.test.mjs
+++ b/packages/bus/tests/messaging.test.mjs
@@ -203,10 +203,16 @@ test('roles without the field behave exactly as before', (t) => {
test('a business with no role definitions at all is unlimited', (t) => {
const businesses = demo();
delete businesses.demo.definitions;
- const { send, refusals } = setup(t, businesses);
- // A send to the assistant still answers a message in its thread (row 121).
+ const { b, caps, call, send, refusals } = setup(t, businesses);
+ // A send to the assistant still answers a message in its thread (row 121). A tester message
+ // reaches only a thread the tester owns (row 182), so the tester answers in a thread of its own.
const opened = send('assistant', 'pm');
- for (const from of ['pm', 'tester']) send(from, 'assistant', { in_reply_to: opened.id });
+ send('pm', 'assistant', { in_reply_to: opened.id });
+ const project = call('tester', 'project.create', { name: 'test work' }).id;
+ const thread = call('tester', 'thread.create', { project, name: 'probe' }).id;
+ caps.probe = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'probe-run', harness: 'pi', thread });
+ call('probe', 'role.claim');
+ send('tester', 'assistant', { in_reply_to: call('probe', 'message.send', { to: 'pm', body: 'hello' }).id });
assert.deepEqual(refusals(), []);
});
diff --git a/packages/bus/tests/tester-threads.test.mjs b/packages/bus/tests/tester-threads.test.mjs
index 92eb25067..3a456ba18 100644
--- a/packages/bus/tests/tester-threads.test.mjs
+++ b/packages/bus/tests/tester-threads.test.mjs
@@ -227,3 +227,72 @@ test('row 177 T8: a tester-thread raise routed to the arbiter stays allowed', (t
assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', raised.id).route_to, 'chief');
assert.deepEqual(call('human', 'inbox'), []);
});
+
+// Row 182 (#1680, Filbert N1 on row 177): a tester's message.send to a threaded instance takes its
+// thread from the in_reply_to chain, so the owner check runs there too. The assistant here has no
+// lists, so #checkMessaging lets the tester through and the chain is what decides. `stack` is a
+// project the business names, for the spec notice test.
+const open = () => demo({ definitions: { 'chief-of-staff': {}, assistant: {} }, projects: { stack: { root: '/nonexistent/r182' } } });
+const rows = (store) => ['messages', 'message_threads'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
+
+test('row 182 T9: a tester reply into the human\'s thread refuses thread-not-owned and stores nothing', (t) => {
+ const { store, call, code, launch, H } = both(t, open());
+ launch('h', H);
+ const mine = call('human', 'thread.send', { thread: H, body: 'mine' }).id;
+ const before = [rows(store), call('human', 'thread.messages', { thread: H })];
+ assert.equal(code('tester', 'message.send', { to: 'assistant', body: 'in', in_reply_to: mine }), 'thread-not-owned');
+ assert.deepEqual([rows(store), call('human', 'thread.messages', { thread: H })], before);
+ assert.deepEqual(call('h', 'message.receive').map((m) => m.id), [mine]);
+});
+
+test('row 182 T9: a tester reply into its own thread lands there', (t) => {
+ const { store, call, launch, T } = both(t, open());
+ launch('a', T);
+ const asked = call('tester', 'thread.send', { thread: T, body: 'probe' }).id;
+ const again = call('tester', 'message.send', { to: 'assistant', body: 'more', in_reply_to: asked }).id;
+ assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', again).thread, T);
+ assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [asked, again]);
+ assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [asked, again]);
+});
+
+// The chief holds its role as a plain run, outside any thread.
+const chief = ({ b, caps, call }) => {
+ caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
+ call('chief', 'role.claim');
+};
+
+test('row 182 T9: the chief\'s reply up a chain into a tester\'s thread lands there', (t) => {
+ const ctx = both(t, open());
+ const { store, call, launch, T } = ctx;
+ launch('a', T);
+ chief(ctx);
+ const asked = call('tester', 'thread.send', { thread: T, body: 'ask the chief' }).id;
+ const relay = call('a', 'message.send', { to: 'chief', body: 'for you', in_reply_to: asked }).id;
+ const back = call('chief', 'message.send', { to: 'assistant', body: 'answer', in_reply_to: relay }).id;
+ assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', back).thread, T);
+ assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [asked, back]);
+ assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [asked, relay, back]);
+});
+
+test('row 182 T9: a tester reply to a spec notice refuses thread-not-owned; the chief\'s relay still lands', (t) => {
+ const ctx = both(t, open());
+ const { store, call, code, launch } = ctx;
+ const project = call('human', 'project.create', { name: 'stack', repo: 'stack' }).id;
+ const S = call('human', 'thread.create', { project, name: 'spec' }).id;
+ launch('s', S);
+ chief(ctx);
+ // The row #specNotice (broker.mjs) writes for a human save of the spec in project stack. The save
+ // itself needs spec-review.test.mjs's git fixture, which has no tester.
+ const notice = 'n-182';
+ store.run(
+ 'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
+ notice, new Date().toISOString(), 'demo', '_bus', 'bus', 'chief', 'RESULT', null, null, null,
+ 'The human saved spec/north-star.md in project stack: commit c1, from the editor\'s base c0.',
+ );
+ const before = rows(store);
+ assert.equal(code('tester', 'message.send', { to: 'assistant', body: 'in', in_reply_to: notice }), 'thread-not-owned');
+ assert.deepEqual(rows(store), before);
+ // The same chain from the chief reaches the human's thread, so the tester's send got as far as the notice.
+ const relay = call('chief', 'message.send', { to: 'assistant', body: 'relay', in_reply_to: notice }).id;
+ assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', relay).thread, S);
+});