row 177 (#1675, Q173): tester threads

The tester gets threads of its own: thread.create, thread.open, thread.send, thread.messages and
assistant.stop join TESTER_VERBS, with owner `tester`, and neither the tester nor the human reaches
or lists the other's threads. A tester thread.send is from tester-cli with no human.input; in a
tester thread the lists read the tester as the human, the run answers at `tester`, and its send to
`human` refuses tester-thread-human. The tester stop revokes only tester threads, by `tester`. A
business whose human is `tester` refuses invalid-business at start.

The tester listener (7333) serves chat through the TESTER_CLI caller, its fence admitting only the
four chat write routes, with the listener's own Origin required; 7332's chat is unchanged. The
Console composer is enabled for the tester. READMEs state calls (a)-(c), N1, N2 and where a tester
reads replies (sage-1148).

Design 99ce5780 approved in sage-1146 (#1675 comment 29333).
This commit is contained in:
2026-10-19 22:21:55 -05:00
committed by jason.woltje
parent a2eebf997e
commit 1f994257a5
16 changed files with 441 additions and 53 deletions
+3 -1
View File
@@ -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 <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`.
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`, 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
+37 -20
View File
@@ -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;
+194
View File
@@ -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');
});
+12 -7
View File
@@ -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);
+2 -1
View File
@@ -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");
+2 -1
View File
@@ -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);
+18 -8
View File
@@ -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) => {
+12 -3
View File
@@ -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.
+11 -3
View File
@@ -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 {
+2
View File
@@ -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; }
}
+2 -1
View File
@@ -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;
}
+6 -3
View File
@@ -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' });
+22 -1
View File
@@ -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(); }
});
+21
View File
@@ -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);
});
+30 -1
View File
@@ -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 };
}
+67 -3
View File
@@ -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']);
});