From 7b51dbbba4778e7cb4d171dbf4c6e10f011b926e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 3 Nov 2026 16:57:16 -0600 Subject: [PATCH] feat(console): answer ordinary decisions (#1839, Vikunja #797) --- BUILD-LOG.md | 4 + docs/SESSIONS.md | 2 + packages/bus/src/broker.mjs | 34 ++--- packages/bus/tests/console-decide.test.mjs | 105 ++++++++++++++- packages/webui/README.md | 30 +++-- packages/webui/src/decision-http.mjs | 30 +++-- packages/webui/src/public/bus.js | 50 ++++--- packages/webui/tests/decide-browser.test.mjs | 130 ++++++++++++++++++- packages/webui/tests/decide-sso.test.mjs | 77 ++++++++++- packages/webui/tests/decision-http.test.mjs | 61 +++++++++ 10 files changed, 464 insertions(+), 59 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index f654d1f91..d1c772602 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5979,3 +5979,7 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr 2026-11-03 | Lead Codex, request-to-product design handoff (Vikunja #797, Gitea #1839) | After: docs/plans/2026-11-03_request-to-product-design.md proposes general Console answers first, durable pre-task intake and three modes, typed project bootstrap, project-scoped approved PRDs and automatic continuation through reviewed delivery; original pomodoro is the acceptance case. Opus independent builder check also failed Claude rate limit; lead self-check only, no independent approval. Await owner decision on concrete authority/schema scope and temporary GPT implementation delegation while Claude is unavailable. Documentation only; product behavior and acceptance not claimed. 2026-11-03 | Lead Codex, #797 / Gitea #1839 implementation start | Jason reports CLI Proxy API leaves Claude accounts unauthenticated and authorizes GPT agents for these tasks. Execute the staged design with GPT coding and independent review; general Console buttons first under existing REQ-WEB-2, detailed lifecycle/PRD plan in parallel. No further Claude calls or production-host changes. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 before | Prepared checkout t3/b15e72fa at 7e849a7b0b635d34950a5c5c2e4b78c4238fe651. Console answers currently require two-option grant requests. Scope: ordinary 2–9 choices through existing preview/confirmation/resolve, preserved grant and identity gates; no tracker/role/PRD/runtime changes. Baseline bus 12/12; initial WebUI 12 pass/5 fail from missing oauth4webapi@3.8.8, restored with locked npm ci --ignore-scripts, then WebUI 17/17 including Chromium. Intended RED: broker 14 pass/3 fail decision-not-eligible; HTTP/browser 12 pass/5 fail ordinary confirmation 503 or absent option panel; real SSO browser 0 pass/1 fail zero buttons instead of three. Owner continuation asks for user in changed prose, technical identifiers retained for #798. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 after | Ordinary user-routed decisions now expose authoritative kind/options through the existing two Console verbs; confirmation uses the selected key/text and ordinary receipts say Recorded. The digest binds the complete decision row and raise authorization/grant context; stale membership/context refuses transactionally. Grant approve/deny/summary and one-use executor path, OIDC/session/origin checks, role attribution and authority remain intact. Cancellation, navigation, reload, closure and changed-context refresh drop UI confirmations without replay. Added real fake-OP signed-in Chromium -> HTTP -> broker third-option coverage: one resolution, verified-subject audit, raiser notice and zero grant activity. RED-to-GREEN also checked project/spec digest binding. Targeted broker 17/17 and WebUI 25/25; final full bus 1407/1407 and WebUI 509/509, zero failures/skips. First full runs: bus 1406 pass/1 fail at unchanged host-run T27 sweep (expected one removed dir, got []), isolated T27 group 4/4 and full rerun green without changing it; WebUI 507 pass/1 fail/1 skip due absent package-local esbuild@0.28.2, restored with its lockfile, isolated build/provenance 7/7 and full rerun green. A newly added SSO test transport initially used nonexistent broker.release; corrected to disconnect after diagnosing its timeout, without weakening assertions. No independent reviewer launched; controller provides review. No tracker, role JSON, PRD, host/service/config/secrets changes, push, merge, PR or deployment. Internal identifier migration remains #798 / Gitea #1840. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 20ee3b9e7..5377e1358 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1980,3 +1980,5 @@ are never rewritten or removed; corrections are new entries. 2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | Vikunja #415 and #416, .53 and .54 Console writes | bd79daffd on t3/console-writes-53-54 (base 1ff45c4ad): factory takes the paired --sso --send with a console.sso check before any unit write; .53 business document on provider proxy, route anthropic, account factory/proxy; library --send not done (needs the #385 refusal reverted, returned to the lead); cli case2-unit 88, stack-upgrade 25, case1-accept 53, business 8, 3 and 3, no skips; 2 mutants killed; no push, queue or PR action. 2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | editor-browser paste test round 2 (Vikunja #784) | e71222d1a on t3/784-r2 (base 0c6aa1a1e): test-only; a MutationObserver on the live document records handler-bearing elements entering the page and h.quiet() asserts none, so the CSP filter cannot hide an attach-then-remove; the reviewer's mutation now fails the paste and live-handler tests; the four earlier mutants still killed; editor-browser 17/17, editor 5/5, editor-provenance 6/6, no skips; no push, queue or PR action. 2026-11-03 | Lead Codex GPT-6.1 Sol, resumed c5c1e8ba | Jason request-to-product intake, general Console decision buttons and autonomous product workflow (#797, Gitea #1839) | Recovered owner decisions and current code; queue next sage returned historical ledger row 7, direct owner request takes priority; drafted scoped design and acceptance, both Claude research/review delegates failed account rate limit; no product/host/config/role change, no provider substitution; owner alignment and coding-model exception outstanding. + +2026-11-03 | GPT-6.1 Sol implementation sub-agent, prepared t3/b15e72fa | #797 / Gitea #1839 stage 1 ordinary Console answers | 2–9 authoritative choices, selected-option confirmation/receipt, full-context digest and lifecycle coverage; targeted broker 17 and WebUI 25, full bus 1407 and WebUI 509 passing with zero skips; initial unchanged T27 flake and locked dependency restoration recorded in BUILD-LOG; local commit only, controller review pending, no trackers or external state changed. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index d79947e9e..f8a16adf9 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1681,17 +1681,21 @@ export class Broker { ['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0) ); } - // Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's - // check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding - // and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is - // the other. The summary carries only the binding's method, agent, host and target projects. The - // digest binds the id, business, options, approval key and binding, so any change reads stale. + // #797 / #1839: authoritative answers for a user-routed decision. Open is the caller's check. + // Ordinary decisions have 2–9 choices. Grant requests keep row 252's narrower eligibility and + // approve/deny summary; a missing binding never falls through to the ordinary path. The digest + // binds the question/action, route, full options and raise-time authorization/grant context. #consoleResolution(d) { - if (d.route_to !== 'human' || d.action !== 'credential.mint') return null; + if (d.route_to !== 'human') return null; const raise = this.#raised(d.business, d.id); - if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null; + if (!raise) return null; const options = JSON.parse(d.options); - if (options.length !== 2 || options[0].key === options[1].key) return null; + if (!Array.isArray(options) || options.length < 2 || options.length > 9 || + options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) || + new Set(options.map(o => o.key)).size !== options.length) return null; + const digest = sha256(JSON.stringify([{ ...d, options }, raise])); + if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest }; + if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null; const approve = options.find((o) => o.key === raise.approvalChoice); if (!approve) return null; const deny = options.find((o) => o.key !== raise.approvalChoice); @@ -1703,10 +1707,11 @@ export class Broker { ...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}), }; return { + kind: 'grant', options, approve: { key: approve.key, text: approve.text }, deny: { key: deny.key, text: deny.text }, grant, - digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])), + digest, }; } // r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only @@ -6076,9 +6081,8 @@ export class Broker { } return this.#decisionView(this.#decision(s, did)); } - // Row 252 (#1753, design section 3): the Console's read of one grant request before its - // confirmation. It returns the two answers, a safe grant summary and the digest the answer must - // carry. Anything not eligible refuses, so the browser never decides eligibility (J1). + // The Console reads authoritative choices and context before confirmation. Ineligible + // decisions refuse here, so the browser never decides eligibility. case 'decision.preview.console': { keys(a, ['id'], ['id']); identifier(a.id); @@ -6100,13 +6104,13 @@ export class Broker { if (a.note !== undefined) string(a.note); const d = this.#decision(s, a.id); if (this.#closed(d.id)) fail('decision-closed'); - // Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice - // in this transaction. It answers one of the two keys, never a key the browser names (J2). + // Recheck eligibility, context digest and membership in the current options in this + // transaction, before any resolution, audit, notification or grant execution. if (viaConsole) { if (!s.console || s.human !== b.human) fail('human-required'); const c = this.#consoleResolution(d); if (!c) fail('decision-not-eligible'); - if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale'); + if (a.digest !== c.digest || !c.options.some(o => o.key === a.choice)) fail('decision-stale'); } else if (verb === 'decision.withdraw') { if (s.human) { this.#human(s); diff --git a/packages/bus/tests/console-decide.test.mjs b/packages/bus/tests/console-decide.test.mjs index 52db7b6e1..e7f1ccda7 100644 --- a/packages/bus/tests/console-decide.test.mjs +++ b/packages/bus/tests/console-decide.test.mjs @@ -152,7 +152,7 @@ test('preview: the two real answers, a grant summary and the digest, for an open // The inbox row says the same, for the human and the reader. The agent-facing views do not. for (const who of ['human', 'reader']) { const row = f.call(who, 'inbox').find((r) => r.id === d.id); - assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest }); + assert.deepEqual(row.console, { kind: 'grant', options, approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest }); } }); @@ -171,6 +171,109 @@ test('J1, J2: a decision that is not an eligible grant request refuses both cons } }); +test('ordinary three-choice decisions expose authoritative options and resolve the third choice once with Console provenance', (t) => { + const f = setup(t); + const choices = [ + { key: 'new/autonomous', text: 'New project — autonomous' }, + { key: 'new/guided', text: 'New project — guided' }, + { key: 'existing:project', text: 'Add to existing project' }, + ]; + const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'How should we handle this request?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false }); + const p = f.call('console', 'decision.preview.console', { id: d.id }); + assert.deepEqual(p.options, choices); + assert.equal(p.kind, 'decision'); + assert.equal(p.grant, undefined); + assert.equal(p.approve, undefined); + for (const who of ['human', 'reader']) assert.deepEqual(f.call(who, 'inbox').find(r => r.id === d.id).console.options, choices); + const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest }); + assert.deepEqual(r, { id: d.id, op: 'resolved', choice: choices[2].key }); + assert.deepEqual(f.closes(d.id).map(c => [c.op, c.by, c.choice, c.via]), [['resolved', 'jason', choices[2].key, 'console']]); + const inputs = f.events().filter(e => e.event === 'human.input' && e.about === d.id); + assert.equal(inputs.length, 1); + assert.deepEqual(inputs[0].subject, SUBJECT); + assert.equal(inputs[0].via, 'console'); + assert.equal(inputs[0].actor_role, null); + assert.equal(inputs[0].actor_run, null); + assert.equal(inputs[0].decisionSeq, f.closes(d.id)[0].seq); + const notices = f.store.all("SELECT * FROM messages WHERE decision=? AND from_role='human' AND class='DECISION'", d.id); + assert.equal(notices.length, 1); + assert.equal(notices[0].to_role, 'chief'); + assert.equal(notices[0].from_run, 'human-console'); + assert.match(notices[0].body, /resolved by jason: existing:project \(Add to existing project\)/); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest }), 'decision-closed'); + assert.equal(f.enrolls.length, 0); + assert.deepEqual(f.grantEvents(d.id), []); + assert.deepEqual(f.make().reconcileGrants(), []); +}); + +test('ordinary PRD approval and nine non-yes/no choices use the same protocol without grant execution', (t) => { + const f = setup(t); + for (const choices of [options, Array.from({ length: 9 }, (_, i) => ({ key: `choice-${i}`, text: `Choice ${i}` }))]) { + const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'Approve this PRD?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false }); + const p = f.call('console', 'decision.preview.console', { id: d.id }); + assert.deepEqual(p.options, choices); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'outside', digest: p.digest }), 'decision-stale'); + assert.equal(f.closes(d.id).length, 0); + assert.deepEqual(f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[0].key, digest: p.digest }), { id: d.id, op: 'resolved', choice: choices[0].key }); + assert.deepEqual(f.grantEvents(d.id), []); + } + assert.equal(f.enrolls.length, 0); +}); + +test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => { + const f = setup(t); + for (const route of ['pm', 'chief']) { + const id = grantDecision(f.store, { action: 'prd.approve', route, close: null }); + refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible'); + refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible'); + f.call(route, 'decision.resolve', { id, choice: 'yes' }); + assert.deepEqual(f.closes(id).map(c => [c.by, c.via]), [[route + '-run', 'broker']]); + assert.equal(f.events().filter(e => e.event === 'human.input' && e.about === id).length, 0); + } +}); + +test('the digest binds ordinary question, action, options, business and raise authorization context', (t) => { + const f = setup(t); + const d = f.call('chief', 'decision.raise', { action: 'prd.approve', target: 'product-1', question: 'Approve draft?', options, recommendation: 'yes', blocking: false }); + const p = f.call('console', 'decision.preview.console', { id: d.id }); + const original = f.store.get('SELECT * FROM decisions WHERE id=?', d.id); + const event = f.store.get("SELECT * FROM events WHERE subject=? AND kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise'", d.id); + // Each immutable variant is inserted into a fresh scratch store, never rewritten in place. + for (const change of [ + { question: 'Approve changed draft?' }, { action: 'deploy' }, + { project: 'product-2' }, { spec_commit: 'f'.repeat(40) }, + { options: JSON.stringify([{ key: 'yes', text: 'Changed' }, options[1]]) }, + { authorization: { target: 'product-2' } }, { authorization: { approvalChoice: 'no' } }, + ]) { + const g = setup(t), { authorization, ...fields } = change; + const row = { ...original, ...fields }; + const columns = Object.keys(row); + g.store.run(`INSERT INTO decisions(${columns.join(',')}) VALUES(${columns.map(() => '?').join(',')})`, ...Object.values(row)); + const e = { ...event, body: JSON.stringify({ ...JSON.parse(event.body), ...authorization }) }; + const ec = Object.keys(e); + g.store.run(`INSERT INTO events(${ec.join(',')}) VALUES(${ec.map(() => '?').join(',')})`, ...Object.values(e)); + const current = g.call('console', 'decision.preview.console', { id: d.id }); + assert.notEqual(current.digest, p.digest); + refusesSync(() => g.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: p.digest }), 'decision-stale'); + assert.deepEqual(g.closes(d.id), []); + } + const other = grantDecision(f.store, { business: 'other', action: 'prd.approve', close: null }); + refusesSync(() => f.call('console', 'decision.preview.console', { id: other }), 'decision-not-found'); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: other, choice: 'yes', digest: p.digest }), 'decision-not-found'); +}); + +test('missing and malformed grant bindings cannot become ordinary Console answers or start an executor', (t) => { + const f = setup(t); + for (const grant of [undefined, {}, { host: 'web1' }, { method: 7 }]) { + const id = grantDecision(f.store, { grant, close: null }); + refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible'); + refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible'); + assert.deepEqual(f.closes(id), []); + assert.deepEqual(f.grantEvents(id), []); + } + assert.equal(f.enrolls.length, 0); +}); + test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => { const f = setup(t); const d = await f.raise(); diff --git a/packages/webui/README.md b/packages/webui/README.md index ab3cb23ae..7d2dc4b8c 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -328,28 +328,38 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`. same functions, so both show the same broker data. - Nothing writes but the "Message a role" box, the Settings update, the Assistant section's three buttons and the spec and docs editor's Save, all under `--send` (above, - "Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below. + "Assistant settings" and "Spec and docs editor"), and the decision answers below. A decision shows `mosaic decide ` for each choice, - with Copy, which only puts the command on the clipboard. It is answered in a - terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The + with Copy, which only puts the command on the clipboard. This optional terminal + fallback remains available (REQ-DEC-3, Q4). Seen is not set from the browser. The command has no `--business`, so `mosaic decide` uses the running bus host's business, as the Console does by default. From a Console started with `--business` for another business the copied command refuses (no bus host, or no such decision) rather than answering elsewhere. -- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed - to the human, raised with a grant and two options) can also be approved or - denied on its decision page, only when the Console runs with both `--sso` and +- #797 / Gitea #1839 stage 1 (REQ-WEB-2), extending row 252 (#1753): an open + decision routed to the user offers one answer button per option, including + ordinary two-to-nine-option decisions with non-yes/no keys. A grant request + (`credential.mint`, raised with a grant and two options) retains Approve and + Deny with its concrete rights summary. Answers are enabled only with `--sso` and `--send` and the page is signed in as a `console.sso` subject. The first click POSTs `/api/decisions//confirmation` and shows what the answer does; only Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to - the session, decision and choice). Each step spawns + the session, business, decision, choice and current context digest). The broker + supplies authoritative `options` and a `kind` of `decision` or `grant`; grants + also retain `approve`, `deny` and `grant`. Ordinary HTTP confirmations carry + `kind: decision`, `action: answer` and the selected key/text, so the receipt + records that choice without claiming a grant started. Each step spawns `packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's child holding the signed-in subject, and the bus records the answer `via console` with a `human.input` naming that subject. Nothing retries; an unknown - outcome says to refresh. Without `--sso` the page has no buttons and says why; + outcome says to refresh. Cancellation, navigation and page reload discard the + page's confirmation; a refresh that finds a closed decision or a changed + context drops it too. The broker rechecks eligibility, digest and choice in + the resolve transaction. Missing or invalid grant bindings cannot fall through + to ordinary answers. Without `--sso` the page has no buttons and says why; under `--sso` without `--send` it says the Console is read-only for decisions. - The tester listener has no decision routes. Every other decision stays - terminal-only. + The tester listener has no decision routes. Decisions routed to agent roles + remain with those roles, using their real role/run attribution. - What no Q1 verb returns is labelled where it would show, for example "Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed. - A task's list row can only say its current state came from a Vikunja read, diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs index 9c2d238b1..49c249a4a 100644 --- a/packages/webui/src/decision-http.mjs +++ b/packages/webui/src/decision-http.mjs @@ -1,6 +1,6 @@ -// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant -// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's -// two answers and digest and returns a one-use confirmation bound to this session, business, +// #797 / #1839 extends row 252's signed-in Console answers to ordinary decisions. Two POSTs. +// /confirmation asks the bus for authoritative answers and digest and returns a one-use +// confirmation bound to this session, business, // decision, choice and digest. /resolve takes only that choice and confirmation, consumes the // confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus // rechecks everything in the resolve transaction. Nothing here retries. @@ -89,10 +89,17 @@ async function readBody(req) { return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' }; } catch { return { code: 'invalid-request' }; } } -const preview = (v, id) => - v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') && - ['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') && - v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string'; +const option = o => o && typeof o.key === 'string' && o.key.length > 0 && typeof o.text === 'string' && o.text.length > 0; +const options = list => Array.isArray(list) && list.length >= 2 && list.length <= 9 && list.every(option) && new Set(list.map(o => o.key)).size === list.length; +function preview(v, id) { + if (!v || v.id !== id || typeof v.business !== 'string' || !DIGEST.test(v.digest ?? '')) return false; + if (v.kind === 'decision') return options(v.options) && !['grant', 'approve', 'deny'].some(k => k in v); + // Retain the existing grant preview fields, including previews from an older pinned broker. + return (v.kind === undefined || v.kind === 'grant') && option(v.approve) && option(v.deny) && + v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string' && + (v.options === undefined || (options(v.options) && v.options.length === 2 && + [v.approve, v.deny].every(o => v.options.some(x => x.key === o.key && x.text === o.text)))); +} // `session` is the live sign-in session object this request passed, and `live()` reads it again // right before each spawn. `call(verb, args, { subject, business })` is the console transport. @@ -120,13 +127,16 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf let v; try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); } if (!preview(v, id)) return refuse('confirmation-unavailable'); - const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null; - if (!action) return refuse('decision-stale'); + const ordinary = v.kind === 'decision'; + const chosen = (ordinary ? v.options : [v.approve, v.deny]).find(o => o.key === value.choice); + if (!chosen) return refuse('decision-stale'); + const action = ordinary ? 'answer' : value.choice === v.approve.key ? 'approve' : 'deny'; if (live() !== session) return refuse('sign-in-required'); const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest }); if (!confirmation) return refuse('confirmation-unavailable'); line(`action=${action}`); - return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation }); + return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: chosen.text, + ...(ordinary ? { kind: 'decision' } : { grant: v.grant }), confirmation }); } const keys = Object.keys(value); if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request'); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 70b275b62..460e64e5d 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -258,7 +258,7 @@ // Inbox function decisionCard(d) { return `
  • -

    ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}

    +

    ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}

    ${approves(d.authorization)}

    Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}

  • `; } @@ -316,7 +316,7 @@ el.outerHTML = composerHtml(); restore(k); } - // Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when + // #797 / #1839 extends row 252's answer panel to every eligible option, drawn only when // the server says this Console answers decisions (`decide` in /api/config: signed in through SSO // and started with --send) and the bus marked the inbox row eligible (its `console` field). The // first click asks the Console for a one-use confirmation that names what the answer does. Only @@ -324,7 +324,11 @@ // unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s // refresh redraws it. The server and the bus check everything again; this is presentation only. let canDecide = false, ssoOn = false, answer = null; + document.addEventListener('mosaic-signed-out', () => { answer = null; }); const ACTIONS = { approve: 'Approve', deny: 'Deny' }; + const answerChoices = c => c.kind === 'decision' + ? c.options.map((o, i) => ({ ...o, step: `option-${i}`, label: o.text })) + : Object.entries(ACTIONS).map(([step, label]) => ({ ...c[step], step, label })); const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · '); function decideHtml() { const a = answer; @@ -333,17 +337,18 @@ let body; if (a.result) { const r = a.result; - body = `

    ${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.

    `; + body = `

    ${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.

    `; } else if (a.confirm) { const c = a.confirm, label = `Confirm ${c.action}`; body = `

    ${label}

    -
    Id
    ${txt(c.id)}
    Question
    ${txt(c.question)}
    Answer
    ${txt(c.choice)} ${txt(c.text)}
    Grant
    ${grantText(c.grant)}
    -

    ${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.

    +
    Id
    ${txt(c.id)}
    Question
    ${txt(c.question)}
    Answer
    ${txt(c.choice)} ${txt(c.text)}
    ${c.kind === 'decision' ? '' : `
    Grant
    ${grantText(c.grant)}
    `}
    +

    ${c.kind === 'decision' ? 'Confirm to record this choice.' : c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.

    `; } else { - body = `
      ${Object.entries(ACTIONS).map(([k, l]) => `
    • ${l} chooses ${txt(a.console[k].key)} ${txt(a.console[k].text)}
    • `).join('')}
    -

    Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.

    -
    ${Object.entries(ACTIONS).map(([k, l]) => ``).join('')}
    `; + const choices = answerChoices(a.console); + body = `
      ${choices.map(o => `
    • ${a.console.kind === 'decision' ? '' : `${txt(o.label)} chooses `}${txt(o.key)} ${txt(o.text)}
    • `).join('')}
    +

    ${a.console.kind === 'decision' ? '' : `Grant: ${grantText(a.console.grant)}. `}Each button opens a confirmation first. Nothing is answered until you confirm.

    +
    ${choices.map(o => ``).join('')}
    `; } const e = a.error; const said = e ? `

    Not answered here: ${txt(e.message)} (${txt(e.code)}).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}

    ` : ''; @@ -375,18 +380,20 @@ async function decide(step) { const a = answer; if (!a || a.pending || a.result) return; - if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; } + if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide]'); announce('Cancelled. Nothing was answered.'); return; } if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; } - if (step === 'approve' || step === 'deny') { - const choice = a.console[step].key; + const selected = answerChoices(a.console).find(o => o.step === step); + if (selected) { + const choice = selected.key, ordinary = a.console.kind === 'decision'; a.pending = step; a.error = null; a.confirm = null; paintDecide(); const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice }); a.pending = null; // The page moved to another decision meanwhile: its confirmation is dropped, never shown. if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; } if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; } - if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') { - a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`); + if (res?.ok && json?.id === a.id && json.action === (ordinary ? 'answer' : step) && json.choice === choice && typeof json.text === 'string' && typeof json.confirmation === 'string' && + (ordinary ? json.kind === 'decision' && !json.grant : json.grant && typeof json.grant.method === 'string')) { + a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${json.action}: ${json.text}`); } else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } return; } @@ -399,8 +406,8 @@ if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; } if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; } if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) { - a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null }; - paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false); + a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null }; + paintDecide('#decide-status'); announce(c.kind === 'decision' ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false); } else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } } async function inboxView(get) { @@ -408,7 +415,7 @@ const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at))); countInbox(rows); const body = rows.length ? `
      ${rows.map(decisionCard).join('')}
    ` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.'); - const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with mosaic decide.' + const answered = canDecide ? 'Open an eligible decision to answer any of its options here, after a confirmation. Grant requests keep their Approve and Deny confirmation. You can also use mosaic decide in a terminal.' : 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'; return `${head('Inbox', `${readNote()} ${answered}`)} ${await composer(get)} @@ -439,7 +446,10 @@ // or this page just answered it. const eligible = canDecide && answerable && open?.console ? open.console : null; if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null; - else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible; + else if (eligible && !answer.pending) { + if (answer.console.digest !== eligible.digest) answer.confirm = null; + answer.console = eligible; + } else if (!eligible && !answer.pending && !answer.result) answer = null; const options = `
      ${d.options.map(o => { const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key; @@ -454,8 +464,8 @@ // draws a disabled one (design section 5). const why = !answerable || isTester || canDecide ? '' : ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.' - : ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).'; - const help = eligible ? '

      Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.

      ' + : ' Answering a decision from the Console needs sign-in (--sso) and writes enabled (--send).'; + const help = eligible ? '

      Choose an answer in the panel above. You can also run one of these in a terminal. Copy only puts the command on your clipboard.

      ' : answerable ? `

      To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}

      ` : !c ? `

      This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.

      ` : ''; return `${head(`Decision ${txt(short(d.id))}`, status)} @@ -1231,6 +1241,8 @@ clearTimeout(timer); if (out()) return; const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === ''; + // Leaving a decision discards its confirmation immediately, including a preview in flight. + if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null; const g = ++gen; document.body.classList.toggle('on-bus', !board); $('bus-view').hidden = board; diff --git a/packages/webui/tests/decide-browser.test.mjs b/packages/webui/tests/decide-browser.test.mjs index fd1c65123..81a0e30b8 100644 --- a/packages/webui/tests/decide-browser.test.mjs +++ b/packages/webui/tests/decide-browser.test.mjs @@ -36,6 +36,11 @@ const STUB = `(() => { const c = await (await real(url, init)).json(); return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) }); } + if (u === '/api/bus/inbox' && window.__changedDecision) { + const body = await (await real(url, init)).json(); + body.rows = body.rows.map(row => row.id === window.__changedDecision ? { ...row, console: { ...row.console, digest: 'e'.repeat(64) } } : row); + return answer(200, body); + } if (!u.startsWith('/api/decisions/')) return real(url, init); window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect }); const a = window.__answers.shift(); @@ -64,13 +69,20 @@ function hostileGrant(store, grant) { return id; } -async function setup() { +async function setup({ ordinary = false } = {}) { const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-')); const store = new Store(root), b0 = new Broker({ store, businesses }); const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] }; const open = hostileGrant(store, grant); const deploy = grantDecision(store, { action: 'deploy', grant, close: null }); const bare = grantDecision(store, { close: null }); + const chief = b0.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' }); + b0.request(chief, { verb: 'role.claim' }); + const normal = ordinary ? b0.request(chief, { verb: 'decision.raise', args: { + action: 'prd.approve', question: `Choose a project ${HOSTILE}`, blocking: false, + options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: `Add to existing project ${HOSTILE}` }], + recommendation: 'new/autonomous', approvalChoice: 'new/autonomous', + } }).id : null; const reader = b0.bindReader({ business: 'demo' }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const f = await fixture(), b = await browser(); @@ -93,7 +105,7 @@ async function setup() { }; const settle = () => new Promise(r => setTimeout(r, 600)); const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); }; - return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done }; + return { b, store, base, open, deploy, bare, normal, wait, text, has, posts, queue, click, page, settle, done }; } const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } }); @@ -135,7 +147,7 @@ test('J16: no controls without sso and decide, read-only reasons, terminal lines await s.b.navigate(`${s.base}?sso&decide#/inbox`); await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3'); await s.settle(); - assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./); + assert.match(await s.text('#bus-view'), /Open an eligible decision to answer any of its options here, after a confirmation\./); assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1); assert.deepEqual(await s.posts(), []); } finally { await s.done(); } @@ -248,3 +260,115 @@ test('a sign-in redirect at either step signs the page out and answers nothing', assert.equal((await s.posts()).length, 2); } finally { await s.done(); } }); + +test('ordinary choices render every key/text, cancel and navigation discard confirmations, and the third option records an accurate receipt', { timeout: 120000 }, async () => { + const s = await setup({ ordinary: true }); + const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: `Choose a project ${HOSTILE}`, choice: 'existing:project', text: `Add to existing project ${HOSTILE}`, confirmation: 'd'.repeat(64) } }; + try { + await s.page('sso&decide', s.normal); + assert.equal(await s.has('#decide-section'), true); + assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => e.textContent)'), ['New project — autonomous', 'New project — guided', `Add to existing project ${HOSTILE}`]); + assert.equal(await s.b.evaluate('window.injected ?? null'), null); + assert.doesNotMatch(await s.text('#decide-section'), /Grant:|Approve|Deny/); + await s.queue({ ...confirmation, hold: true }); + await s.click('option-2'); + await s.click('option-2'); + await s.b.evaluate('window.__release(), true'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + assert.equal((await s.posts()).length, 1); + assert.match(await s.text('.s1-confirm'), /Confirm answer/); + assert.doesNotMatch(await s.text('.s1-confirm'), /Grant|Approving|Denying/); + await s.settle(); + assert.equal(await s.has('[data-decide=confirm]'), true); + await s.click('cancel'); + assert.equal(await s.has('[data-decide=confirm]'), false); + assert.equal((await s.posts()).length, 1); + + await s.queue(confirmation); + await s.click('option-2'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + await s.b.navigate(`${s.base}?sso&decide#/inbox`); + await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")'); + await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`); + await s.wait('document.querySelector("#decide-section")'); + assert.equal(await s.has('[data-decide=confirm]'), false, 'leaving the decision must discard its confirmation'); + assert.equal((await s.posts()).length, 2); + + // Navigation while the preview is in flight must also discard the late answer. + await s.queue({ ...confirmation, hold: true }); + await s.click('option-2'); + await s.b.navigate(`${s.base}?sso&decide#/inbox`); + await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")'); + await s.b.evaluate('window.__release(), true'); + await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`); + await s.wait('document.querySelector("#decide-section")'); + assert.equal(await s.has('[data-decide=confirm]'), false); + await s.queue(confirmation); + await s.click('option-2'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + await s.queue({ status: 200, hold: true, body: { id: s.normal, op: 'resolved', choice: 'existing:project' } }); + await s.click('confirm'); + await s.click('confirm'); + await s.b.evaluate('window.__release(), true'); + await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); + assert.match(await s.text('#decide-status'), /Recorded: you chose “existing:project”, Add to existing project/); + assert.doesNotMatch(await s.text('#decide-status'), /grant|denial|Approved|Denied|Nothing starts/); + await s.settle(); + const posts = await s.posts(); + assert.equal(posts.filter(p => p.url.endsWith('/resolve')).length, 1); + assert.equal(await s.has('[data-decide]'), false); + } finally { await s.done(); } +}); + +test('ordinary confirmation is dropped on closure, reload, session expiry and uncertain outcome without automatic replay', { timeout: 120000 }, async () => { + const s = await setup({ ordinary: true }); + const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/guided', text: 'New project — guided', confirmation: 'd'.repeat(64) } }; + try { + await s.page('sso&decide', s.normal); + assert.equal(await s.has('#decide-section'), true); + await s.queue(confirmation); + await s.click('option-1'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + await s.page('sso&decide', s.normal); + assert.equal(await s.has('[data-decide=confirm]'), false); + assert.equal((await s.posts()).length, 0); + await s.queue(confirmation); + await s.click('option-1'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + await s.queue({ reject: true }); + await s.click('confirm'); + await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"'); + await s.settle(); + assert.equal((await s.posts()).length, 2); + assert.equal(await s.has('[data-decide=confirm]'), false); + await s.queue(confirmation); + await s.click('option-1'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + await s.queue({ status: 401, body: { error: 'sign-in-required', message: 'Sign in again before answering.' } }); + await s.click('confirm'); + await s.wait('document.querySelector("#decide-status code")?.textContent === "sign-in-required" || document.body.classList.contains("signed-out")'); + assert.equal(await s.has('[data-decide=confirm]'), false); + assert.equal((await s.posts()).length, 4); + await s.page('sso&decide', s.normal); + await s.queue(confirmation); + await s.click('option-1'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.normal, '2026-11-03T00:00:00.000Z', 'resolved', 'jason', 'new/autonomous', 'cli'); + await s.wait('!document.querySelector("#decide-section")'); + assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0); + } finally { await s.done(); } +}); + +test('a changed authoritative context drops an open ordinary confirmation before another answer can be submitted', { timeout: 120000 }, async () => { + const s = await setup({ ordinary: true }); + try { + await s.page('sso&decide', s.normal); + await s.queue({ status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/autonomous', text: 'New project — autonomous', confirmation: 'c'.repeat(64) } }); + await s.click('option-0'); + await s.wait('document.querySelector("[data-decide=confirm]")'); + await s.b.evaluate(`window.__changedDecision = ${JSON.stringify(s.normal)}`); + await s.wait('!document.querySelector("[data-decide=confirm]") && document.querySelector("[data-decide=option-0]")'); + assert.equal((await s.posts()).length, 1); + assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0); + } finally { await s.done(); } +}); diff --git a/packages/webui/tests/decide-sso.test.mjs b/packages/webui/tests/decide-sso.test.mjs index 36387f0a7..f4c94b167 100644 --- a/packages/webui/tests/decide-sso.test.mjs +++ b/packages/webui/tests/decide-sso.test.mjs @@ -7,7 +7,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Broker } from '../../bus/src/broker.mjs'; +import { Store } from '../../bus/src/store.mjs'; +import { views } from '../../bus/src/views.mjs'; +import { browser } from './browser.mjs'; import { startServer } from '../src/serve.mjs'; import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; import { DECISION_REFUSALS } from '../src/decision-http.mjs'; @@ -96,6 +102,75 @@ test('a signed-in owner confirms and answers, with the session\'s provider, issu assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false); }); +test('a signed-in browser selects the third ordinary option through HTTP and records exactly one audited resolution and raiser notification, with zero grants', { timeout: 120000 }, async t => { + const root = mkdtempSync(join(tmpdir(), 'console-797-sso-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const business = { id: 'demo', human: 'jason', arbiters: { delivery: 'pm', technical: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } }; + let grants = 0; + const broker = new Broker({ store, businesses: { demo: business }, hostGrant: { enroll: async () => { grants++; } } }); + const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' }); + broker.request(chief, { verb: 'role.claim' }); + const d = broker.request(chief, { verb: 'decision.raise', args: { action: 'prd.approve', question: 'Choose a product project', blocking: false, + options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }], recommendation: 'new/autonomous', approvalChoice: 'new/autonomous' } }); + const reader = broker.bindReader({ business: 'demo' }); + const bus = views({ call: async (verb, args = {}) => broker.request(reader, { verb, args }) }); + const calls = []; + const env = await setup(t, { bus, decisionWrite: async (verb, args, ctx) => { + calls.push({ verb, args, ctx }); + const cap = broker.bindConsole({ business: ctx.business ?? 'demo', human: 'jason', subject: ctx.subject }); + try { return broker.request(cap, { verb, args }); } finally { broker.disconnect(cap); } + } }); + const session = await env.signIn(); + const b = await browser(); + t.after(() => b.close()); + // Existing CDP harness pattern: fulfill a synthetic HTTPS origin from this scratch listener, + // carrying the real fake-OP session just established above; no live Console or proxy is used. + b.on('Fetch.requestPaused', ({ requestId, request: incoming }) => { + const url = new URL(incoming.url); + const req = request({ host: '127.0.0.1', port: env.port, path: url.pathname + url.search, method: incoming.method, + headers: { ...incoming.headers, host: HOST, cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => { + const parts = []; + res.on('data', part => parts.push(part)); + res.on('end', () => { + const responseHeaders = Object.entries(res.headers).filter(([name]) => !['connection', 'keep-alive', 'transfer-encoding'].includes(name)) + .flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map(value => ({ name, value: String(value) }))); + b.call('Fetch.fulfillRequest', { requestId, responseCode: res.statusCode, responseHeaders, body: Buffer.concat(parts).toString('base64') }).catch(() => {}); + }); + }); + req.on('error', () => b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {})); + req.end(incoming.postData); + }); + await b.call('Fetch.enable', { patterns: [{ urlPattern: ORIGIN + '/*', requestStage: 'Request' }] }); + await b.navigate(`${ORIGIN}/#/inbox/${d.id}`); + const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,50))}throw new Error('timed out')})()`); + await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && document.querySelector("#bus-view .s1-opts")'); + assert.equal(await b.evaluate('document.querySelectorAll("#decide-section [data-decide]").length'), 3); + await b.evaluate('document.querySelector("[data-decide=option-2]").click()'); + await wait('document.querySelector("[data-decide=confirm]")'); + assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE decision=? AND op='resolved'", d.id).n, 0); + await b.evaluate('document.querySelector("[data-decide=confirm]").click(); document.querySelector("[data-decide=confirm]")?.click()'); + await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")'); + assert.match(await b.evaluate('document.querySelector("#decide-status").textContent'), /existing:project.*Add to existing project/); + assert.doesNotMatch(await b.evaluate('document.querySelector("#decide-status").textContent'), /grant|denial|Approved|Denied/); + const closes = store.all('SELECT * FROM decision_events WHERE decision=?', d.id); + assert.equal(closes.length, 1); + assert.deepEqual([closes[0].op, closes[0].by, closes[0].choice, closes[0].via], ['resolved', 'jason', 'existing:project', 'console']); + const inputs = store.all("SELECT * FROM events WHERE kind='human.input' AND subject=?", d.id); + assert.equal(inputs.length, 1); + const input = JSON.parse(inputs[0].body); + assert.deepEqual(input.subject, { provider: 'a', iss: env.iss.issuer, sub: 'user-a' }); + assert.equal(input.via, 'console'); + assert.equal(input.decisionSeq, closes[0].seq); + const notices = store.all("SELECT * FROM messages WHERE decision=? AND class='DECISION' AND from_role='human'", d.id); + assert.equal(notices.length, 1); + assert.equal(notices[0].to_role, 'chief'); + assert.equal(notices[0].from_run, 'human-console'); + assert.equal(calls.filter(c => c.verb === 'decision.resolve.console').length, 1); + assert.equal(grants, 0); + assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 0); +}); + test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => { const env = await setup(t); refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required'); diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs index 245a1aa2e..fbbf3bdd8 100644 --- a/packages/webui/tests/decision-http.test.mjs +++ b/packages/webui/tests/decision-http.test.mjs @@ -86,6 +86,67 @@ test('deny resolves without a grant field', async () => { assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' }); }); +const ORDINARY = { + id: 'd1', business: 'demo', question: 'How should we handle this request?', digest: DIGEST, kind: 'decision', + options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }], +}; +test('ordinary confirmation carries the selected third key/text and decision context, then resolves once in its business', async () => { + const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) }); + const c = await s.confirm('existing:project'); + assert.equal(c.status, 200, c.text); + assert.deepEqual({ ...c.json(), confirmation: 'x' }, { id: 'd1', kind: 'decision', question: ORDINARY.question, action: 'answer', choice: 'existing:project', text: 'Add to existing project', confirmation: 'x' }); + const body = { choice: 'existing:project', confirmation: c.json().confirmation }; + assert.deepEqual((await s.resolve(body)).json(), { id: 'd1', op: 'resolved', choice: 'existing:project' }); + assert.equal(s.calls[1].ctx.business, 'demo'); + assert.equal(s.calls[1].args.digest, DIGEST); + refused(await s.resolve(body), 'confirmation-required'); + assert.equal(s.calls.length, 2); +}); + +test('ordinary options are validated authoritatively at confirmation, including two and nine choices', async () => { + for (const options of [ORDINARY.options.slice(0, 2), Array.from({ length: 9 }, (_, i) => ({ key: `key:${i}`, text: `Answer ${i}` }))]) { + const s = setup({ answer: () => ({ ...ORDINARY, options }) }); + assert.equal((await s.confirm(options.at(-1).key)).status, 200); + refused(await s.confirm('not-listed'), 'decision-stale'); + } + for (const options of [[], ORDINARY.options.slice(0, 1), Array(10).fill(ORDINARY.options[0]), [ORDINARY.options[0], ORDINARY.options[0]], [{ key: 1, text: 'x' }, ORDINARY.options[1]], [{ key: 'x' }, ORDINARY.options[1]]]) { + const s = setup({ answer: () => ({ ...ORDINARY, options }) }); + refused(await s.confirm('new/autonomous'), 'confirmation-unavailable'); + assert.equal(s.confirmations.size, 0); + } +}); + +test('ordinary stale/closed and uncertain outcomes spend their confirmation and never replay', async () => { + for (const code of ['decision-stale', 'decision-closed', 'outcome-unknown']) { + const s = setup({ answer: () => ORDINARY, resolve: () => { throw new BusReadError(code); } }); + const c = await s.confirm('new/guided'); + assert.equal(c.status, 200, c.text); + const body = { choice: 'new/guided', confirmation: c.json().confirmation }; + refused(await s.resolve(body), code); + refused(await s.resolve(body), 'confirmation-required'); + assert.equal(s.calls.length, 2); + } +}); + +test('ordinary confirmations stay bound to the issuing session, choice, decision and business', async () => { + const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) }); + let c = (await s.confirm('new/guided')).json(); + refused(await s.resolve({ choice: 'existing:project', confirmation: c.confirmation }), 'confirmation-required'); + c = (await s.confirm('new/guided')).json(); + refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }, 'd2'), 'confirmation-required'); + c = (await s.confirm('new/guided')).json(); + s.current = null; + refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'sign-in-required'); + s.current = s.session; + refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required'); + const other = setup({ answer: () => ({ ...ORDINARY, business: 'other' }) }); + other.confirmations = s.confirmations; + c = (await s.confirm('new/guided')).json(); + refused(await other.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required'); + assert.equal(s.calls.filter(x => x.verb === 'decision.resolve.console').length, 0); + assert.equal(other.calls.length, 0); +}); + test('the gates before the body: method, read-only, sign-in and same origin', async () => { const s = setup(); refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');