row 182 (#1680): a tester message reaches only a thread the tester owns
#threadFor puts a message to a threaded instance in the thread found up its in_reply_to chain. It checked the thread's instance but not its owner, so a tester reply to a message in a human's thread landed in the human's thread. A new #testerOwns check refuses thread-not-owned when the sender is the tester and the thread's owner isn't. It runs on both the thread branch and the row 143 spec-notice branch. A role's reply into a tester's thread is unchanged, since that's how the answer comes back. The bus README says so in its row 177 paragraph. tester-threads.test.mjs adds four T9 tests: a tester reply into the human's thread refuses and stores nothing, one into its own thread lands, the chief's reply into a tester thread lands, and a tester reply to a spec notice refuses while the chief's relay still lands. messaging.test.mjs:203 used to send as the tester into a human thread. It now sends from a tester-owned thread. Filbert approved round 1 in #1680 comment 29388. Their notes N1 (thread-instance-mismatch is checked first, as before) and N2 (BUILD.md missed project-runtime.test.mjs:13 as a text reader of broker.mjs, now corrected) need no change.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(), []);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user