diff --git a/packages/bus/README.md b/packages/bus/README.md index f8dc20c9a..ebf71f0d0 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -609,7 +609,9 @@ writes them with the human as actor (`packages/business/README.md`, "Routes"). ` from `harnessLevels()`, which loads each harness module in the tree once at start, switched on or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mjs`. -A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs ` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`. 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`. +A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs ` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`. Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`, `limits`, `plugins`, `instances`, `project.list` and `thread.list`, each answering what the human's answers (`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`, so its replies stay in the thread and never reach the human's inbox; the human sees them in the trail as `tester` traffic. That covers the run's own sends only: the assistant's lists still let it message the chief of staff, 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 dd00b36cf..f540ebfee 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -94,8 +94,10 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task // sent to `tester`, and the human's read views. Every other verb refuses tester-refused. // 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. +// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs. 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', 'credentials']); + 'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials', + 'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop']); // Row 175: the organization verbs a tester may call. Its writes run every check the human's do // and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it. export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']); @@ -239,7 +241,8 @@ export class Broker { } } for (const [name, b] of Object.entries(this.#businesses)) { - if (!id(name) || b.id !== name || !id(b.human) || !object(b.roles) || !object(b.arbiters)) + // Row 177: a human named `tester` would share the tester's threads. + if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters)) fail('invalid-business'); for (const role of Object.keys(b.roles)) { if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business'); @@ -347,9 +350,13 @@ export class Broker { const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business); if (!t) fail('thread-not-found'); // REQ-USER-2: a user reaches only their own threads. - if (t.owner !== s.human) fail('thread-not-owned'); + if (t.owner !== this.#owner(s)) fail('thread-not-owned'); return t; } + // Row 177 (#1675): who owns the threads a session makes. A tester's are `tester`'s, never the human's. + #owner(s) { + return s.tester ? 'tester' : s.human; + } #agent(s) { if (s.tester) fail('tester-refused'); if (s.reader) fail('read-only'); @@ -1837,7 +1844,12 @@ export class Broker { if (to === 'tester') fail('unknown-role'); } if (to !== 'human' && to !== 'tester' && !Object.hasOwn(b.roles, to)) fail('unknown-role'); - this.#checkMessaging(s.business, this.#sender(s), to); + // Row 177 (#1675): a run of a tester's thread answers the tester, never the human, and the + // lists read the tester there as the human, so the run reaches it as far as it reaches the human. + const testerThread = !s.human && !s.tester && s.thread + && this.#store.get('SELECT owner FROM threads WHERE id=?', s.thread)?.owner === 'tester'; + if (testerThread && to === 'human') fail('tester-thread-human'); + this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to); string(a.body, 32768); if ( a.class !== undefined && @@ -2246,14 +2258,16 @@ export class Broker { // Row 155 (#1649, Q169): the human stops the assistant from the Console. It revokes the claim of // every thread of the instance the human made (REQ-USER-2), so each run exits at its next bus // call. The launcher starts one again only for a thread with pending messages or an open. + // Row 177 (#1675): a tester stops the runs of its own threads only, recorded by `tester`. case 'assistant.stop': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, ['instance'], ['instance']); if (!Object.hasOwn(b.roles, identifier(a.instance))) fail('unknown-role'); if (!this.#threaded(s.business, a.instance)) fail('not-assistant'); - const held = this.#threadHolders(s.business, a.instance, s.human); + const held = this.#threadHolders(s.business, a.instance, this.#owner(s)); if (held.length === 0) fail('not-held'); - for (const h of held) this.#claimEnd(s, h, 'revoke', null, 'stopped by the human'); + for (const h of held) + this.#claimEnd(s, h, 'revoke', null, s.tester ? 'stopped by the tester' : 'stopped by the human', s.tester ? 'tester' : s.human); return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) }; } case 'names': { @@ -2292,8 +2306,9 @@ export class Broker { return this.#store.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business); } // A thread belongs to the user who made it and to the business's one thread instance. + // Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's. case 'thread.create': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, ['project', 'name'], ['project', 'name']); if (!name(a.name)) fail('invalid-request'); if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)) @@ -2311,13 +2326,13 @@ export class Broker { s.business, a.project, instances[0], - s.human, + this.#owner(s), a.name, ); 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. + // Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's, + // and the tester's lists only its own (row 175 listed it the business human's). case 'thread.list': { if (!s.tester) this.#human(s); keys(a, ['project']); @@ -2327,24 +2342,26 @@ export class Broker { (a.project === undefined ? '' : ' AND project=?') + ' ORDER BY seq', s.business, - s.tester ? b.human : s.human, + this.#owner(s), ...(a.project === undefined ? [] : [a.project]), ); } // Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). case 'thread.open': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, ['thread'], ['thread']); const t = this.#thread(s, a.thread); - this.#store.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', this.#now(), t.id, s.human); + this.#store.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', this.#now(), t.id, this.#owner(s)); return { opened: t.id }; } + // Row 177 (#1675): a tester's message is the tester's, from tester-cli, and writes no + // human.input, as on message.send. In its own thread the lists read it as the human. case 'thread.send': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, ['thread', 'body', 'class'], ['thread', 'body']); const t = this.#thread(s, a.thread); // Row 120's lists hold here as on message.send: the instance must hear from the human. - this.#checkMessaging(s.business, this.#sender(s), t.instance); + this.#checkMessaging(s.business, 'human', t.instance); string(a.body, 32768); if (a.class !== undefined && !['REQUEST', 'INFO', 'REACTION'].includes(a.class)) fail('invalid-class'); const mid = randomUUID(); @@ -2353,8 +2370,8 @@ export class Broker { mid, this.#now(), s.business, - 'human', - 'human-cli', + s.tester ? 'tester' : 'human', + s.tester ? 'tester-cli' : 'human-cli', t.instance, a.class ?? 'INFO', null, @@ -2363,11 +2380,11 @@ export class Broker { a.body, ); this.#route(s.business, mid, t.instance, t.id); - return { id: mid, request: this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) }; + return { id: mid, request: s.tester ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) }; } // The thread's history, oldest first. Reading writes nothing. case 'thread.messages': { - this.#human(s); + if (!s.tester) this.#human(s); keys(a, ['thread', 'after', 'limit'], ['thread']); const t = this.#thread(s, a.thread); const limit = a.limit ?? THREAD_PAGE; diff --git a/packages/bus/tests/tester-threads.test.mjs b/packages/bus/tests/tester-threads.test.mjs new file mode 100644 index 000000000..b54c0aaa7 --- /dev/null +++ b/packages/bus/tests/tester-threads.test.mjs @@ -0,0 +1,194 @@ +// Row 177 (#1675, Q173): the tester's own threads. Owner `tester`; neither the human nor the +// tester reaches the other's threads. The assistant's lists are the shipped roles/assistant.json's, +// and in a tester's thread the broker reads the tester as the human. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Store } from '../src/store.mjs'; +import { Broker } from '../src/broker.mjs'; + +const shipped = (name) => + JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); +const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; +const roles = { + chief: { definition: 'chief-of-staff', ...none }, + assistant: { definition: 'assistant', ...none }, +}; +const demo = (extra = {}, messaging = shipped('assistant').messaging) => ({ + demo: { + id: 'demo', + human: 'jason', + arbiters: { technical: 'chief', delivery: 'chief' }, + vars: { 'bus.tester': true }, + roles, + definitions: { 'chief-of-staff': {}, assistant: { messaging } }, + ...extra, + }, +}); + +function setup(t, businesses = demo()) { + const root = mkdtempSync(join(tmpdir(), 'bus-tester-threads-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const b = new Broker({ store, businesses }); + const caps = { + human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), + tester: b.bindTester({ business: 'demo' }), + }; + const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); + const code = (who, verb, args = {}) => { + try { + call(who, verb, args); + } catch (e) { + return e.code; + } + return null; + }; + const launch = (name, thread) => { + caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread }); + return call(name, 'role.claim'); + }; + const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n; + return { b, store, caps, call, code, launch, inputs }; +} + +// A project of each, with one thread of the human's (H) and one of the tester's (T). +function both(t, businesses) { + const ctx = setup(t, businesses); + const hp = ctx.call('human', 'project.create', { name: 'human work' }).id; + const tp = ctx.call('tester', 'project.create', { name: 'test work' }).id; + const H = ctx.call('human', 'thread.create', { project: hp, name: 'mine' }).id; + const T = ctx.call('tester', 'thread.create', { project: tp, name: 'probe' }).id; + return { ...ctx, hp, tp, H, T }; +} + +test('row 177 T1: a tester thread is owned by tester, and neither side reaches or lists the other', (t) => { + const { store, call, code, hp, H, T } = both(t); + assert.equal(store.get('SELECT owner FROM threads WHERE id=?', T).owner, 'tester'); + assert.equal(store.get('SELECT owner FROM threads WHERE id=?', H).owner, 'jason'); + assert.deepEqual(call('human', 'thread.list').map((x) => x.id), [H]); + assert.deepEqual(call('tester', 'thread.list').map((x) => x.id), [T]); + assert.deepEqual(call('tester', 'thread.list', { project: hp }), []); + for (const [verb, args] of [ + ['thread.open', {}], + ['thread.send', { body: 'hello' }], + ['thread.messages', {}], + ]) { + assert.equal(code('human', verb, { thread: T, ...args }), 'thread-not-owned', `human ${verb}`); + assert.equal(code('tester', verb, { thread: H, ...args }), 'thread-not-owned', `tester ${verb}`); + } + // The accepted leak: names are unique per project, not per owner. + assert.equal(code('tester', 'thread.create', { project: hp, name: 'mine' }), 'thread-exists'); + // A tester may make a thread in the human's project; it is still the tester's. + const X = call('tester', 'thread.create', { project: hp, name: 'side' }).id; + assert.deepEqual(call('human', 'thread.list', { project: hp }).map((x) => x.id), [H]); + assert.deepEqual(call('tester', 'thread.list').map((x) => x.id), [T, X]); +}); + +test('row 177 T2: the tester thread.send is from tester-cli with no human.input; its open is by tester', (t) => { + const { store, call, inputs, H, T } = both(t); + const before = inputs(); + const sent = call('tester', 'thread.send', { thread: T, body: 'hello' }); + assert.equal(sent.request, null); + assert.equal(inputs(), before); + const m = store.get('SELECT * FROM messages WHERE id=?', sent.id); + assert.equal(m.from_role, 'tester'); + assert.equal(m.from_run, 'tester-cli'); + assert.equal(m.to_role, 'assistant'); + assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', sent.id).thread, T); + assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [sent.id]); + assert.deepEqual(call('tester', 'thread.open', { thread: T }), { opened: T }); + assert.equal(store.get('SELECT by FROM thread_opens WHERE thread=?', T).by, 'tester'); + // The human's send still writes its human.input. + const own = call('human', 'thread.send', { thread: H, body: 'mine' }); + assert.ok(own.request); + assert.equal(inputs(), before + 1); +}); + +test('row 177 T3: the lists read the tester as the human in its own thread, and nowhere else', (t) => { + const { code, call, T } = both(t); + assert.ok(call('tester', 'thread.send', { thread: T, body: 'in the thread' }).id); + // Outside a thread the tester is still not on the assistant's lists. + assert.equal(code('tester', 'message.send', { to: 'assistant', body: 'direct' }), 'message-sender-refused'); +}); + +test('row 177 T3: an assistant that would not hear the human refuses the tester too', (t) => { + const { code, T } = both(t, demo({}, { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['chief-of-staff'] })); + assert.equal(code('tester', 'thread.send', { thread: T, body: 'hello' }), 'message-sender-refused'); +}); + +test('row 177 T4: the tester thread run answers the tester, never the human', (t) => { + const { store, call, code, launch, H, T } = both(t); + const asked = call('tester', 'thread.send', { thread: T, body: 'probe' }).id; + launch('a', T); + const reply = call('a', 'message.send', { to: 'tester', body: 'answer', class: 'RESULT', in_reply_to: asked }).id; + const r = store.get('SELECT * FROM messages WHERE id=?', reply); + assert.equal(r.to_role, 'tester'); + assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', reply).thread, T); + assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [asked, reply]); + assert.equal(call('human', 'messages').messages.some((m) => m.id === reply), false); + assert.deepEqual(call('human', 'message.receive'), []); + assert.deepEqual(call('tester', 'message.receive').map((m) => m.id), [reply]); + // The run's own message to the human refuses. + assert.equal(code('a', 'message.send', { to: 'human', body: 'psst' }), 'tester-thread-human'); + assert.equal(call('human', 'messages').total, 0); + // A run of the human's thread still answers the human. + call('human', 'thread.send', { thread: H, body: 'mine' }); + launch('h', H); + assert.ok(call('h', 'message.send', { to: 'human', body: 'yours' }).id); + assert.equal(call('human', 'messages').total, 1); + // Outside a tester's thread the lists still don't name the tester, so the human's thread run + // can't answer it even in reply to a tester's message. + assert.equal(code('h', 'message.send', { to: 'tester', body: 'x', in_reply_to: asked }), 'message-recipient-refused'); +}); + +test('row 177 T5: each stop revokes only its own threads, recorded by its own actor', (t) => { + const { store, call, code, launch, H, T } = both(t); + launch('a', T); + launch('h', H); + const last = (thread) => store.get('SELECT * FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread); + assert.deepEqual(call('tester', 'assistant.stop', { instance: 'assistant' }), { stopped: [{ thread: T, run: 'a-run' }] }); + assert.equal(last(T).op, 'revoke'); + assert.equal(last(T).by, 'tester'); + assert.equal(last(T).reason, 'stopped by the tester'); + assert.equal(last(H).op, 'claim'); + assert.equal(code('tester', 'assistant.stop', { instance: 'assistant' }), 'not-held'); + assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), { stopped: [{ thread: H, run: 'h-run' }] }); + assert.equal(last(H).by, 'jason'); + assert.equal(last(H).reason, 'stopped by the human'); + assert.equal(code('human', 'assistant.stop', { instance: 'assistant' }), 'not-held'); +}); + +test('row 177 T5: the human stop never ends a tester run', (t) => { + const { store, code, launch, T } = both(t); + launch('a', T); + assert.equal(code('human', 'assistant.stop', { instance: 'assistant' }), 'not-held'); + assert.equal(store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', T).op, 'claim'); +}); + +test('row 177 T6: a business whose human is tester refuses invalid-business', (t) => { + const root = mkdtempSync(join(tmpdir(), 'bus-tester-threads-boot-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const businesses = demo(); + businesses.demo.human = 'tester'; + assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' }); +}); + +test('row 177 T7: the human trail shows a tester thread message as the tester', (t) => { + const { call, T } = both(t); + const sent = call('tester', 'thread.send', { thread: T, body: 'probe' }).id; + const rows = call('human', 'trail', { subject: sent }).filter((r) => r.table === 'messages'); + assert.equal(rows.length, 1); + assert.equal(rows[0].from_role, 'tester'); + assert.equal(rows[0].from_run, 'tester-cli'); +}); diff --git a/packages/bus/tests/tester-writes.test.mjs b/packages/bus/tests/tester-writes.test.mjs index c9a84b009..22d97bf0f 100644 --- a/packages/bus/tests/tester-writes.test.mjs +++ b/packages/bus/tests/tester-writes.test.mjs @@ -12,12 +12,15 @@ 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', {}]]; +// Row 177 (#1675): thread.list left this list; the tester's lists its own threads (tester-threads.test.mjs). +const READS = [['usage', {}], ['names', {}], ['limits', {}], ['plugins', {}], ['instances', {}], ['project.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', 'credentials', + // Row 177 (#1675): the tester's own threads. + 'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop', ]); assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']); assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS)); @@ -60,9 +63,9 @@ test('row 175: each of the seven reads as the tester deep-equals the human read' 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']); + // Not empty, so names has a row. Row 177: the human's thread is not the tester's to list. + assert.equal(r.call(r.human, 'thread.list').length, 1); + assert.deepEqual(r.call(r.tester, 'thread.list', { project }), []); assert.equal(r.call(r.tester, 'names').length, 1); assert.equal(r.call(r.tester, 'project.list').length, 1); }); @@ -129,12 +132,14 @@ test('row 175: the human-only actions refuse a tester tester-refused, and the fi ['decision.resolve', { id: 'd-1', choice: 'yes' }], ['launch.revoke', {}], ['launch.restore', {}], - ['assistant.stop', { thread }], - ['thread.create', { project, name: 'two' }], + ]) assert.equal(r.code(r.tester, verb, args), 'tester-refused', verb); + // Row 177 (#1675): the thread verbs are the tester's now, but the human's thread is not. + for (const [verb, args] of [ ['thread.open', { thread }], ['thread.send', { thread, body: 'hello' }], ['thread.messages', { thread }], - ]) assert.equal(r.code(r.tester, verb, args), 'tester-refused', verb); + ]) assert.equal(r.code(r.tester, verb, args), 'thread-not-owned', verb); + assert.equal(r.code(r.tester, 'assistant.stop', { instance: 'assistant' }), 'not-held'); // 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) }]]) await assert.rejects(Promise.resolve().then(() => r.call(r.tester, verb, args)), { code: 'project-spec-human-only' }, verb); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index b487576de..09e9467d0 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -356,7 +356,8 @@ async function talk(rest, io, deps, receiveOnly = false) { if (parsed.positional.length !== (receiveOnly ? 0 : threadRef === undefined ? 2 : 1)) { throw usage(receiveOnly ? "follow takes no arguments" : threadRef === undefined ? "talk takes a role and one quoted text" : "talk --thread takes one quoted text"); } - // The thread verbs are the human's own: the tester has no thread. + // Row 177 (#1675): the tester has threads of its own now, but the CLI's thread verbs stay the + // human's; a tester talk --thread is a follow-up row. if (threadRef !== undefined && parsed.flags.has("--tester")) throw usage("--thread takes no --tester"); if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows"); const follow = receiveOnly || parsed.flags.has("--follow"); diff --git a/packages/cli/src/thread.mjs b/packages/cli/src/thread.mjs index 5c8579c49..8eda59b76 100644 --- a/packages/cli/src/thread.mjs +++ b/packages/cli/src/thread.mjs @@ -107,7 +107,8 @@ 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. +// Row 175 (#1673, Q173): with --tester the tester lists threads. Since row 177 (#1675) those are +// its own threads (owner tester); the CLI has no other tester thread verb. export async function threadsCommand(args, io, callFor) { 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); diff --git a/packages/cli/tests/tester-reads.test.mjs b/packages/cli/tests/tester-reads.test.mjs index 718f0ded8..db8f97bce 100644 --- a/packages/cli/tests/tester-reads.test.mjs +++ b/packages/cli/tests/tester-reads.test.mjs @@ -1,6 +1,7 @@ -// 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`. +// Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the CLI's tester forms. The reads print what the +// human's forms print (since row 177 `threads --tester` lists the tester's own), 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"; @@ -52,7 +53,7 @@ const READS = [ ["names"], ["limits"], ["plugins"], - ["threads"], + // Row 177 (#1675): `threads` left this list; the tester's lists its own threads (below). ["project", "list"], ]; @@ -78,10 +79,19 @@ test("row 175: each read with --tester inside an agent prints the human form's o 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"]); + // Row 177 (#1675): the tester's threads are its own, so the human's "one" is not in its list. + const testerThreads = async () => { + const x = inAgent(s.f.env); + await main(["threads", "--business", "acme", "--json", "--tester"], x, s.deps); + return JSON.parse(x.out.text).map((r) => r.name); + }; + assert.deepEqual(await testerThreads(), []); + s.rt.broker.request(s.tester, { verb: "thread.create", args: { project, name: "t-one" } }); + assert.deepEqual(await testerThreads(), ["t-one"]); + const h = io(s.f.env); + await main(["threads", "--business", "acme", "--json"], h, s.deps); + assert.deepEqual(JSON.parse(h.out.text).map((r) => r.name), ["one"]); + await assert.rejects(main(["threads", "--business", "acme"], inAgent(s.f.env), s.deps), (e) => e.exitCode === 3, "threads without --tester"); }); test("row 175: --tester on limits set, reset and migrate and every write form outside W1-W7 exits 4 before any call", async (t) => { diff --git a/packages/webui/README.md b/packages/webui/README.md index 8277badba..e3448f2a2 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -945,11 +945,20 @@ thread-scoped; sign-out clears private chat state. Bodies are inert text. `serve --tester-port 7333` starts a second listener on `127.0.0.1` only and requires the bus config. The main listener keeps the human transport and rejects `x-mosaic-tester` for every Host. The second listener rejects the main preview -auth header and uses `TESTER_CLI` for bus reads and the instance, route and name -writes. Its banner says tester; it offers only those editors. It never enables -settings saves, document saves, assistant stop, messages to roles or chat writes. +auth header and uses `TESTER_CLI` for bus reads, the instance, route and name +writes, and chat. Its banner says tester; it offers only those editors and the +chat composer. It never enables settings saves, document saves, assistant stop or +messages to roles. Both credential and committed-project reads keep their existing secret/path checks. +Its chat (row 177, #1675) is the tester's own. The chat calls the bus through +`TESTER_CLI`, so it lists, opens and sends only in threads owned by `tester`, and +a human's thread answers 403 `thread-not-owned`. The main listener's chat stays +the human's and never lists tester threads. A tester send starts an assistant +turn, so a loopback caller on this port can start assistant turns, which row 176's +listener could not. The business var `bus.tester` stays the switch: while it is +off, chat answers 403 `tester-off` like every other tester call. + The tester port must be 1024..65535, different from `--port`, and never the forwarded port 7332. Writes need the listener's own loopback Origin and same-origin Fetch Metadata, plus the existing bounded strict JSON bodies. diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs index 5a92dcff8..f88f70b7d 100644 --- a/packages/webui/src/chat-http.mjs +++ b/packages/webui/src/chat-http.mjs @@ -16,6 +16,7 @@ const STATUS = { 'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503, 'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503, + 'tester-off': 403, 'tester-required': 403, 'tester-refused': 403, }; function safeMessage(code) { if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].'; @@ -50,9 +51,16 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev }); if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed'); if (write) { - if (!send || !named || !preview) return refuse(403, 'read-only'); - if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated'); - if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin'); + if (preview?.tester) { + // Row 177 (#1675): the tester listener's chat calls the bus as the tester, so there is no + // sign-in; the Origin is the listener's own, as on its other writes. + if (!chat) return refuse(403, 'read-only'); + if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin'); + } else { + if (!send || !named || !preview) return refuse(403, 'read-only'); + if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated'); + if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin'); + } if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site'); } try { diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index f13d152c0..bb8775137 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -89,6 +89,8 @@ try { tester: true, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads, projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args), instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args), + // Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's. + chat: createChat({ call }), }); } catch (err) { await new Promise(resolve => server.close(resolve)); throw err; } } diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 01c12b9e1..391c405d4 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -1092,7 +1092,8 @@ sections(path); window.MosaicChat?.leave(path); if (path === '/chat' || path.startsWith('/chat/')) { - await window.MosaicChat.render({ path, container: $('bus-view'), canSend, navigated }); + // Row 177 (#1675): the tester Console writes to its own threads. + await window.MosaicChat.render({ path, container: $('bus-view'), canSend: canSend || isTester, navigated }); if (g !== gen || out()) return; shown = route; schedule(); return; } diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 15ae40523..8846e7341 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -60,6 +60,8 @@ async function body(req, max = 4096) { // Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback. const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes']; const DECISION_LISTS = ['roles', 'recent']; +// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes. +const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i; async function busRead(res, bus, verb, search) { let arg; if (verb === 'usage') { @@ -439,7 +441,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (host === 'localhost') host = '127.0.0.1'; if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1'); - if (tester && [send, settingsWrite, projectSave, assistantStop, chat].some(value => value !== null)) throw new Error('tester listener permits only instance, route and name writes'); + // Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads. + if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes'); const upstream = boardURL(board); // An object with none of the three is refused too: asking for the preview never serves without it. if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { @@ -477,7 +480,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' }); } const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview; - if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path)) + if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path)) return json(res, 403, { error: 'read-only', sent: false }); if(path==='/api/settings'&&req.method==='POST'){ if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false}); @@ -509,7 +512,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return busSend(req, res, { send, preview, log }); } if (path === '/api/chat' || path.startsWith('/api/chat/')) { - return chatHTTP(req, res, { path, search, chat, named, send, preview, log }); + return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log }); } // Nothing else but GET and HEAD gets past here in preview mode, whatever the Host. if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' }); diff --git a/packages/webui/tests/tester-browser.test.mjs b/packages/webui/tests/tester-browser.test.mjs index 1df5adab0..8fbb061d9 100644 --- a/packages/webui/tests/tester-browser.test.mjs +++ b/packages/webui/tests/tester-browser.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { browser } from './browser.mjs'; -import { testerFixture } from './tester-fixture.mjs'; +import { testerFixture, testerChat } from './tester-fixture.mjs'; test('tester Console banner exposes only instance route and name editors and saves a route as tester', { timeout: 60000 }, async t => { const s = await testerFixture(t), b = await browser(); @@ -23,3 +23,24 @@ test('tester Console banner exposes only instance route and name editors and sav assert.ok(s.lines.some(l => l.includes('routes user="tester"'))); } finally { await b.close(); } }); + +test('row 177: the tester Console chat composer is enabled and a send shows in its own thread', { timeout: 60000 }, async t => { + const s = await testerChat(t), b = await browser(); + try { + const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<150;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); + const tester = s.broker.bindTester({ business: 'demo' }); + const project = s.broker.request(tester, { verb: 'project.create', args: { name: 'probe work' } }).id; + const thread = s.broker.request(tester, { verb: 'thread.create', args: { project, name: 'probe' } }).id; + await b.navigate(`http://127.0.0.1:${s.web.address().port}/#/chat/threads/${thread}`); + await wait('!!document.querySelector("#tester-banner") && document.querySelector("#chat-body")?.disabled === false'); + await b.evaluate(`(()=>{const e=document.querySelector('#chat-body');e.value='from the tester console';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#chat-send-form').requestSubmit();return true;})()`); + await wait('document.querySelector("#chat-receipt")?.textContent.includes("Accepted")'); + // The log shows a send at the next read, as on the human Console; Refresh reads now. + await wait('!document.querySelector("#refresh").disabled'); + await b.evaluate('document.querySelector("#refresh").click(),true'); + await wait('document.querySelector(".chat-log")?.textContent.includes("from the tester console")'); + const m = s.store.get("SELECT * FROM messages WHERE body='from the tester console'"); + assert.equal(m.from_role, 'tester'); assert.equal(m.from_run, 'tester-cli'); + assert.ok(s.lines.some(l => l.includes('action=send user="tester"'))); + } finally { await b.close(); } +}); diff --git a/packages/webui/tests/tester-cli.test.mjs b/packages/webui/tests/tester-cli.test.mjs index 49b1f3253..a6cabe039 100644 --- a/packages/webui/tests/tester-cli.test.mjs +++ b/packages/webui/tests/tester-cli.test.mjs @@ -56,4 +56,25 @@ test('CLI tester listener binds loopback and spawns TESTER_CLI for every Console assert.equal(calls.some(c => c.cli === HUMAN_CLI), false); assert.ok(![401, 403, 500, 502, 503].includes(r.status), path + ': ' + r.body); } + // Row 177 (#1675): the tester port's chat reaches the bus through TESTER_CLI as well. + const { revision, plugins } = await f.call('plugins'); + await f.call('plugins.set', { revision, plugins: { ...plugins, channel: ['cli', 'webui'] } }); + const send = (path, value) => new Promise((resolve, reject) => { + const q = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => { + let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text })); + }); q.on('error', reject); q.end(JSON.stringify(value)); + }); + const spawns = async (label, go) => { + const before = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).length; + const r = await go(); + const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse).slice(before); + assert.ok(calls.length > 0, label); assert.ok(calls.every(c => c.cli === TESTER_CLI), label); + return r; + }; + for (const path of ['/api/chat/projects', '/api/chat/threads']) assert.equal((await spawns(path, () => get(port, path))).status, 200, path); + const project = await spawns('create project', () => send('/api/chat/projects', { name: 'tester work' })); + assert.equal(project.status, 200, project.body); + // The fixture business has no assistant instance, so the thread's refusal comes from the bus. + const thread = await spawns('create thread', () => send('/api/chat/threads', { project: JSON.parse(project.body).id, name: 'probe' })); + assert.equal(JSON.parse(thread.body).error, 'thread-instance-missing', thread.body); }); diff --git a/packages/webui/tests/tester-fixture.mjs b/packages/webui/tests/tester-fixture.mjs index 742421741..cdf4abeb2 100644 --- a/packages/webui/tests/tester-fixture.mjs +++ b/packages/webui/tests/tester-fixture.mjs @@ -9,10 +9,12 @@ import { loadBusiness } from '../../business/src/index.mjs'; import { writeJson } from '../../business/tests/helpers.mjs'; import { initProject } from '../../project/src/index.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; -import { BusError } from '../../bus/src/broker.mjs'; +import { Broker, BusError } from '../../bus/src/broker.mjs'; +import { Store } from '../../bus/src/store.mjs'; import { BusReadError } from '../src/bus.mjs'; import { views } from '../../bus/src/views.mjs'; import { startServer } from '../src/serve.mjs'; +import { createChat } from '../src/chat.mjs'; import { close } from './fixture.mjs'; export const TOKEN = 'row176-fixture-token-sentinel'; @@ -65,3 +67,30 @@ export async function testerFixture(t, { enabled = true } = {}) { }; return { root, f, file, tokenPath, runtime, calls, call, web, lines, history, nameRows }; } + +// Row 177 (#1675): a tester listener whose chat calls a plain broker as the tester. The shared +// fixture has no assistant role, and other tests count its roles, so the chat gets its own business. +export async function testerChat(t, { enabled = true } = {}) { + const root = tmp(t, 'console-tester-chat-'); + const store = new Store(root); + t.after(() => store.close()); + const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; + const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, + vars: { 'bus.tester': enabled }, roles: { assistant: { definition: 'assistant', ...none }, chief: { definition: 'chief-of-staff', ...none } }, + definitions: { assistant: {}, 'chief-of-staff': {} } } } }); + const human = (verb, args = {}) => broker.request(broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), { verb, args }); + const calls = []; + const chat = createChat({ call: async (verb, args) => { + calls.push(verb); + try { + // Bound at each call, as the tester CLI is, so bus.tester off refuses before any read. + const tester = broker.bindTester({ business: 'demo' }); + if (verb === 'plugins') return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; + return structuredClone(broker.request(tester, { verb, args })); + } catch (err) { if (err instanceof BusError) throw new BusReadError(err.code); throw err; } + } }); + const lines = []; + const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', chat, log: line => lines.push(line) }); + t.after(() => close(web)); + return { store, broker, human, calls, web, lines }; +} diff --git a/packages/webui/tests/tester-listener.test.mjs b/packages/webui/tests/tester-listener.test.mjs index 51be2b458..9588bf1fb 100644 --- a/packages/webui/tests/tester-listener.test.mjs +++ b/packages/webui/tests/tester-listener.test.mjs @@ -4,7 +4,7 @@ import { request, createServer } from 'node:http'; import { startServer } from '../src/serve.mjs'; import { BusReadError } from '../src/bus.mjs'; import { close } from './fixture.mjs'; -import { testerFixture, TOKEN, VARIABLE, OUTSIDE } from './tester-fixture.mjs'; +import { testerFixture, testerChat, TOKEN, VARIABLE, OUTSIDE } from './tester-fixture.mjs'; export function http(server, path, { method = 'GET', headers = {}, data } = {}) { const port = server.address().port; @@ -73,13 +73,13 @@ test('tester listener writes only instances routes and names with tester receipt }); test('tester listener refuses every human-only write with no bus call', async t => { - for (const key of ['settingsWrite', 'projectSave', 'assistantStop', 'chat']) { + for (const key of ['settingsWrite', 'projectSave', 'assistantStop']) { const unsafe = await startServer({ port: 0, tester: true, [key]: async () => ({}) }).catch(e => e); if (!(unsafe instanceof Error)) { await close(unsafe); assert.fail('tester accepted forbidden capability ' + key); } assert.match(unsafe.message, /tester.*writes/i); } const s = await testerFixture(t); - for (const path of ['/api/settings', '/api/project-file', '/api/assistant-stop', '/api/bus/message', '/api/chat/projects', '/api/chat/threads', '/api/chat/threads/x/messages', '/api/chat/threads/x/open']) { + for (const path of ['/api/settings', '/api/project-file', '/api/assistant-stop', '/api/bus/message']) { const r = await post(s.web, path, { any: 'value' }); assert.equal(r.status, 403, path); assert.deepEqual(s.calls, []); } @@ -117,3 +117,67 @@ test('tester credentials routes and committed project reads keep secrets and pat assert.equal(r.text.includes(OUTSIDE), false); } }); + +// Row 177 (#1675): the tester listener's chat is the tester's own. +test('row 177 W1: the tester chat makes, sends to, opens and reads its own thread as tester', async t => { + const s = await testerChat(t); + const project = await post(s.web, '/api/chat/projects', { name: 'probe work' }); + assert.equal(project.status, 200, project.text); + const thread = await post(s.web, '/api/chat/threads', { project: project.body.id, name: 'probe' }); + assert.equal(thread.status, 200, thread.text); assert.equal(thread.body.instance, 'assistant'); + const sent = await post(s.web, `/api/chat/threads/${thread.body.id}/messages`, { body: 'hello' }); + assert.equal(sent.status, 200, sent.text); + const opened = await post(s.web, `/api/chat/threads/${thread.body.id}/open`, {}); + assert.equal(opened.status, 200, opened.text); assert.equal(opened.body.opened, thread.body.id); + const read = await http(s.web, `/api/chat/threads/${thread.body.id}/messages`); + assert.equal(read.status, 200, read.text); + assert.deepEqual(read.body.rows.map(m => [m.id, m.from_role]), [[sent.body.id, 'tester']]); + assert.deepEqual((await http(s.web, '/api/chat/threads')).body.rows.map(r => r.id), [thread.body.id]); + assert.equal(s.store.get('SELECT owner FROM threads WHERE id=?', thread.body.id).owner, 'tester'); + assert.equal(s.store.get('SELECT from_run FROM messages WHERE id=?', sent.body.id).from_run, 'tester-cli'); + assert.equal(s.lines.length, 4); assert.ok(s.lines.every(l => l.includes('user="tester"')), s.lines.join('\n')); + assert.deepEqual(s.human('thread.list'), []); +}); + +test('row 177 W2: tester chat writes refuse a missing or foreign Origin, cross-site, text/plain and the proxy header with no bus call', async t => { + const s = await testerChat(t), port = s.web.address().port; + const bare = { 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; + assert.equal((await http(s.web, '/api/chat/projects', { method: 'POST', data: { name: 'nope' }, headers: bare })).status, 403); + assert.deepEqual(s.calls, []); + for (const headers of [{ origin: 'http://elsewhere' }, { origin: `http://127.0.0.1:${port + 1}` }, { 'sec-fetch-site': 'cross-site' }, { 'content-type': 'text/plain' }, { 'x-user': 'jason' }]) { + const r = await post(s.web, '/api/chat/projects', { name: 'nope' }, headers); + assert.ok([400, 403].includes(r.status), JSON.stringify(headers) + ': ' + r.text); + assert.deepEqual(s.calls, []); + } +}); + +test('row 177 W3: the human thread through the tester chat is thread-not-owned, and bus.tester off answers tester-off', async t => { + const s = await testerChat(t); + const H = s.human('thread.create', { project: s.human('project.create', { name: 'human work' }).id, name: 'mine' }).id; + for (const [method, path, data] of [['GET', `/api/chat/threads/${H}/messages`], ['POST', `/api/chat/threads/${H}/messages`, { body: 'x' }], ['POST', `/api/chat/threads/${H}/open`, {}]]) { + const r = method === 'GET' ? await http(s.web, path) : await post(s.web, path, data); + assert.equal(r.status, 403, path + ': ' + r.text); assert.equal(r.body.error, 'thread-not-owned'); + } + assert.equal(s.store.get('SELECT count(*) n FROM messages').n, 0); + assert.equal(s.store.get('SELECT count(*) n FROM thread_opens').n, 0); + const off = await testerChat(t, { enabled: false }); + for (const r of [await http(off.web, '/api/chat/projects'), await post(off.web, '/api/chat/projects', { name: 'x' })]) { + assert.equal(r.status, 403, r.text); assert.equal(r.body.error, 'tester-off'); + } + assert.equal(off.store.get('SELECT count(*) n FROM projects').n, 0); +}); + +test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and writes before the chat', async t => { + const calls = []; + const chat = Object.fromEntries(['projects', 'createProject', 'threads', 'createThread', 'messages', 'send', 'open'].map(k => [k, async () => { calls.push(k); return []; }])); + const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', preview: { host: 'console.example', header: 'x-user', user: 'jason' }, send: async () => ({}), chat, log: () => {} }); + t.after(() => close(web)); + const signed = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; + for (const [method, path, data] of [['GET', '/api/chat/threads'], ['POST', '/api/chat/threads/20000000-0000-4000-8000-000000000002/messages', { body: 'x' }]]) { + const r = await http(web, path, { method, data, headers: { ...signed, 'x-mosaic-tester': '' } }); + assert.equal(r.status, 403, path); assert.equal(r.body.error, 'tester listener required'); assert.deepEqual(calls, []); + } + // The control: without the header the same read reaches the chat. + assert.equal((await http(web, '/api/chat/threads', { headers: signed })).status, 200); + assert.deepEqual(calls, ['threads']); +});