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:
dewey committed 2026-10-19 21:30:56 -05:00
1 parent c49de08e86
commit 1971683e14
16 files changed
+441 -53

No files matched your search

+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 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`. 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 ## 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. // 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, // 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. // 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', 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 // 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. // 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']); 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)) { 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'); fail('invalid-business');
for (const role of Object.keys(b.roles)) { for (const role of Object.keys(b.roles)) {
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business'); 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); const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found'); if (!t) fail('thread-not-found');
// REQ-USER-2: a user reaches only their own threads. // 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; 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) { #agent(s) {
if (s.tester) fail('tester-refused'); if (s.tester) fail('tester-refused');
if (s.reader) fail('read-only'); if (s.reader) fail('read-only');
@@ -1837,7 +1844,12 @@ export class Broker {
if (to === 'tester') fail('unknown-role'); if (to === 'tester') fail('unknown-role');
} }
if (to !== 'human' && to !== 'tester' && !Object.hasOwn(b.roles, to)) 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); string(a.body, 32768);
if ( if (
a.class !== undefined && 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 // 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 // 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. // 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': { case 'assistant.stop': {
this.#human(s); if (!s.tester) this.#human(s);
keys(a, ['instance'], ['instance']); keys(a, ['instance'], ['instance']);
if (!Object.hasOwn(b.roles, identifier(a.instance))) fail('unknown-role'); if (!Object.hasOwn(b.roles, identifier(a.instance))) fail('unknown-role');
if (!this.#threaded(s.business, a.instance)) fail('not-assistant'); 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'); 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 })) }; return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) };
} }
case 'names': { 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); 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. // 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': { case 'thread.create': {
this.#human(s); if (!s.tester) this.#human(s);
keys(a, ['project', 'name'], ['project', 'name']); keys(a, ['project', 'name'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request'); if (!name(a.name)) fail('invalid-request');
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)) 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, s.business,
a.project, a.project,
instances[0], instances[0],
s.human, this.#owner(s),
a.name, a.name,
); );
return { id: tid, instance: instances[0] }; return { id: tid, instance: instances[0] };
} }
// Row 175: a tester has no threads of its own (row 177 adds them), so it lists the // Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
// business human's, the list the human sees. // and the tester's lists only its own (row 175 listed it the business human's).
case 'thread.list': { case 'thread.list': {
if (!s.tester) this.#human(s); if (!s.tester) this.#human(s);
keys(a, ['project']); keys(a, ['project']);
@@ -2327,24 +2342,26 @@ export class Broker {
(a.project === undefined ? '' : ' AND project=?') + (a.project === undefined ? '' : ' AND project=?') +
' ORDER BY seq', ' ORDER BY seq',
s.business, s.business,
s.tester ? b.human : s.human, this.#owner(s),
...(a.project === undefined ? [] : [a.project]), ...(a.project === undefined ? [] : [a.project]),
); );
} }
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). // Opening a thread asks the launcher to start its session (REQ-LAUNCH-1).
case 'thread.open': { case 'thread.open': {
this.#human(s); if (!s.tester) this.#human(s);
keys(a, ['thread'], ['thread']); keys(a, ['thread'], ['thread']);
const t = this.#thread(s, a.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 }; 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': { case 'thread.send': {
this.#human(s); if (!s.tester) this.#human(s);
keys(a, ['thread', 'body', 'class'], ['thread', 'body']); keys(a, ['thread', 'body', 'class'], ['thread', 'body']);
const t = this.#thread(s, a.thread); const t = this.#thread(s, a.thread);
// Row 120's lists hold here as on message.send: the instance must hear from the human. // 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); string(a.body, 32768);
if (a.class !== undefined && !['REQUEST', 'INFO', 'REACTION'].includes(a.class)) fail('invalid-class'); if (a.class !== undefined && !['REQUEST', 'INFO', 'REACTION'].includes(a.class)) fail('invalid-class');
const mid = randomUUID(); const mid = randomUUID();
@@ -2353,8 +2370,8 @@ export class Broker {
mid, mid,
this.#now(), this.#now(),
s.business, s.business,
'human', s.tester ? 'tester' : 'human',
'human-cli', s.tester ? 'tester-cli' : 'human-cli',
t.instance, t.instance,
a.class ?? 'INFO', a.class ?? 'INFO',
null, null,
@@ -2363,11 +2380,11 @@ export class Broker {
a.body, a.body,
); );
this.#route(s.business, mid, t.instance, t.id); 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. // The thread's history, oldest first. Reading writes nothing.
case 'thread.messages': { case 'thread.messages': {
this.#human(s); if (!s.tester) this.#human(s);
keys(a, ['thread', 'after', 'limit'], ['thread']); keys(a, ['thread', 'after', 'limit'], ['thread']);
const t = this.#thread(s, a.thread); const t = this.#thread(s, a.thread);
const limit = a.limit ?? THREAD_PAGE; 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'; import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; 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', () => { test('row 175: TESTER_VERBS and the organization branch tester set are pinned', () => {
assert.deepEqual([...TESTER_VERBS], [ assert.deepEqual([...TESTER_VERBS], [
'message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies', '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',
// 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.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)); 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; const project = r.call(r.human, 'project.create', { name: 'alpha' }).id;
r.call(r.human, 'thread.create', { project, name: 'one' }); 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); 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. // 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.tester, 'thread.list').length, 1); assert.equal(r.call(r.human, 'thread.list').length, 1);
assert.deepEqual(r.call(r.tester, 'thread.list', { project }).map((x) => x.name), ['one']); assert.deepEqual(r.call(r.tester, 'thread.list', { project }), []);
assert.equal(r.call(r.tester, 'names').length, 1); assert.equal(r.call(r.tester, 'names').length, 1);
assert.equal(r.call(r.tester, 'project.list').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' }], ['decision.resolve', { id: 'd-1', choice: 'yes' }],
['launch.revoke', {}], ['launch.revoke', {}],
['launch.restore', {}], ['launch.restore', {}],
['assistant.stop', { thread }], ]) assert.equal(r.code(r.tester, verb, args), 'tester-refused', verb);
['thread.create', { project, name: 'two' }], // 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.open', { thread }],
['thread.send', { thread, body: 'hello' }], ['thread.send', { thread, body: 'hello' }],
['thread.messages', { thread }], ['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. // 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) }]]) 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); 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)) { 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"); 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 (threadRef !== undefined && parsed.flags.has("--tester")) throw usage("--thread takes no --tester");
if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows"); if (receiveOnly && parsed.flags.has("--follow")) throw usage("follow already follows");
const follow = receiveOnly || parsed.flags.has("--follow"); 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`); 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) { export async function threadsCommand(args, io, callFor) {
const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json", "--tester"], values: ["--business", "--project"] }); const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json", "--tester"], values: ["--business", "--project"] });
if (parsed.positional.length !== 0) throw new CliError(`threads takes no arguments\n${THREADS_USAGE}`, 4); 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 // Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the CLI's tester forms. The reads print what the
// the human's forms print, every write form outside W1-W7 refuses --tester before any call, and // human's forms print (since row 177 `threads --tester` lists the tester's own), every write form
// `project init --tester` checks the project area, binds the tester, then commits as `tester`. // 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 { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
@@ -52,7 +53,7 @@ const READS = [
["names"], ["names"],
["limits"], ["limits"],
["plugins"], ["plugins"],
["threads"], // Row 177 (#1675): `threads` left this list; the tester's lists its own threads (below).
["project", "list"], ["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`); 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`); 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. // Row 177 (#1675): the tester's threads are its own, so the human's "one" is not in its list.
const x = inAgent(s.f.env); const testerThreads = async () => {
await main(["threads", "--business", "acme", "--json", "--tester"], x, s.deps); const x = inAgent(s.f.env);
assert.deepEqual(JSON.parse(x.out.text).map((r) => r.name), ["one"]); 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) => { 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 `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 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 `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 auth header and uses `TESTER_CLI` for bus reads, the instance, route and name
writes. Its banner says tester; it offers only those editors. It never enables writes, and chat. Its banner says tester; it offers only those editors and the
settings saves, document saves, assistant stop, messages to roles or chat writes. 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. 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 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 forwarded port 7332. Writes need the listener's own loopback Origin and
same-origin Fetch Metadata, plus the existing bounded strict JSON bodies. 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, 'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503, 'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503, 'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
}; };
function safeMessage(code) { 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"].'; 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 (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
if (write) { if (write) {
if (!send || !named || !preview) return refuse(403, 'read-only'); if (preview?.tester) {
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated'); // Row 177 (#1675): the tester listener's chat calls the bus as the tester, so there is no
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin'); // 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'); if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
} }
try { try {
+2
View File
@@ -89,6 +89,8 @@ try {
tester: true, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads, tester: true, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args), 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), 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; } } catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
} }
+2 -1
View File
@@ -1092,7 +1092,8 @@
sections(path); sections(path);
window.MosaicChat?.leave(path); window.MosaicChat?.leave(path);
if (path === '/chat' || path.startsWith('/chat/')) { 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; if (g !== gen || out()) return;
shown = route; schedule(); 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. // 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 BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
const DECISION_LISTS = ['roles', 'recent']; 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) { async function busRead(res, bus, verb, search) {
let arg; let arg;
if (verb === 'usage') { 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 (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); 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 && 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); const upstream = boardURL(board);
// An object with none of the three is refused too: asking for the preview never serves without it. // 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 }))) { 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' }); 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; 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 }); return json(res, 403, { error: 'read-only', sent: false });
if(path==='/api/settings'&&req.method==='POST'){ if(path==='/api/settings'&&req.method==='POST'){
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false}); 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 }); return busSend(req, res, { send, preview, log });
} }
if (path === '/api/chat' || path.startsWith('/api/chat/')) { 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. // 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' }); 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 assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { browser } from './browser.mjs'; 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 => { 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(); 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"'))); assert.ok(s.lines.some(l => l.includes('routes user="tester"')));
} finally { await b.close(); } } 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.equal(calls.some(c => c.cli === HUMAN_CLI), false);
assert.ok(![401, 403, 500, 502, 503].includes(r.status), path + ': ' + r.body); 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 { writeJson } from '../../business/tests/helpers.mjs';
import { initProject } from '../../project/src/index.mjs'; import { initProject } from '../../project/src/index.mjs';
import { startBroker } from '../../bus/src/runtime.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 { BusReadError } from '../src/bus.mjs';
import { views } from '../../bus/src/views.mjs'; import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { createChat } from '../src/chat.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
export const TOKEN = 'row176-fixture-token-sentinel'; 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 }; 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 { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs'; import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.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 } = {}) { export function http(server, path, { method = 'GET', headers = {}, data } = {}) {
const port = server.address().port; 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 => { 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); 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); } if (!(unsafe instanceof Error)) { await close(unsafe); assert.fail('tester accepted forbidden capability ' + key); }
assert.match(unsafe.message, /tester.*writes/i); assert.match(unsafe.message, /tester.*writes/i);
} }
const s = await testerFixture(t); 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' }); const r = await post(s.web, path, { any: 'value' });
assert.equal(r.status, 403, path); assert.deepEqual(s.calls, []); 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); 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']);
});