fix(console): check the owner-to-PM edge on every intake control, refuse request URLs without SSO, bound the browser waits (#1839)

Review r1 of lifecycle Task 3.

C1: the four owner controls (answer, attach, mode, cancel) inserted and routed a
human-to-PM message without the messaging policy check message.send makes. The
shared writer now reads the current human-to-PM edge inside the control's
transaction, before it inserts or routes anything, and refuses with
message-sender-refused. The refusal rolls back the journal row, the human.input
event, the message and a withdrawn selection decision. The cancel withdrawal
checks the same edge. Regression tests close the edge after the question and
cover all four controls, then reopen it and prove the same call lands once.

I1: serve.mjs read sso.publicOrigin for a request URL when the Console ran
without --sso (or in preview mode), which ended the process on an unauthenticated
GET /api/requests/r1. A request route now answers through requestHTTP with no
transports and no SSO fields: unavailable on a read, read-only on a write, 405
for the wrong method. Listener tests cover GET and POST without SSO and in
preview mode, named and loopback, and the process keeps serving.

M1: the browser test waiter allowed about 3 s and reload() could run out its loop
without failing. Waits are now deadline-bound at 15 s and name the expression,
and reload() waits for the new document's own ready flag and the mounted chat,
and throws if it never gets there. The no-repost and stored-answer assertions of
the lost-answer case are unchanged.

Migration 3p-3q is unchanged.
This commit is contained in:
jason.woltje committed 2026-11-04 11:21:43 -06:00
1 parent eb551b1f89
commit 4e79eaace1
5 files changed
+135 -6

No files matched your search

+9 -2
View File
@@ -719,7 +719,7 @@ export class Broker {
// A message into a session being cleared or retired is cancelled at arrival, and a control that cannot reach the PM
// fails closed here, which rolls back the journal row that called this.
#intakeInput(s, state, body, text) {
if (!s.console || s.human !== state.owner || s.human !== this.#business(s.business).human) fail('human-required');
this.#intakeEdge(s, state);
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
@@ -728,9 +728,16 @@ export class Broker {
if (cancelled) fail(cancelled);
return this.#event(s, 'human.input', { ...body, via: 'console', subject: s.console, message: mid }, mid);
}
// The owner's controls obey the same messaging policy as an ordinary message.send. The edge is read from the business
// document as it is now, inside the control's transaction, so a policy reloaded after the PM asked its question
// refuses with the code message.send gives and rolls back whatever the control had written.
#intakeEdge(s, state) {
if (!s.console || s.human !== state.owner || s.human !== this.#business(s.business).human) fail('human-required');
this.#checkMessaging(s.business, 'human', state.pm);
}
// The cancel of a request whose selection decision is still open closes that decision as withdrawn by the owner.
#withdrawIntake(s, state, decision) {
if (!s.console || s.human !== state.owner) fail('human-required');
this.#intakeEdge(s, state);
const d = this.#decision(s, decision);
if (d.action !== 'project.bootstrap' || d.route_to !== 'human' || d.raised_by_role !== state.pm) fail('request-selection-refused');
if (this.#closed(d.id)) fail('decision-closed');
@@ -250,6 +250,54 @@ test('the journal refuses a forged answer whose human.input is not the owner ans
assert.equal(n(d, "SELECT count(*) n FROM request_events WHERE kind='question.answered'"), 0);
});
// Review r1 C1: an owner control is a human-to-PM message, so it obeys the messaging policy message.send obeys. The
// policy is read when the control runs, not when the PM asked, and a refusal leaves no evidence behind.
const evidence = x => ({
journal: n(x, 'SELECT count(*) n FROM request_events'),
inputs: n(x, "SELECT count(*) n FROM events WHERE kind='human.input'"),
messages: n(x, 'SELECT count(*) n FROM messages'),
consoleMessages: n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"),
decisionEvents: n(x, 'SELECT count(*) n FROM decision_events'),
});
async function controlUnderRefusedEdge(x, verb, args) {
const state = await get(x), policy = structuredClone(x.business);
policy.definitions = { pm: { messaging: { sendTo: ['human', 'pm'], receiveFrom: ['pm'] } } };
x.b.reloadBusiness('demo', policy);
await assert.rejects(async () => x.call('human', 'message.send', { to: 'pm', body: 'direct' }), /message-sender-refused/, 'message.send refuses this edge');
const before = evidence(x);
await assert.rejects(async () => x.call('console', verb, args), /message-sender-refused/, verb);
assert.deepEqual(evidence(x), before, 'no journal row, human.input, message or decision close survives the refusal');
assert.deepEqual(await get(x), state, 'the request is exactly where it was');
// The refusal was the policy and nothing else: with the edge back, the very same call succeeds once.
x.b.reloadBusiness('demo', x.business);
const done = await x.call('console', verb, args);
assert.equal(n(x, "SELECT count(*) n FROM messages WHERE from_run='human-console'"), before.consoleMessages + 1, 'one message, from the retry only');
assert.equal(done.revision, state.revision + 1);
return done;
}
test('an answer is refused when the owner-to-PM edge closed after the question, and leaves no evidence', async t => {
const x = await atDiscovery(t), asked = await ask(x, x.state);
const done = await controlUnderRefusedEdge(x, 'request.answer.console',
{ request: x.source.request, revision: asked.revision, question: asked.question.id, op: 'answer-1', text: 'the web' });
assert.ok(done.question.answer);
});
test('an attach pick is refused when the owner-to-PM edge closed, and leaves no evidence', async t => {
const x = await atPicker(t);
const done = await controlUnderRefusedEdge(x, 'request.attach.console', { request: x.source.request, revision: x.state.revision, project: 'old', op: 'attach-1' });
assert.equal(done.revision, x.state.revision + 1);
});
test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn decision and no cancellation', async t => {
const open = await atSelection(t), decision = open.state.selection.decision;
const done = await controlUnderRefusedEdge(open, 'request.cancel.console', { request: open.source.request, revision: open.state.revision, op: 'cancel-1' });
assert.equal(done.phase, 'cancelled');
assert.equal(n(open, "SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", decision), 1, 'withdrawn once, by the retry');
const x = await atDiscovery(t), asked = await ask(x, x.state);
assert.equal((await controlUnderRefusedEdge(x, 'request.cancel.console', { request: x.source.request, revision: asked.revision, op: 'cancel-2' })).phase, 'cancelled');
});
test('3q to 3r widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3r');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
+5 -2
View File
@@ -783,8 +783,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// granted user stopped at userRoute above. Reads need the read transport; the writes also --send.
if (requestPath) {
if (tester) return requestRefuse(res, 'not-found');
const ready = named && sso !== null;
return requestHTTP(req, res, { path, requestRead: ready ? requestRead : null, requestWrite: ready && send ? requestWrite : null,
// Without SSO there is no owner session to act as, and preview mode has none either. Refuse here, before any
// SSO field is read: requestHTTP answers a missing transport itself and touches no session, live check or origin.
if (!(named && sso !== null))
return requestHTTP(req, res, { path, requestRead: null, requestWrite: null, session: null, live: () => null, origin: null, log });
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
session: ssoSession, live: () => sso.session(req), origin: sso.publicOrigin, log });
}
// Row 194 (#1692, D4.1): the tester also launches and terminates.
+15 -2
View File
@@ -32,6 +32,7 @@ const stubFor = boot => `(() => {
window.__rreads = []; window.__rwrites = []; window.__rAnswers = [];
window.__states = boot.states || {}; window.__projects = boot.projects || []; window.__inbox = boot.inbox || []; window.__pages = boot.pages || [];
window.__readStatus = {};
window.__stubReady = true;
const flags = { requests: !location.search.includes('norequests'), requestWrites: !location.search.includes('readonly') };
window.fetch = async (url, init = {}) => {
const path = String(url), method = init.method || 'GET';
@@ -68,6 +69,10 @@ const stubFor = boot => `(() => {
return data({ rows: [], after: null, hasMore: false });
};
})();`;
// Review r1 M1: one bound for every wait on the page, sized for the full suite where Chromium shares the host with
// the other files. It is far above a quiet run and below the 25 s CDP limit, so a page that never gets there fails
// here, by name, instead of passing a silent loop.
const WAIT_MS = 15000;
async function setup(t, { boot = {}, suffix = '' } = {}) {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
@@ -75,7 +80,7 @@ async function setup(t, { boot = {}, suffix = '' } = {}) {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stubFor({ pages: [{ rows: [ROW, { id: D, body: 'assistant reply', from_role: 'assistant', at: '2026-11-03T00:00:01Z' }], after: D, hasMore: false }], states: { [R]: ASKING }, ...boot }) });
const url = `http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`;
await b.navigate(url);
const w = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
const w = (expression, ms = WAIT_MS) => b.evaluate(`(async()=>{const end=performance.now()+${ms};for(;;){if(${expression})return;if(performance.now()>end)throw new Error('timed out after ${ms} ms waiting for: '+${JSON.stringify(expression)});await new Promise(r=>setTimeout(r,30));}})()`);
const set = (selector, text) => b.evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(text)};el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click(),true`);
const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`);
@@ -83,10 +88,18 @@ async function setup(t, { boot = {}, suffix = '' } = {}) {
const writes = () => b.evaluate('window.__rwrites');
const answer = (...a) => b.evaluate(`window.__rAnswers.push(...${JSON.stringify(a)}),true`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
// The explicit signal is the new document's own: the old window carries __old, and only a fresh document has the stub's
// __stubReady with the load complete. Then the chat has mounted. If that never happens, this throws.
const reload = async () => {
await b.evaluate('window.__old=true,true');
await b.call('Page.reload', { ignoreCache: true });
for (let i = 0; i < 100; i++) { try { if (await b.evaluate('!window.__old && document.readyState==="complete"')) break; } catch { /* the old page is going away */ } await sleep(50); }
const end = Date.now() + WAIT_MS;
for (;;) {
try { if (await b.evaluate('!window.__old && window.__stubReady === true && document.readyState==="complete"')) break; } catch { /* the old page is going away */ }
if (Date.now() > end) throw new Error(`the reloaded page was not ready after ${WAIT_MS} ms`);
await sleep(50);
}
await w('document.querySelector(".chat-conversation")');
};
await w('document.querySelector(".chat-conversation")');
return { b, w, set, click, text, count, writes, answer, reload, sleep };
@@ -230,6 +230,8 @@ function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOS
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
// A listener that stops answering fails the test here, fast and by name, instead of hanging the file.
req.setTimeout(10000, () => req.destroy(new Error(`no response from ${method} ${path}`)));
req.end(body);
});
}
@@ -329,6 +331,62 @@ test('a signed-out caller, a denied origin and a granted user all stop before th
assert.equal(env.reads.length + env.writes.length, 0);
});
// Review r1 I1: with no SSO there is no owner session, so a request URL is refused where the other intake refusals
// are, and never reaches an SSO field. The first form of this bug was an uncaught TypeError that ended the process.
const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => {
const seen = [];
for (const path of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft']) {
const r = await call(port, { path, host, headers: extra });
seen.push([path, r.status, r.body.error]);
}
for (const action of ['answer', 'attach', 'mode', 'cancel']) {
const r = await call(port, { method: 'POST', path: `/api/requests/r1/${action}`, host,
headers: { ...extra, 'content-type': 'application/json' }, body: JSON.stringify(BODIES[action]) });
seen.push([action, r.status, r.body.error]);
}
const wrong = [await call(port, { method: 'POST', path: '/api/requests/r1', host, headers: extra, body: '{}' }),
await call(port, { path: '/api/requests/r1/answer', host, headers: extra })];
seen.push(['wrong-method', ...wrong.map(r => r.status)]);
return seen;
};
const EXPECTED_WITHOUT_OWNER = [['/api/requests/r1', 503, 'unavailable'], ['/api/requests/r1/projects', 503, 'unavailable'], ['/api/requests/r1/draft', 503, 'unavailable'],
['answer', 403, 'read-only'], ['attach', 403, 'read-only'], ['mode', 403, 'read-only'], ['cancel', 403, 'read-only'], ['wrong-method', 405, 405]];
test('without --sso a request URL is refused by the listener and the process keeps serving', async t => {
const crashes = [];
const onCrash = err => crashes.push(err);
process.on('uncaughtException', onCrash); t.after(() => process.off('uncaughtException', onCrash));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(web));
const port = web.address().port, host = `127.0.0.1:${port}`;
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, host), EXPECTED_WITHOUT_OWNER);
// The same-origin form a page would send changes nothing: there is still no owner to act as.
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, host, { origin: `http://${host}`, 'sec-fetch-site': 'same-origin' }), EXPECTED_WITHOUT_OWNER);
const cross = await call(port, { path: '/api/requests/r1', host, headers: { origin: 'https://evil.test' } });
assert.deepEqual([cross.status, cross.body.error], [403, 'cross-origin']);
assert.equal((await call(port, { path: '/api/config', host })).status, 200, 'the process still answers');
assert.equal((await call(port, { path: '/api/config', host })).body.requests ?? false, false);
assert.deepEqual(crashes, []);
});
test('in preview mode, named or loopback, a request URL is refused by the listener and the process keeps serving', async t => {
const crashes = [];
const onCrash = err => crashes.push(err);
process.on('uncaughtException', onCrash); t.after(() => process.off('uncaughtException', onCrash));
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', preview, bus, log: () => {} });
t.after(() => close(web));
const port = web.address().port;
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, preview.host, { 'x-user': 'jason' }), EXPECTED_WITHOUT_OWNER, 'the named host, signed in by the proxy header');
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, preview.host, { 'x-user': 'jason', origin: `https://${preview.host}`, 'sec-fetch-site': 'same-origin' }), EXPECTED_WITHOUT_OWNER);
const unsigned = await call(port, { path: '/api/requests/r1', host: preview.host });
assert.equal(unsigned.status, 401, 'the proxy header is still required first');
assert.deepEqual(await REFUSED_WITHOUT_OWNER(port, `127.0.0.1:${port}`), EXPECTED_WITHOUT_OWNER, 'a loopback Host');
assert.equal((await call(port, { path: '/api/config', host: preview.host, headers: { 'x-user': 'jason' } })).status, 200, 'the process still answers');
assert.deepEqual(crashes, []);
});
test('startServer refuses request transports without sso, without send, or on the tester listener', async () => {
const fn = async () => ({});
await assert.rejects(startServer({ port: 0, requestRead: fn }), /Console requests need --sso/);