diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 4e41804c7..b2e77e840 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -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'); diff --git a/packages/bus/tests/request-console.test.mjs b/packages/bus/tests/request-console.test.mjs index 11a90255d..e01230d5c 100644 --- a/packages/bus/tests/request-console.test.mjs +++ b/packages/bus/tests/request-console.test.mjs @@ -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-')); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 86c65a13d..704b88f43 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -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. diff --git a/packages/webui/tests/request-browser.test.mjs b/packages/webui/tests/request-browser.test.mjs index a1fb2b2b7..c54b68197 100644 --- a/packages/webui/tests/request-browser.test.mjs +++ b/packages/webui/tests/request-browser.test.mjs @@ -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 }; diff --git a/packages/webui/tests/request-http.test.mjs b/packages/webui/tests/request-http.test.mjs index b1462878e..30b14bc5b 100644 --- a/packages/webui/tests/request-http.test.mjs +++ b/packages/webui/tests/request-http.test.mjs @@ -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/);