diff --git a/agents/scrooge/work/queue-252/BUILD.md b/agents/scrooge/work/queue-252/BUILD.md new file mode 100644 index 000000000..fca5249f3 --- /dev/null +++ b/agents/scrooge/work/queue-252/BUILD.md @@ -0,0 +1,122 @@ +# Row 252 build notes: Console inbox Approve and Deny for grant requests + +Seat: Scrooge. Reviewer: Webby. Issue #1753, Vikunja #435 (J2). +Brief: `docs/plans/2026-10-21_console-inbox-approve.md` (blob b41676d06). +Design: Gander's DESIGN.md at d218e14bd, approved by Webby at 5d8f0f4b5 (webby-0082) with manifest checks N1-N5. +Base: aca16404c on branch `scrooge/row252`. Rebase onto the row 253 tip under a new branch name once 253 lands. + +## Status + +- Code, tests and docs are written. No test has been run yet, because Gander holds the gate slot for row 253 run 5 and Sage ruled no node until a CLEAR. +- Everything in the Results section is empty until that run. Nothing below claims a pass. + +## Q188 + +No agent creates a grant. Approve and Deny are human-only Console actions. + +- Only a console cap reaches `decision.preview.console` and `decision.resolve.console`. A console cap reaches nothing else (console-decide `J1, Q188`). +- The socket refuses a console proof for any other verb before the proof check (`server.mjs`). +- A console cap binds only through `authenticateConsole`, which needs the console proof (section 2): the same-tree `console-cli.mjs` wrapper, its own nonce, a direct same-tree `serve` parent, and a configured owner subject from the spawn environment. +- `recordEvent` keeps `human.input` reserved, so no agent writes the record that lets a console close start the executor (console-decide `recordEvent keeps human.input reserved`). +- A `via console` close without its matching `human.input` approves nothing (console-decide `J14`). + +## Manifest checks N1-N5 + +- **N1.** Every `console.sso` subject is an owner of the business. Name is display only. + - `cli/src/config.mjs` carries `{provider, iss, sub}` per subject into the boot as `consoleOwners`. No sso means no key. + - `bus/src/runtime.mjs` validates the list (array, at most 512, exactly three non-empty strings each) and passes it to `verifyPrincipal`. + - Tests: cli config.test `Row 252 (Webby N1)` and principal.test. +- **N2.** `human.input` is written in the same transaction, before `#approvedGrant`. The executor starts once, and a deny starts none. + - Tests: console-decide `N2, J13` checks the order by seq and that the grant runs once. `J15` checks that a deny starts nothing. +- **N3.** The close notice comes from `from_run 'human-console'`, and `public/bus.js` shows "via console". `landing` and `#protectedEnd` stay CLI-only. + - Tests: console-decide `N2, J13` checks the notice. decide-browser `J17` checks the close-elsewhere line. +- **N4.** `decision.resolve` is not a Console verb. + - `CONSOLE_VERBS` is exactly the two console verbs. The webui serve `BUS_VERBS` read list has no decision verb, and webui `cli.mjs` has no `decision.` string. `consoleCall` refuses anything outside `CONSOLE_VERBS`. + - Tests: console-decide `N4`, and decide-sso `N4`, which hits `/api/bus/decision.*` for 404 and covers the cli.mjs scan and the consoleCall refusals. +- **N5.** `readProcess` keeps the five `MOSAIC_BUS_CONSOLE_*` keys in the env allowlist (`bus/src/human.mjs`). The human proof and its spawn are unchanged. + +## Design choices and tradeoffs + +- **Webui imports from the broker.** `webui/src/bus.mjs` imports `CONSOLE_VERBS` from `bus/src/broker.mjs`, which loads the broker module into the Console at import. + - I chose one source for the verb list over a second copy that could drift. + - If Webby prefers the copy, the alternative is a literal list plus a test pinning it equal to `CONSOLE_VERBS`. +- **The Console transport shares humanCall's spawn.** `consoleCall` and `humanCall` share it as `transport()`, so `webui/src/bus.mjs` changed. The busCaller `business` default keeps `||` (the old falsy semantics), not `??`. +- **Confirmations are held in memory in the serve process.** Their lifetime is 2 minutes, they are single use, and they bind the session, decision, choice and digest. The cap is a limit that never drops a live confirmation. + - A restart loses all held confirmations. The user clicks again and gets a new preview. Nothing is written until the resolve. +- **No retry anywhere.** A resolve whose bus reply is lost answers `outcome-unknown`, the confirmation is spent, and the page says "Nothing was sent again." + +## Old tests changed (for Mos, per mos-1272) + +- **`webui/tests/shell.test.mjs`**, write-boundary test. Gating `done` waits on Mos's ruling. + - The `writes` list gains the new `fetch(url, ...)` in `decidePost`. It is first in file order (bus.js:362). + - The `method:` count goes from 7 to 8. + - A new assertion pins `decidePost`'s callers to `url` plus the two literal `/api/decisions/${enc(a.id)}/...` routes. + - No existing entry was loosened. +- **`bus/tests/channel-proof.test.mjs`**, sha256 re-pins with a header comment: + - `bus/src/human.mjs` (N5 env keys) to bb13c146…c885. + - `webui/src/bus.mjs` (consoleCall and `transport()`) to 701f009e…5272. + - Any later edit to either file needs a re-pin. +- `bus/tests/principal.test.mjs`, `webui/tests/tester-listener.test.mjs` and `cli/tests/config.test.mjs` have new tests appended only. + +## Mutant plan (design section 6, J1-J17) + +Each mutant is one edit to the named source. Run the killing test, then restore and compare the sha256 against the pre-mutant hash with `git status` clean apart from the build. Kill rules are written before the runs. + +| J | Mutant | Source | Expected kill | +|---|---|---|---| +| J1 | eligibility from the request, `#consoleResolution` returns non-null for any decision | broker.mjs | console-decide `J1, J2` (ineligible) | +| J2 | hardcode `approve = yes`, `deny = no` | broker.mjs | console-decide `J2` (go/stop, deny listed first) | +| J3 | owner from name or header, skip the owner match in verifyPrincipal | principal.mjs | principal.test `console requires…` | +| J4 | skip the serve-parent walk | principal.mjs | principal.test `console requires…` | +| J5 | accept the human nonce namespace | principal.mjs | principal.test `console requires…` | +| J6 | reuse the session from the confirmation, skip the live session check at resolve | decision-http.mjs | decision-http `session that ends`, decide-sso `listener gates` (new session) | +| J7 | keep the console cap bound after the call (skip `release` for kind console) | server.mjs | **open**: no new test pins release for a console proof. Before the run, check whether an existing server test kills it. If not, add one and name it in the report. | +| J8 | skip the same-origin check | decision-http.mjs | decision-http `gates`, decide-sso `listener gates` | +| J9 | resolve without a confirmation token | decision-http.mjs | decision-http `a confirmation then a resolve`, `bad body` | +| J10 | drop the choice, decision or session binding | decision-http.mjs | decision-http `binds the session, decision and choice` | +| J11 | skip the digest, closed or business check | broker.mjs | console-decide `J11`, `N2, J13` (second answer closed) | +| J12 | do not spend the confirmation | decision-http.mjs | decision-http `a second resolve refuses`, `confirmations… one use` | +| J13 | write via cli, or omit the subject in human.input | broker.mjs | console-decide `N2, J13` | +| J14 | `#approvedGrant` accepts via console without the human.input | broker.mjs | console-decide `J14` | +| J15 | start the executor on any resolved choice | broker.mjs | console-decide `J15` and `J2` (stop starts nothing) | +| J16 | `decide` true without sso or send | serve.mjs, public/bus.js | decide-sso `J16`, decide-browser `J16` | +| J17 | retry on outcome-unknown or auto-submit after preview | public/bus.js | decide-browser `J17` (post count stays at 7) | + +## Manifest + +Source: +- packages/bus/src/broker.mjs +- packages/bus/src/client.mjs +- packages/bus/src/console-cli.mjs (new) +- packages/bus/src/human.mjs +- packages/bus/src/principal.mjs +- packages/bus/src/runtime.mjs +- packages/bus/src/server.mjs +- packages/cli/src/config.mjs +- packages/webui/src/bus.mjs +- packages/webui/src/cli.mjs +- packages/webui/src/decision-http.mjs (new) +- packages/webui/src/public/bus.js +- packages/webui/src/serve.mjs + +Tests: +- packages/bus/tests/channel-proof.test.mjs (re-pins) +- packages/bus/tests/console-decide.test.mjs (new) +- packages/bus/tests/principal.test.mjs +- packages/cli/tests/config.test.mjs +- packages/webui/tests/decide-browser.test.mjs (new) +- packages/webui/tests/decide-sso.test.mjs (new) +- packages/webui/tests/decision-http.test.mjs (new) +- packages/webui/tests/shell.test.mjs (old test changed, see above) +- packages/webui/tests/tester-listener.test.mjs + +Docs: +- packages/webui/README.md +- agents/scrooge/work/queue-252/BUILD.md (this file) + +## Results + +Not run. At the first CLEAR: +1. Run each touched test file whole, with TMPDIR under /mnt/storage/scratch/tmp. +2. Run the bus, webui and cli suites. +3. Run the mutants above, then the drift check against refactor. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 5107822eb..d5e7f18fd 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -137,6 +137,9 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm // collaborator grant, so a viewer gets access-refused on each. export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names', 'thread.share', 'thread.unshare', 'thread.shares']); +// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb +// refuses it human-required before its own checks, and these refuse every other cap. +export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']); // Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They // read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a // collaborator grant on the project and instance, so a viewer gets access-refused on each. @@ -1456,6 +1459,26 @@ export class Broker { if (!this.#userGrants(s).length) fail('access-revoked'); return this.#cap(s); } + // Row 252 (#1753, design section 2): the trusted Console transport invokes this only after + // verifyPrincipal checked the console-cli wrapper, its webui `serve` parent and the signed-in owner. + // The cap is the business human's with via 'console' and the owner subject. request() lets it reach + // CONSOLE_VERBS only, and #human refuses it, so no cli verb accepts it. + bindConsole(record) { + keys(record, ['business', 'human', 'subject'], ['business', 'human', 'subject']); + const b = this.#business(record.business); + const x = record.subject; + if (record.human !== b.human || !object(x) || Object.keys(x).length !== 3 || + !['provider', 'iss', 'sub'].every((k) => typeof x[k] === 'string' && x[k].length > 0 && x[k].length <= 512)) + fail('human-required'); + return this.#cap({ + business: record.business, + human: b.human, + via: 'console', + console: Object.freeze({ provider: x.provider, iss: x.iss, sub: x.sub }), + role: null, + run: null, + }); + } bindReader({ business }) { this.#business(business); return this.#cap({ business, reader: true, role: null, run: null, human: false }); @@ -1504,10 +1527,61 @@ export class Broker { const raise = this.#raised(business, decision); if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null; const r = this.#closed(decision); - if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human) - return null; + if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null; + // Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input, + // which only the broker writes (recordEvent reserves the kind). + if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null; return raise.grant; } + // Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice, + // no role or run, and a three-string owner subject. + #consoleInput(business, decision, r) { + const e = this.#store.get( + `SELECT body FROM events WHERE business=? AND kind='human.input' AND subject=? AND actor_role IS NULL AND actor_run IS NULL + AND json_extract(body,'$.via')='console' AND json_extract(body,'$.decisionSeq')=? ORDER BY seq LIMIT 1`, + business, + decision, + r.seq, + ); + const x = e && JSON.parse(e.body); + return ( + !!x && + x.kind === 'answer' && + x.decision === decision && + x.op === 'resolved' && + x.choice === r.choice && + object(x.subject) && + ['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. + #consoleResolution(d) { + if (d.route_to !== 'human' || d.action !== 'credential.mint') return null; + const raise = this.#raised(d.business, d.id); + if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null; + const options = JSON.parse(d.options); + if (options.length !== 2 || options[0].key === options[1].key) return null; + const approve = options.find((o) => o.key === raise.approvalChoice); + if (!approve) return null; + const deny = options.find((o) => o.key !== raise.approvalChoice); + const g = raise.grant; + const grant = { + method: g.method, + ...(typeof g.agent === 'string' ? { agent: g.agent } : {}), + ...(typeof g.host === 'string' ? { host: g.host } : {}), + ...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}), + }; + return { + 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])), + }; + } // r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only // guarded body with `decision` (#436b section 7), so #spent counts it. #consumeGrant(business, decision, method) { @@ -4551,6 +4625,9 @@ export class Broker { grants = this.#userGrants(s, doc); if (!grants.length) fail('access-revoked'); } + // Row 252 (design section 2, N4): a console cap reaches the two console verbs only, and nothing + // else reaches them. decision.resolve stays off the list. + if (!!s.console !== CONSOLE_VERBS.includes(request.verb)) fail('human-required'); const args = request.args ?? {}; if (!object(args)) fail('invalid-request'); this.#secretCheck(args); @@ -5081,6 +5158,7 @@ export class Broker { const s = this.#session(cap); try { if (s.user) fail('access-refused'); + if (s.console) fail('human-required'); this.#processingWork(); keys(request, ['verb', 'args'], ['verb']); if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb'); @@ -5119,6 +5197,7 @@ export class Broker { let args; try { if (s.user) fail('access-refused'); + if (s.console) fail('human-required'); keys(request, ['verb', 'args'], ['verb']); this.#processingWork(); if (!verb) fail('unknown-verb'); @@ -5178,6 +5257,7 @@ export class Broker { let a, entry, timeout, bound; try { if (s.user) fail('access-refused'); + if (s.console) fail('human-required'); this.#processingWork(); keys(request, ['verb', 'args'], ['verb']); if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb'); @@ -5501,15 +5581,38 @@ 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). + case 'decision.preview.console': { + keys(a, ['id'], ['id']); + identifier(a.id); + if (!s.console || s.human !== b.human) fail('human-required'); + const d = this.#decision(s, a.id); + if (this.#closed(d.id)) fail('decision-closed'); + const c = this.#consoleResolution(d); + if (!c) fail('decision-not-eligible'); + return { id: d.id, business: d.business, question: d.question, ...c }; + } + case 'decision.resolve.console': case 'decision.resolve': case 'decision.seen': case 'decision.withdraw': { - keys(a, ['id', 'choice', 'note'], ['id']); + const viaConsole = verb === 'decision.resolve.console'; + if (viaConsole) keys(a, ['id', 'choice', 'digest'], ['id', 'choice', 'digest']); + else keys(a, ['id', 'choice', 'note'], ['id']); identifier(a.id); if (a.note !== undefined) string(a.note); const d = this.#decision(s, a.id); if (this.#closed(d.id)) fail('decision-closed'); - if (verb === 'decision.withdraw') { + // 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). + 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'); + } else if (verb === 'decision.withdraw') { if (s.human) { this.#human(s); } else { @@ -5521,32 +5624,39 @@ export class Broker { this.#agent(s); if (s.role !== d.route_to) fail('not-resolver'); } - const op = verb === 'decision.resolve' ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn'; + const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn'; if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice)) fail('invalid-choice'); - this.#store.run( - 'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)', - d.id, - this.#now(), - op, - by, - op === 'resolved' ? a.choice : null, - a.note ?? null, - s.human ? 'cli' : 'broker', + const seq = Number( + this.#store.run( + 'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)', + d.id, + this.#now(), + op, + by, + op === 'resolved' ? a.choice : null, + a.note ?? null, + s.console ? 'console' : s.human ? 'cli' : 'broker', + ).lastInsertRowid, ); - // Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this - // transaction commits. It rechecks all of this itself, so a rollback starts nothing. - const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null; - if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id)); - if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id)); - if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id)); + // Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's + // record to start the executor. Its body names the owner subject and the close's seq. if (s.human) this.#event( s, 'human.input', - { kind: 'answer', decision: d.id, op, choice: a.choice ?? null }, + s.console + ? { kind: 'answer', decision: d.id, op, choice: a.choice, via: 'console', subject: s.console, decisionSeq: seq } + : { kind: 'answer', decision: d.id, op, choice: a.choice ?? null }, d.id, ); + // Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this + // transaction commits. It rechecks all of this itself, so a rollback starts nothing. Row 252: a + // Console approve does too. A deny closes the decision with the other key and starts nothing. + const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null; + if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id)); + if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id)); + if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id)); // Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's. // Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the // raiser, definitions change only with a pinned copy and arbiters only through the business @@ -5573,7 +5683,7 @@ export class Broker { this.#now(), s.business, s.human ? 'human' : s.role, - s.human ? 'human-cli' : s.run, + s.console ? 'human-console' : s.human ? 'human-cli' : s.run, d.raised_by_role, 'DECISION', null, @@ -5583,7 +5693,8 @@ export class Broker { ); this.#route(s.business, mid, d.raised_by_role, session); } - return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op }; + const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op }; + return grant ? { ...answer, grant: 'started' } : answer; } case 'message.send': { keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']); @@ -5939,7 +6050,12 @@ export class Broker { s.business, s.human || s.reader ? 'human' : s.role, ) - .map((d) => this.#decisionView(d)); + .map((d) => { + // Row 252: a grant request the Console can answer carries its two answers. The Console + // shows buttons from this, and the answer path rechecks it at the bus. + const c = s.human || s.reader ? this.#consoleResolution(d) : null; + return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d); + }); if (verb === 'agents') { const claims = this.#store.all( "SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role", diff --git a/packages/bus/src/client.mjs b/packages/bus/src/client.mjs index 356e0ee81..2ef4d3991 100644 --- a/packages/bus/src/client.mjs +++ b/packages/bus/src/client.mjs @@ -1,18 +1,19 @@ import { connect } from 'node:net'; import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs'; export class Client { - constructor({ path, cap, human, tester, user, timeout = 5000 }) { + constructor({ path, cap, human, tester, user, console: owner, timeout = 5000 }) { this.path = path; this.cap = cap; this.human = human; this.tester = tester; this.user = user; + this.console = owner; this.timeout = timeout; } call(verb, args = {}) { const request = JSON.stringify({ - ...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }), + ...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : this.console ? { console: this.console } : { cap: this.cap }), verb, args, }) + '\n'; diff --git a/packages/bus/src/console-cli.mjs b/packages/bus/src/console-cli.mjs new file mode 100644 index 000000000..67dd6b532 --- /dev/null +++ b/packages/bus/src/console-cli.mjs @@ -0,0 +1,41 @@ +// Row 252 (#1753, design section 2): the Console owner's transport shim, spawned only by the webui +// `cli.mjs serve` process for one preview or one answer. Not a second product CLI. The Console sets +// the nonce, the business and the signed-in (provider, iss, sub) in the spawn environment, and the +// bus reads them back from this process's initial environment. Nothing respawns here, so the +// Console stays the direct parent. Input is a verb and its args only: a body that names a business, +// a subject or any other key is refused, never merged. +import { readFileSync } from 'node:fs'; +import { Client } from './client.mjs'; +import { readProcess } from './human.mjs'; +import { CONSOLE_VERBS } from './broker.mjs'; +const env = process.env; +try { + if (!/^[a-f0-9]{64}$/.test(env.MOSAIC_BUS_CONSOLE_NONCE ?? '')) throw Object.assign(Error(), { code: 'human-required' }); + const request = JSON.parse(readFileSync(0, 'utf8')); + if ( + !request || + typeof request !== 'object' || + Array.isArray(request) || + Object.keys(request).some((k) => !['verb', 'args'].includes(k)) || + !CONSOLE_VERBS.includes(request.verb) + ) + throw Error(); + const me = readProcess(process.pid); + const client = new Client({ + path: process.argv[2], + console: { + business: env.MOSAIC_BUS_CONSOLE_BUSINESS, + provider: env.MOSAIC_BUS_CONSOLE_PROVIDER, + iss: env.MOSAIC_BUS_CONSOLE_ISS, + sub: env.MOSAIC_BUS_CONSOLE_SUB, + pid: me.pid, + startTime: me.startTime, + nonce: env.MOSAIC_BUS_CONSOLE_NONCE, + }, + }); + const result = await client.call(request.verb, request.args ?? {}); + process.stdout.write(JSON.stringify(result) + '\n'); +} catch (e) { + process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n'); + process.exitCode = 2; +} diff --git a/packages/bus/src/human.mjs b/packages/bus/src/human.mjs index ee8d13d83..30cb1e66d 100644 --- a/packages/bus/src/human.mjs +++ b/packages/bus/src/human.mjs @@ -10,6 +10,10 @@ const MARKERS = [ 'CODEX_THREAD_ID', 'PI_AGENT_DIR', ]; +// Row 252 (#1753, Webby N5): the Console wrapper's nonce and the subject its spawn bound. The +// verifier reads each from the wrapper's initial environment, never from its proof alone. +export const CONSOLE_ENV = Object.freeze(['MOSAIC_BUS_CONSOLE_NONCE', 'MOSAIC_BUS_CONSOLE_BUSINESS', 'MOSAIC_BUS_CONSOLE_PROVIDER', 'MOSAIC_BUS_CONSOLE_ISS', 'MOSAIC_BUS_CONSOLE_SUB']); +const NONCE_KEYS = ['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE', ...CONSOLE_ENV]; const refuse = () => { throw new BusError('human-required'); }; @@ -31,7 +35,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } = for (const entry of environment.split('\0')) { const i = entry.indexOf('='); const key = entry.slice(0, i); - if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1); + if (NONCE_KEYS.includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1); } return { pid, diff --git a/packages/bus/src/principal.mjs b/packages/bus/src/principal.mjs index 3f60a32e3..3a63b5c1c 100644 --- a/packages/bus/src/principal.mjs +++ b/packages/bus/src/principal.mjs @@ -5,8 +5,8 @@ import { verifyTester, testerNonce } from './tester.mjs'; // Trusted transport API. Identity is held only by this connection's transient session. // User mapping and live grant authorization belong to R3, not to this verifier. -export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) { - const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]); +export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce, consoleOwners = [] }) { + const kinds = ['human', 'tester', 'user', 'console'].filter(k => request?.[k]); if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope'); const kind = kinds[0], proof = request[kind]; const error = kind === 'tester' ? 'tester-required' : 'human-required'; @@ -16,6 +16,7 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [ const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce }); return { kind: 'tester', business, id: 'tester' }; } + if (kind === 'console') return verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners }); let checked = proof, parent; if (kind === 'user') { if (!proof || typeof proof.user !== 'string' || !proof.user || @@ -42,3 +43,38 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [ if (!id) throw new BusError('unknown-business'); return { kind: 'owner', business, id }; } + +// Row 252 (#1753, design section 2): the Console owner. The same-tree console-cli.mjs wrapper, its own +// nonce namespace and the human walk, with the exact same-tree webui `cli.mjs serve` as its direct +// parent, read again after the walk as the user kind does. Business and (provider, iss, sub) must be +// the wrapper's initial spawn environment and one of the checked console.sso subjects the runtime +// was started with (Webby N1: every configured subject is an owner). The Console's mapping is trusted +// as row 196 permits; the bus does not exchange an ID token itself. +const SUBJECT_KEYS = ['provider', 'iss', 'sub']; +const SUBJECT_ENV = { business: 'MOSAIC_BUS_CONSOLE_BUSINESS', provider: 'MOSAIC_BUS_CONSOLE_PROVIDER', iss: 'MOSAIC_BUS_CONSOLE_ISS', sub: 'MOSAIC_BUS_CONSOLE_SUB' }; +function verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners }) { + const refuse = () => { throw new BusError('human-required'); }; + if (!proof || typeof proof !== 'object' || + Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', ...SUBJECT_KEYS].includes(k)) || + SUBJECT_KEYS.some(k => typeof proof[k] !== 'string' || !proof[k] || proof[k].length > 512)) refuse(); + if (typeof paths.consoleCli !== 'string' || typeof paths.console !== 'string') refuse(); + const { provider, iss, sub, ...checked } = proof; + let child, parent; + try { + child = readProcess(proof.pid); + parent = readProcess(child.ppid); + if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse(); + for (const [key, name] of Object.entries(SUBJECT_ENV)) if (child.env?.[name] !== proof[key]) refuse(); + } catch { refuse(); } + const { business } = verifyHuman(checked, { cliPath: paths.consoleCli, launches, readProcess, nonceKey: 'MOSAIC_BUS_CONSOLE_NONCE' }); + try { + const again = readProcess(proof.pid), up = readProcess(parent.pid); + if (again.ppid !== parent.pid || again.startTime !== child.startTime || up.startTime !== parent.startTime || + up.ppid !== parent.ppid || up.uid !== parent.uid || up.argv[1] !== parent.argv[1] || up.argv[2] !== 'serve') refuse(); + for (const [key, name] of Object.entries(SUBJECT_ENV)) if (again.env?.[name] !== proof[key]) refuse(); + } catch { refuse(); } + if (!Array.isArray(consoleOwners) || !consoleOwners.some(o => o && o.provider === provider && o.iss === iss && o.sub === sub)) refuse(); + const id = businesses[business]?.human; + if (!id) throw new BusError('unknown-business'); + return { kind: 'console', business, id, via: 'console', subject: { provider, iss, sub } }; +} diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index c0309e86c..d02d85158 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -106,7 +106,15 @@ export async function startBroker({ // Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet. FED R4 (#1801): // intervalMs, the revocation timer's period (design 3.7, 30 s), in-process only like librarySync's. access = null, + // Row 252 (#1753, design section 2): the checked console.sso subjects, [{provider, iss, sub}], each an + // owner of the business (Webby N1). The boot config carries them; no socket verb sets them. Empty, no + // Console owner binds and the terminal owner is unchanged. + consoleOwners = [], }) { + if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) || + Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512))) + throw new BusError('invalid-host-request'); + const owners = Object.freeze(consoleOwners.map((o) => Object.freeze({ provider: o.provider, iss: o.iss, sub: o.sub }))); if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object' || (Object.hasOwn(access, 'intervalMs') && !(Number.isSafeInteger(access.intervalMs) && access.intervalMs > 0)))) throw new BusError('invalid-host-request'); @@ -408,6 +416,7 @@ export async function startBroker({ tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)), user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)), console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)), + consoleCli: fileURLToPath(new URL('./console-cli.mjs', import.meta.url)), }; const path = join(store.directory, 'broker.sock'); // The existing host executor owns SSH and its key lifecycle. Combine its revocation @@ -439,6 +448,11 @@ export async function startBroker({ const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords }); return broker.bindUser({ business: p.business, user: p.id }); }, + // Row 252 (#1753): a fresh proof per connection, a cap for the Console verbs only. + authenticateConsole: (proof, peer) => { + const p = verifyPrincipal({ console: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords, consoleOwners: owners }); + return broker.bindConsole({ business: p.business, human: p.id, subject: p.subject }); + }, }); // Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't // wait on the network. diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index 91f0d7633..e2dfe0619 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -1,6 +1,6 @@ import { createServer } from 'node:net'; import { lstatSync, chmodSync, unlinkSync } from 'node:fs'; -import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs'; +import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX, CONSOLE_VERBS } from './broker.mjs'; import { inspectInterpreter } from './interpreter.mjs'; import { createPeerReader } from './peercred.mjs'; const LIMIT = 65536; @@ -13,8 +13,9 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('. // the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted). // `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses // host-not-configured. -// A request carries exactly one of cap, human (row S4) and tester (row 110) proof. -export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) { +// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console +// (row 252) proof. +export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) { try { lstatSync(path); throw new BusError('socket-exists'); @@ -64,15 +65,17 @@ export async function serve({ broker, path, authenticateHuman = null, authentica !r || Array.isArray(r) || typeof r !== 'object' || - Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) || + Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'console', 'verb', 'args'].includes(k)) || typeof r.verb !== 'string' || - [r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1 + [r.cap, r.human, r.tester, r.user, r.console].filter(Boolean).length !== 1 ) throw new BusError('invalid-envelope'); let cap = r.cap; - const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null; + const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : r.console ? 'console' : null; + // Row 252 (#1753): a Console owner proof reaches its two verbs only, refused before any proof check. + if (kind === 'console' && !CONSOLE_VERBS.includes(r.verb)) throw new BusError('human-required'); if (kind) { - const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser; + const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : kind === 'user' ? authenticateUser : authenticateConsole; if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required'); socket.pause(); let peer; diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs index 5c174528d..ba0179832 100644 --- a/packages/bus/tests/channel-proof.test.mjs +++ b/packages/bus/tests/channel-proof.test.mjs @@ -6,6 +6,9 @@ // bus.mjs for six library-* STATUS entries; no proof or transport change. // Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or // transport change. +// Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five +// MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall, +// which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged. import test from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; @@ -15,7 +18,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs"; import { Client } from "../src/client.mjs"; const baselines = { - "packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079", + "packages/bus/src/human.mjs": "bb13c146717cdc46d75d157177e400edac4d3e10f14fe511027a25fd44cec885", "packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af", "packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774", // Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists, @@ -26,7 +29,7 @@ const baselines = { // Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and // organization-tracker-bot-taken 409; no proof or transport change. // Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged. - "packages/webui/src/bus.mjs": "39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c", + "packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c", }; const repoFile = path => new URL("../../../" + path, import.meta.url); diff --git a/packages/bus/tests/console-decide.test.mjs b/packages/bus/tests/console-decide.test.mjs new file mode 100644 index 000000000..bc8ef3bec --- /dev/null +++ b/packages/bus/tests/console-decide.test.mjs @@ -0,0 +1,291 @@ +// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5, N1-N5): the broker side of the +// Console's Approve and Deny for a grant request. A console cap, made by bindConsole after the +// transport checked the wrapper and the signed-in owner, reaches only decision.preview.console and +// decision.resolve.console, and no other cap reaches those. The answer records via 'console' and a +// human.input that names the owner, written before the grant check (N2), and only that record lets +// an approve start the executor (J13, J14). Q188: no agent caller answers. The grant uses row 234's +// host-enroll raise with a fake keyscan and fake enroll and check steps, so nothing connects anywhere. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Broker, CONSOLE_VERBS } from '../src/broker.mjs'; +import { Store } from '../src/store.mjs'; +import { hostGrant, fingerprint } from '../src/host-grant.mjs'; +import { grantDecision } from './host-fixtures.mjs'; + +const options = [{ key: 'yes', text: 'Yes, enroll it' }, { key: 'no', text: 'No' }]; +const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const keyLine = (dir, name) => { + execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]); + return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' '); +}; +const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true)); + +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'c252-')); + const store = new Store(root); + t.after(() => { + store.close(); + rmSync(root, { recursive: true, force: true }); + }); + const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')]; + const configDir = join(root, 'config'); + const real = hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run'), scan: async () => hostKeys }); + const enrolls = []; + const hg = { + ...real, + enroll: async (...args) => (enrolls.push(args), real.enroll(...args)), + check: async (...args) => ({ installerFingerprint: args[2].installer_fingerprint, exit: 0 }), + }; + const business = { + id: 'demo', + human: 'jason', + arbiters: { technical: 'pm', delivery: 'pm' }, + roles: { + chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, + pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } }, + }, + vars: { 'bus.tester': true }, + }; + const make = () => new Broker({ store, businesses: { demo: business }, hostGrant: hg }); + const broker = make(); + const caps = {}; + for (const role of ['chief', 'pm']) { + caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role }); + broker.request(caps[role], { verb: 'role.claim' }); + } + caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + caps.tester = broker.bindTester({ business: 'demo' }); + caps.reader = broker.bindReader({ business: 'demo' }); + caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT }); + const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: hostKeys.map(fingerprint) }; + const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args }); + const raise = (extra = {}) => + call('chief', 'decision.raise', { action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false, grant, ...extra }); + const events = () => + store + .all("SELECT seq, kind, actor_role, actor_run, subject, body FROM events WHERE business='demo' ORDER BY seq") + // The row's subject is `about`, since a console answer's body has its own `subject`. + .map((e) => ({ seq: e.seq, kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, about: e.subject, ...JSON.parse(e.body) })); + const grantEvents = (decision) => events().filter((e) => e.actor_run === 'grant' && (e.decision === decision || e.grantDecision === decision)); + const settle = async (decision) => { + for (let i = 0; i < 250; i++) { + if (grantEvents(decision).some((e) => ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return; + await sleep(20); + } + throw new Error('enrollment did not end'); + }; + const closes = (decision) => store.all('SELECT seq, op, by, choice, via FROM decision_events WHERE decision=? ORDER BY seq', decision); + return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant }; +} + +test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => { + assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']); + assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false); + assert.ok(Object.isFrozen(CONSOLE_VERBS)); +}); + +test('bindConsole takes only the business human and a three-string owner subject', (t) => { + const f = setup(t); + const bind = (record) => () => f.broker.bindConsole(record); + refusesSync(bind({ business: 'demo', human: 'someone', subject: SUBJECT }), 'human-required'); + refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, name: 'Jason' } }), 'human-required'); + refusesSync(bind({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'i' } }), 'human-required'); + refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: '' } }), 'human-required'); + refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 'x'.repeat(513) } }), 'human-required'); + refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 7 } }), 'human-required'); + refusesSync(bind({ business: 'demo', human: 'jason', subject: 'owner-1' }), 'human-required'); + assert.throws(bind({ business: 'demo', human: 'jason', subject: SUBJECT, via: 'cli' })); + assert.throws(bind({ business: 'demo', human: 'jason' })); + assert.throws(bind({ business: 'other', human: 'jason', subject: SUBJECT })); + assert.deepEqual(f.broker.identity(f.caps.console), { business: 'demo', role: null, run: null, human: 'jason' }); +}); + +test('J1, Q188: only a console cap reaches the console verbs, and a console cap reaches nothing else', async (t) => { + const f = setup(t); + const d = await f.raise(); + const preview = f.call('console', 'decision.preview.console', { id: d.id }); + const args = { id: d.id, choice: 'yes', digest: preview.digest }; + // Agents, the cli human, the tester and the reader are refused before the verb runs. + for (const who of ['chief', 'pm', 'human', 'tester', 'reader']) { + refusesSync(() => f.call(who, 'decision.preview.console', { id: d.id }), 'human-required'); + refusesSync(() => f.call(who, 'decision.resolve.console', args), 'human-required'); + } + // N4: the console cap answers nothing through decision.resolve, and reaches no other verb. + for (const [verb, a] of [ + ['decision.resolve', { id: d.id, choice: 'yes' }], + ['decision.seen', { id: d.id }], + ['decision.withdraw', { id: d.id }], + ['decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant }], + ['inbox', {}], + ['message.send', { to: 'chief', body: 'hi' }], + ['role.claim', {}], + ]) + refusesSync(() => f.call('console', verb, a), 'human-required'); + assert.deepEqual(f.closes(d.id), []); + assert.equal(f.enrolls.length, 0); +}); + +test('preview: the two real answers, a grant summary and the digest, for an open grant request only', async (t) => { + const f = setup(t); + const d = await f.raise(); + const p = f.call('console', 'decision.preview.console', { id: d.id }); + assert.equal(p.id, d.id); + assert.equal(p.business, 'demo'); + assert.match(p.question, /^Enroll web1\?/); + assert.deepEqual(p.approve, { key: 'yes', text: 'Yes, enroll it' }); + assert.deepEqual(p.deny, { key: 'no', text: 'No' }); + assert.deepEqual(p.grant, { method: 'host-enroll', host: 'web1' }); + assert.match(p.digest, /^[a-f0-9]{64}$/); + // Read twice, the digest is the same; a preview writes nothing. + assert.equal(f.call('console', 'decision.preview.console', { id: d.id }).digest, p.digest); + assert.deepEqual(f.closes(d.id), []); + refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id, choice: 'yes' }), 'invalid-request'); + refusesSync(() => f.call('console', 'decision.preview.console', { id: 'no-such-decision' }), 'decision-not-found'); + // 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 }); + } +}); + +test('J1, J2: a decision that is not an eligible grant request refuses both console verbs', async (t) => { + const f = setup(t); + const grant = { method: 'host', agent: 'pm', host: 'web1', commands: ['uptime'], rotateBy: '2099-01-01' }; + const ineligible = [ + grantDecision(f.store, { action: 'deploy', grant, close: null }), + grantDecision(f.store, { route: 'pm', grant, close: null }), + grantDecision(f.store, { grant: undefined, close: null }), + ]; + for (const id of ineligible) { + 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.equal(f.call('human', 'inbox').find((r) => r.id === id)?.console, undefined); + } +}); + +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(); + const { digest } = f.call('console', 'decision.preview.console', { id: d.id }); + const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }); + assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'yes', grant: 'started' }); + const [close] = f.closes(d.id); + assert.deepEqual({ ...close, seq: undefined }, { seq: undefined, op: 'resolved', by: 'jason', choice: 'yes', via: 'console' }); + const input = f.events().filter((e) => e.kind === 'human.input' && e.about === d.id); + assert.equal(input.length, 1); + assert.deepEqual( + { ...input[0], seq: undefined }, + { seq: undefined, kind: 'answer', actor_role: null, actor_run: null, about: d.id, decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: close.seq }, + ); + await f.settle(d.id); + assert.equal(f.enrolls.length, 1); + const spend = f.grantEvents(d.id).find((e) => e.operation === 'grant.host-enroll'); + assert.ok(spend && input[0].seq < spend.seq, 'the human.input precedes the executor spend'); + assert.ok(f.grantEvents(d.id).some((e) => e.action === 'grant.host.enrolled')); + // N3: the close notice to the raiser comes from the human through the Console. + const notices = f.store.all("SELECT from_run, body FROM messages WHERE business='demo' AND decision=? AND class='DECISION' AND from_role='human'", d.id); + assert.equal(notices.length, 1); + const [notice] = notices; + assert.equal(notice.from_run, 'human-console'); + assert.match(notice.body, /resolved by jason: yes \(Yes, enroll it\)/); + // A second answer of either kind finds it closed, and nothing starts again. + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed'); + refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id }), 'decision-closed'); + refusesSync(() => f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }), 'decision-closed'); + await sleep(50); + assert.equal(f.enrolls.length, 1); + assert.equal(f.closes(d.id).length, 1); +}); + +test('J15: a Console deny closes with the other key and starts nothing', async (t) => { + const f = setup(t); + const d = await f.raise(); + const { digest } = f.call('console', 'decision.preview.console', { id: d.id }); + const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'no', digest }); + assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'no' }); + assert.deepEqual(f.closes(d.id).map((c) => [c.op, c.choice, c.via]), [['resolved', 'no', 'console']]); + await sleep(100); + assert.equal(f.enrolls.length, 0); + assert.deepEqual(f.grantEvents(d.id), []); + assert.deepEqual(f.make().reconcileGrants(), []); +}); + +test('J2: approve and deny come from approvalChoice and the options, not a fixed yes and no', async (t) => { + const f = setup(t); + // Deny listed first, so a build that takes options[0] as approve fails too. + const keyed = { options: [{ key: 'stop', text: 'Stop' }, { key: 'go', text: 'Go ahead' }], approvalChoice: 'go', recommendation: 'go' }; + const denied = await f.raise({ ...keyed, question: 'Enroll web1? (deny)' }); + const p = f.call('console', 'decision.preview.console', { id: denied.id }); + assert.deepEqual([p.approve, p.deny], [{ key: 'go', text: 'Go ahead' }, { key: 'stop', text: 'Stop' }]); + for (const choice of ['yes', 'no']) refusesSync(() => f.call('console', 'decision.resolve.console', { id: denied.id, choice, digest: p.digest }), 'decision-stale'); + assert.deepEqual(f.call('console', 'decision.resolve.console', { id: denied.id, choice: 'stop', digest: p.digest }), { id: denied.id, op: 'resolved', choice: 'stop' }); + await sleep(100); + assert.equal(f.enrolls.length, 0); + const approved = await f.raise({ ...keyed, question: 'Enroll web1? (approve)' }); + const { digest } = f.call('console', 'decision.preview.console', { id: approved.id }); + assert.deepEqual(f.call('console', 'decision.resolve.console', { id: approved.id, choice: 'go', digest }), { id: approved.id, op: 'resolved', choice: 'go', grant: 'started' }); + await f.settle(approved.id); + assert.equal(f.enrolls.length, 1); +}); + +test('J11: a stale digest or a key outside the two refuses decision-stale and writes nothing', async (t) => { + const f = setup(t); + const d = await f.raise(); + const { digest } = f.call('console', 'decision.preview.console', { id: d.id }); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: 'f'.repeat(64) }), 'decision-stale'); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'maybe', digest }), 'decision-stale'); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes' }), 'invalid-request'); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest, note: 'x' }), 'invalid-request'); + // Another decision's digest does not answer this one. + const other = await f.raise({ question: 'Enroll web1 again?' }); + const od = f.call('console', 'decision.preview.console', { id: other.id }).digest; + assert.notEqual(od, digest); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: od }), 'decision-stale'); + assert.deepEqual(f.closes(d.id), []); + assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0); + // A refusal is recorded, as for every verb. + assert.ok(f.events().some((e) => e.kind === 'action.refused' && e.code === 'decision-stale')); + // The decision closed by the cli meanwhile: the console answer finds it closed. + f.call('human', 'decision.resolve', { id: d.id, choice: 'no' }); + refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed'); +}); + +test('J14: a via console close with no matching human.input approves nothing', (t) => { + const f = setup(t); + const grant = { method: 'host-enroll', host: 'web2', address: '192.0.2.11', port: 22, user: 'jarvis', sudo: true, fingerprints: [] }; + const cli = grantDecision(f.store, { grant }); + const bare = grantDecision(f.store, { grant: { ...grant, host: 'web3' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } }); + const wrong = grantDecision(f.store, { grant: { ...grant, host: 'web4' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } }); + const good = grantDecision(f.store, { grant: { ...grant, host: 'web5' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } }); + const seqOf = (id) => f.store.get('SELECT seq FROM decision_events WHERE decision=?', id).seq; + const input = (id, body, role = null) => + f.store.run( + "INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',?,?,?,?)", + 'e-' + id + '-' + Math.random(), '2026-10-22T00:00:00.000Z', 'demo', role, role && role + '-run', id, JSON.stringify(body), + ); + const answer = (id) => ({ kind: 'answer', decision: id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: seqOf(id) }); + // `bare` has no human.input at all. `wrong` has records that each break one part: another seq, choice, empty subject, an agent actor. + input(wrong, { ...answer(wrong), decisionSeq: seqOf(wrong) + 1000 }); + input(wrong, { ...answer(wrong), choice: 'no' }); + input(wrong, { ...answer(wrong), subject: { ...SUBJECT, sub: '' } }); + input(wrong, answer(wrong), 'chief'); + input(good, answer(good)); + // reconcileGrants ends every approved, unspent host-enroll: exactly the cli one and the good console one. + assert.deepEqual(f.make().reconcileGrants().sort(), [cli, good].sort()); + assert.equal(f.enrolls.length, 0); + assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE business='demo' AND actor_run='grant' AND json_extract(body,'$.decision') IN (?,?)", bare, wrong).n, 0); +}); + +test('recordEvent keeps human.input reserved, so no agent forges a console answer', async (t) => { + const f = setup(t); + const d = await f.raise(); + const forged = { kind: 'human.input', subject: d.id, body: { kind: 'answer', decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: 1 } }; + for (const who of ['chief', 'pm']) refusesSync(() => f.broker.recordEvent(f.caps[who], forged), 'reserved-event'); + refusesSync(() => f.broker.recordEvent(f.caps.console, forged), 'agent-required'); + assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0); +}); diff --git a/packages/bus/tests/principal.test.mjs b/packages/bus/tests/principal.test.mjs index cfba00717..5ca5b80ba 100644 --- a/packages/bus/tests/principal.test.mjs +++ b/packages/bus/tests/principal.test.mjs @@ -46,3 +46,52 @@ test('human leaf identity is reread after walking the parents',()=>{ c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;}; assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/); }); + +// Row 252 (#1753, design section 2, J3-J5): the Console owner. The same-tree console-cli wrapper with +// its own nonce, a direct same-tree `serve` parent, the subject from the wrapper's spawn environment, +// and that subject one of the configured owners (N1). +const owner={provider:'fake-oidc',iss:'https://idp.test',sub:'owner-1'}; +function consoleContext() { + const c=context('user');c.paths={...paths,consoleCli:'/tree/console-cli.mjs'};c.consoleOwners=[{provider:'other',iss:'https://idp.test',sub:'owner-2'},owner]; + Object.assign(c.table.get(50),{argv:['node','/tree/console-cli.mjs'],env:{MOSAIC_BUS_CONSOLE_NONCE:nonce,MOSAIC_BUS_CONSOLE_BUSINESS:'demo',MOSAIC_BUS_CONSOLE_PROVIDER:owner.provider,MOSAIC_BUS_CONSOLE_ISS:owner.iss,MOSAIC_BUS_CONSOLE_SUB:owner.sub}}); + return c; +} +const consoleProof={...proof,...owner}; +test('console requires its own wrapper, nonce and serve parent, and a configured owner from the spawn env',()=>{ + assert.deepEqual(verifyPrincipal({console:consoleProof},consoleContext()),{kind:'console',business:'demo',id:'owner',via:'console',subject:owner}); + // J3: the subject is the spawn's, never the body's alone, and must be a configured owner. + for(const change of [{sub:'owner-2'},{iss:'https://evil.test'},{provider:'other'}]) + assert.throws(()=>verifyPrincipal({console:{...consoleProof,...change}},consoleContext()),/human-required/); + const unlisted=consoleContext();unlisted.consoleOwners=[]; + assert.throws(()=>verifyPrincipal({console:consoleProof},unlisted),/human-required/); + for(const extra of [{name:'Jason'},{human:'owner'},{user:'owner'},{sub:''},{sub:'x'.repeat(513)}]) + assert.throws(()=>verifyPrincipal({console:{...consoleProof,...extra}},consoleContext()),/human-required/); + // J4: the parent is the same-tree serve, direct, and not an agent. + for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}},{uid:uid+1}]) { + const c=consoleContext();Object.assign(c.table.get(40),change); + assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/); + } + const launched=consoleContext();launched.launches=[{pid:40,startTime:'90'}]; + assert.throws(()=>verifyPrincipal({console:consoleProof},launched),/human-required/); + // J5: another nonce namespace, or the wrapper path of another kind, refuses. + for(const env of [{MOSAIC_BUS_CLI_NONCE:nonce},{MOSAIC_BUS_USER_NONCE:nonce}]) { + const c=consoleContext();const t=c.table.get(50);t.env={...t.env,...env};delete t.env.MOSAIC_BUS_CONSOLE_NONCE; + assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/); + } + for(const argv of [['node',paths.human],['node',paths.user],['node','/other/console-cli.mjs']]) { + const c=consoleContext();c.table.get(50).argv=argv; + assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/); + } + // The Console's own nonce does not answer as the cli human or the user. + assert.throws(()=>verifyPrincipal({human:proof},consoleContext()),/human-required/); + assert.throws(()=>verifyPrincipal({user:{...proof,user:'owner'}},consoleContext()),/human-required/); + // Each piece is read again after the walk: a parent or a spawn subject that changed refuses. + for(const [pid,mutate] of [[40,p=>{p.startTime='91';}],[50,p=>{p.env={...p.env,MOSAIC_BUS_CONSOLE_SUB:'owner-2'};}],[50,p=>{p.ppid=41;}]]) { + const c=consoleContext(),read=c.readProcess;let n=0; + // The last read of each pid is verifyConsole's own reread: 40 is read 3 times, 50 is read 4 times. + c.readProcess=x=>{const p=read(x);if(x===pid&&n++===(pid===40?2:3))mutate(p);return p;}; + assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/); + } + for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({console:consoleProof},{...consoleContext(),peer}),/human-required/); + assert.throws(()=>verifyPrincipal({console:consoleProof,human:proof},consoleContext()),/invalid-envelope/); +}); diff --git a/packages/cli/src/config.mjs b/packages/cli/src/config.mjs index 2c016e602..a987cc885 100644 --- a/packages/cli/src/config.mjs +++ b/packages/cli/src/config.mjs @@ -115,6 +115,9 @@ function accessFor(system, env, warn) { return { file, context }; } +export const consoleOwners = (sso) => + sso.providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, iss: p.issuer, sub: s.sub }))); + // The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one // business. The host holds a reader capability for the notifier and binds // launches later through bindLaunch, so `launches` starts empty. @@ -135,6 +138,9 @@ export function bootConfig({ system, businessId, env = process.env, warn = () => if (tracker) config.trackers = { [b.id]: tracker }; const access = accessFor(system, env, warn); if (access) config.access = access; + // Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub. + // The loader checked console.sso; no new config field names owners. + if (system.console?.sso) config.consoleOwners = consoleOwners(system.console.sso); return config; }); } diff --git a/packages/cli/tests/config.test.mjs b/packages/cli/tests/config.test.mjs index b28cedaad..8d86daa1e 100644 --- a/packages/cli/tests/config.test.mjs +++ b/packages/cli/tests/config.test.mjs @@ -69,3 +69,19 @@ test("an unknown business and a broken system config refuse with exit 3", (t) => assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "nope", env: f.env }), (e) => e.exitCode === 3); assert.throws(() => loadSystem({ env: { ...f.env, MOSAIC_CONFIG: join(f.dataRoot, "missing.json") } }), (e) => e.exitCode === 3); }); + +test("Row 252 (Webby N1): every console.sso subject reaches the boot as a console owner, and no sso means no consoleOwners key", (t) => { + const f = fixture(tmp(t)); + const system = loadSystem({ env: f.env }); + assert.equal("consoleOwners" in bootConfig({ system, businessId: "acme", env: f.env }), false); + const sso = { providers: [ + { id: "authentik", issuer: "https://auth.example/app/", subjects: [{ sub: "jason-sub" }, { sub: "second-sub" }] }, + { id: "other", issuer: "https://idp.example", subjects: [{ sub: "jason-sub" }] }, + ] }; + const boot = bootConfig({ system: { ...system, console: { sso } }, businessId: "acme", env: f.env }); + assert.deepEqual(boot.consoleOwners, [ + { provider: "authentik", iss: "https://auth.example/app/", sub: "jason-sub" }, + { provider: "authentik", iss: "https://auth.example/app/", sub: "second-sub" }, + { provider: "other", iss: "https://idp.example", sub: "jason-sub" }, + ]); +}); diff --git a/packages/webui/README.md b/packages/webui/README.md index 6bb6dd32f..0f1d38703 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -335,13 +335,28 @@ 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"). A decision shows `mosaic decide ` for each choice, + "Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny 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 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 + `--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 + `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; + 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. - 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/bus.mjs b/packages/webui/src/bus.mjs index b49cfb996..93dcbc169 100644 --- a/packages/webui/src/bus.mjs +++ b/packages/webui/src/bus.mjs @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import { HUMAN_CLI } from '../../cli/src/transport.mjs'; import { readHostState } from '../../cli/src/host.mjs'; import { views } from '../../bus/src/views.mjs'; +import { CONSOLE_VERBS } from '../../bus/src/broker.mjs'; export class BusReadError extends Error { constructor(code, message = code) { super(message); this.code = code; } @@ -27,10 +28,13 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length // request on stdin, JSON on stdout, or a bus error code as the last // `^[a-z-]{1,64}$` line of stderr. export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) { - return (verb, args = {}) => new Promise((resolve, reject) => { + return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes }); +} +function transport({ cli, socket, env, input, timeoutMs, maxBytes }) { + return new Promise((resolve, reject) => { let child; try { - child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] }); + child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] }); } catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); } const out = [], err = []; let size = 0, settled = false; @@ -53,24 +57,42 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI catch { done(reject, new BusReadError('invalid-response')); } }); child.stdin.on('error', () => {}); // A child that exits early reports through 'close'. - child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`); + child.stdin.end(`${JSON.stringify(input)}\n`); }); } +// Row 252 (#1753, design section 2): the Console owner's answer to a grant request. Each call spawns +// a fresh console-cli with a fresh nonce, and the business and the signed-in (provider, iss, sub) go +// in its spawn environment, where the bus reads them back. Inherited console keys are dropped first, +// so only this call's values reach the wrapper. Only the two console verbs pass (N4). A resolve pins +// the business its confirmation was issued for. +export const CONSOLE_CLI = fileURLToPath(new URL('../../bus/src/console-cli.mjs', import.meta.url)); +export function consoleCall({ dataRoot, socket, business = null, env = process.env, cli = CONSOLE_CLI, timeoutMs = 30000 }) { + return async (verb, args, { subject, business: pinned = null }) => { + if (!CONSOLE_VERBS.includes(verb)) throw new Error(`the Console does not call ${verb}`); + const id = pinned ?? (business || hostBusiness(dataRoot)); + const own = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('MOSAIC_BUS_CONSOLE_'))); + return transport({ + cli, socket, timeoutMs, maxBytes: 1024 * 1024, input: { verb, args }, + env: { ...own, MOSAIC_BUS_CONSOLE_NONCE: randomBytes(32).toString('hex'), MOSAIC_BUS_CONSOLE_BUSINESS: id, + MOSAIC_BUS_CONSOLE_PROVIDER: subject.provider, MOSAIC_BUS_CONSOLE_ISS: subject.iss, MOSAIC_BUS_CONSOLE_SUB: subject.sub }, + }); + }; +} + // The business is the --business flag, or else the live bus host's, read // on every request so a host started after the Console is picked up. export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) { return async (verb, args) => { - let id = business; - if (!id) { - let state; - try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); } - if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given'); - id = state.business; - } - return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args); + return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args); }; } +function hostBusiness(dataRoot) { + let state; + try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); } + if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given'); + return state.business; +} export const busReader = options => views({ call: busCaller(options) }); // FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Same diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 8a6c19125..01c168b7f 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -2,7 +2,7 @@ // MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP. import { createChat } from './chat.mjs'; import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs'; -import { busCaller, busBusiness, userCaller } from './bus.mjs'; +import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs'; import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs'; import { consoleAccess } from './access.mjs'; import { accessContextFor } from '../../cli/src/access.mjs'; @@ -68,7 +68,7 @@ try { // the system config, Business can't resolve authority and Settings has // no notifier. A refusal printed here names no path (redactor). // --send without a bus refuses to start rather than serve a composer that can't send. - let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null; + let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null; let processing = null, ownerCall = null; try { system = loadSystem(); @@ -76,6 +76,9 @@ try { const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); // FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs). ({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn })); + // Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with + // --sso and --send. + if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`; } catch (err) { const why = redactor({ configDir: configDir() })(err.message); @@ -104,7 +107,7 @@ try { } let server; try { - server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, userOptions, federation: system?.federation ?? null }); + server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null }); } catch (err) { sso?.close(); throw err; } let testerServer = null; if (testerSocket) { @@ -128,7 +131,7 @@ try { const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`; // One line per provider: id, issuer, subject count and state. No client id, secret or sub. const health = sso?.health() ?? {}; - const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : ''; + const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : ''; const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : ''; console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`); for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); }); diff --git a/packages/webui/src/decision-http.mjs b/packages/webui/src/decision-http.mjs new file mode 100644 index 000000000..9c2d238b1 --- /dev/null +++ b/packages/webui/src/decision-http.mjs @@ -0,0 +1,148 @@ +// 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, +// 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. +import { createHash, randomBytes } from 'node:crypto'; +import { BusReadError, subjectOk } from './bus.mjs'; + +export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve)$/; +// The endpoint contract, design section 4. Each refusal is one of these, never a 200. +export const DECISION_REFUSALS = Object.freeze({ + 'sign-in-required': [401, 'Sign in again before answering.'], + 'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'], + 'cross-origin': [403, 'Answer from the signed-in Console page.'], + 'not-found': [404, 'Not found.'], + 'human-required': [403, 'The bus refused this human identity.'], + 'peercred-required': [403, 'The bus could not verify this connection.'], + 'method-not-allowed': [405, 'Use POST.'], + 'invalid-request': [400, 'Invalid decision answer.'], + 'request-too-large': [413, 'Decision answer is too large.'], + 'decision-not-found': [404, 'This decision is unavailable.'], + 'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'], + 'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'], + 'decision-stale': [409, 'The choices changed. Review the decision again.'], + 'confirmation-required': [409, 'Review and confirm this answer again.'], + 'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'], + 'outcome-unknown': [503, 'The answer may have reached the bus. Refresh before trying again.'], +}); +// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any +// other code, or no answer, is confirmation-unavailable at the preview and outcome-unknown at the +// resolve, which the page reads as "refresh first". +const BUS_CODES = { + 'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required', + 'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required', + 'peercred-required': 'peercred-required', + 'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible', + 'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale', + 'invalid-request': 'invalid-request', +}; +const BODY_MAX = 4096; +const TOKEN = /^[a-f0-9]{64}$/; +const DIGEST = /^[a-f0-9]{64}$/; +const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); +const hash = (token) => createHash('sha256').update(token).digest('hex'); +export function decisionRefuse(res, code) { + const [status, message] = DECISION_REFUSALS[code]; + res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify({ error: code, message })); +} + +// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024 +// live. A full store refuses a new one rather than drop a live one. An expired one is swept. +export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) { + const live = new Map(); + const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); }; + return { + issue(binding) { + sweep(); + if (live.size >= max) return null; + const token = randomBytes(32).toString('hex'); + live.set(hash(token), { ...binding, expires: now() + ttlMs }); + return token; + }, + // One use: a token found is gone, whether or not it matches. + consume(token, { session, decision, choice }) { + sweep(); + if (typeof token !== 'string' || !TOKEN.test(token)) return null; + const k = hash(token), v = live.get(k); + if (!v) return null; + live.delete(k); + return v.session === session && v.decision === decision && v.choice === choice ? v : null; + }, + get size() { sweep(); return live.size; }, + }; +} + +async function readBody(req) { + if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' }; + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > BODY_MAX) return { code: 'request-too-large' }; + chunks.push(chunk); + } + try { + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); + 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'; + +// `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. +export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) { + const [, raw, step] = DECISION_ROUTE.exec(path); + let id = null; + try { id = decodeURIComponent(raw); } catch { /* refused below */ } + if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed'); + if (!decide) return decisionRefuse(res, 'read-only'); + if (!session) return decisionRefuse(res, 'sign-in-required'); + if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin'); + const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`); + const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); }; + const { value, code } = await readBody(req); + if (code) return refuse(code); + if (!subjectOk(id)) return refuse('decision-not-found'); + const subject = { provider: session.provider, iss: session.iss, sub: session.sub }; + const busCode = (err, open) => { + const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined; + return c ?? open; + }; + if (step === 'confirmation') { + if (Object.keys(value).length !== 1 || typeof value.choice !== 'string') return refuse('invalid-request'); + if (live() !== session) return refuse('sign-in-required'); + 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'); + 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 }); + } + const keys = Object.keys(value); + if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request'); + // Consumed before any await on the bus, so a second click finds nothing (J12). + const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice }); + if (!held) return refuse('confirmation-required'); + if (live() !== session) return refuse('sign-in-required'); + let r; + try { + r = await decide.call('decision.resolve.console', { id, choice: held.choice, digest: held.digest }, { subject, business: held.business }); + } catch (err) { return refuse(busCode(err, 'outcome-unknown')); } + if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown'); + line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`); + return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}) }); +} +function json(res, status, body) { + res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify(body)); +} diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 1cb248984..c7fd196a2 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -190,8 +190,9 @@ out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run }; out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`; } else if (r.table === 'decision_events') { - // The human answers from the CLI (via cli); every other row is the broker's. - out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker'; + // The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the + // signed-in Console (via console); every other row is the broker's. + out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : r.via === 'console' ? 'human-console' : 'broker'; out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`; } else if (r.table === 'task_snapshots') { out.kind = `snapshot.${r.source}`; @@ -227,8 +228,9 @@ // An empty state says what would appear and where it comes from. const empty = (what, from) => `
${what}${from ? `

${from}

` : ''}
`; const taskLink = ref => ref ? `#${esc(String(ref).split('/')[1])}` : ''; - // Rows without an actor role come from the human (CLI, outside any run) or from the broker itself. - const actor = (a, by) => `${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}`; + // Rows without an actor role come from the human (CLI outside any run, or the signed-in Console) + // or from the broker itself. + const actor = (a, by) => `${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : by === 'human-console' ? 'human, signed-in Console' : 'broker'}`; const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now']; const head = (title, sub) => `

${title}

${sub ? `

${sub}

` : ''}
`; let readAt = null; @@ -256,7 +258,7 @@ // Inbox function decisionCard(d) { return `
  • -

    ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}

    +

    ${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('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)}` : ''}

  • `; } @@ -314,12 +316,101 @@ el.outerHTML = composerHtml(); restore(k); } + // Row 252 (#1753, design section 5): Approve and Deny for an open grant request, 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 + // the second click answers. Nothing here retries: a refusal drops the confirmation, and an + // 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; + const ACTIONS = { approve: 'Approve', deny: 'Deny' }; + 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; + if (!a) return ''; + const off = a.pending ? ' disabled' : ''; + 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.

    `; + } 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.

    +
    `; + } 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 e = a.error; + const said = e ? `

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

    ` : ''; + return `

    Answer in Console

    ${body}${said}
    `; + } + // Redraw only the answer panel. `focus` names what takes focus after it. + function paintDecide(focus) { + const el = $('decide-section'); + if (!el) return; + el.outerHTML = decideHtml(); + (focus ? $('decide-section')?.querySelector(focus) : null)?.focus(); + } + async function decidePost(url, body) { + let res = null, json = null; + try { + res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' }); + try { json = await res.json(); } catch { json = null; } + } catch { res = null; } + return { res, json }; + } + // A refusal the Console wrote carries its code and message (design section 4). Anything else, a + // 408 or no answer included, gets the fallback: at the resolve step that is outcome-unknown. + const DECIDE_FALLBACK = { + 'confirmation-unavailable': 'Confirmation is unavailable. Try again later.', + 'outcome-unknown': 'The answer may have reached the bus. Refresh before trying again.', + }; + const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string' + ? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] }; + 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 (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; + 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}`); + } else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } + return; + } + if (step !== 'confirm' || !a.confirm) return; + const c = a.confirm; + a.pending = 'confirm'; a.error = null; paintDecide(); + const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation }); + // One use, whatever came back. + a.pending = null; a.confirm = null; + 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); + } else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } + } async function inboxView(get) { // The broker orders gated first, then oldest; lift blocking ones to the top. 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.'); - return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with mosaic decide; Console only shows them.`)} + 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.' + : 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'; + return `${head('Inbox', `${readNote()} ${answered}`)} ${await composer(get)}

    For you ${rows.length}

    ${body}

    Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.

    `; @@ -343,6 +434,13 @@ // A command is offered only for an open decision routed to the human, and only when // the id and key pass the broker's identifier rule, so the copied text is safe to paste. const answerable = !c && d.routeTo === 'human'; + // Row 252: the answer panel follows this decision. A refresh keeps a confirmation open; a + // decision no longer eligible (closed elsewhere, say) drops it, unless an answer is in flight + // 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 && !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; const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key)) @@ -352,11 +450,18 @@ }).join('')}
    `; const earlier = await chain(get, d); const history = [...d.seen.map(s => `
  • Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}
  • `), ...(c ? [`
  • ${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}
  • `] : [])]; - const help = answerable ? '

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

    ' + // Without SSO, or under SSO without --send, the Console says why it has no buttons and never + // 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.

    ' + : 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)}

    Question

    ${txt(d.question)}

    Class
    ${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}
    Approval
    ${approves(d.authorization)}
    Routed to
    ${txt(d.routeTo)}
    Raised by
    ${txt(d.raisedBy.role)} ${txt(d.raisedBy.run)}
    Raised
    ${when(d.at)}
    ${d.taskRef ? `
    Task
    ${taskLink(d.taskRef)} ${txt(d.taskRef)}
    ` : ''}${d.requirementRef ? `
    Requirement
    ${txt(d.requirementRef)}
    ` : ''}
    DM
    ${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}
    Id
    ${txt(d.id)}
    + ${answer?.id === d.id ? decideHtml() : ''}

    Options

    ${help}${options}

    Earlier versions

    ${earlier.length ? `
      ${earlier.map(x => `
    • ${txt(short(x.id))} ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `${txt(x.closed.note)}` : ''}
    • `).join('')}
    ` : '

    None.

    '}

    ${gap('A later version that replaces this one')}

    History

    ${history.length ? `
      ${history.join('')}
    ` : '

    Not seen yet.

    '}

    Full trail for this decision

    `; @@ -1098,6 +1203,7 @@ return { href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1', field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'), + decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null, open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '', }; } @@ -1105,7 +1211,10 @@ for (const id of k.open) { const d = $(id); if (d) d.open = true; } if ($('copy-status')) $('copy-status').textContent = k.status; const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry')); - if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) { + // Row 252: the answer panel's buttons and its status line. + const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null; + if (dec) dec.focus({ preventScroll: true }); + else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) { el.focus({ preventScroll: true }); if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; } } @@ -1197,6 +1306,9 @@ } return; } + // Row 252: Approve, Deny, Confirm and Cancel. One click is one step; none repeats itself. + const dec = e.target.closest('#decide-section [data-decide]'); + if (dec) { if (!dec.disabled) decide(dec.dataset.decide); return; } if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; } const item = e.target.closest('#cmdk-list [data-href]'); if (item) { pkGo(item.dataset.href); return; } @@ -1318,12 +1430,14 @@ isTester = c?.tester === true; canEdit = !isTester && c?.edit === true; canSend = !isTester && c?.send === true; + ssoOn = c?.sso === true; + canDecide = !isTester && c?.decide === true; if (isTester) { const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref'; banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.'; $('main').prepend(banner); } - if (!canSend && !isTester) return; + if (!canSend && !isTester && !ssoOn) return; const path = routeOf().split('?')[0]; if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); }).catch(() => {}); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 56fcdd4e1..376082853 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -11,6 +11,7 @@ import { ReadError, readStatus, fieldTable } from './reads.mjs'; import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs'; import { chatHTTP } from './chat-http.mjs'; import { processingHTTP } from './processing-http.mjs'; +import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs'; export const DEFAULT_BOARD = 'http://127.0.0.1:7331'; export function isLoopback(host) { @@ -600,7 +601,7 @@ async function projectFileRead(res, read, path, search) { // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // tests; the field table is otherwise the committed packages/tasks/fields.json. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) { +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) { if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (host === 'localhost') host = '127.0.0.1'; if (tester && preview !== null) throw new Error('tester listener cannot use preview mode'); @@ -611,6 +612,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes'); if (tester && !testerSocket) throw new Error('tester listener requires private socket'); if (!tester && testerSocket) throw new Error('tester socket requires tester listener'); + // Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester. + if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener'); + const confirmations = createConfirmations(); const upstream = boardURL(board); // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session // user's own transport (console-bus.mjs). Only a signed-in listener has user sessions. @@ -669,18 +673,23 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // alone stays open on a loopback Host for the status and release gates (Q-C). if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' }); // No CORS. JSON content type and same-origin checks keep browser forms out. - if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' }); - let signer = preview; + const decisionPath = DECISION_ROUTE.test(path); + if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) + return decisionPath ? decisionRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' }); + let signer = preview, ssoSession = null; if (sso !== null && named) { if (path.startsWith('/auth/')) return sso.handle(req, res, path, search); // Only a page load or a write keeps a session alive; the page's background reads don't (Q-D). const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) }); if (!session && path !== '/healthz') { + if (decisionPath) return decisionRefuse(res, 'sign-in-required'); if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' }); res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' }); return res.end(); } if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin }; + // Row 252: only the owner's session answers a decision. A granted user stops at userRoute below. + if (session && session.user === undefined) ssoSession = session; // FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport // object is left, so a route past the gate reaches the broker only as this user. if (session?.user !== undefined) { @@ -696,6 +705,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' }); } const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer; + // Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in + // session on the public name under --sso and --send, they are read-only. + if (decisionPath) { + if (tester) return decisionRefuse(res, 'not-found'); + const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null; + return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log }); + } // Row 194 (#1692, D4.1): the tester also launches and terminates. if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path)) return json(res, 403, { error: 'read-only', sent: false }); @@ -820,7 +836,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // `send`: the page shows the composer only where a send can pass (the public name, --send on). // `edit`: the editor shows Save only where a save can pass, the same test as the route's. // `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged. - if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named }); + if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named }); if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) }); if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } const file = files.get(path); diff --git a/packages/webui/tests/decide-browser.test.mjs b/packages/webui/tests/decide-browser.test.mjs new file mode 100644 index 000000000..2599ee67a --- /dev/null +++ b/packages/webui/tests/decide-browser.test.mjs @@ -0,0 +1,231 @@ +// Row 252 (#1753, design section 5, J16 and J17): Approve and Deny on a decision page, in a browser. +// A loopback page has no sign-in, so a script added before the page loads sets `sso` and `decide` +// in /api/config from the page URL, and answers each POST /api/decisions/... from a queue, recording +// what the page sent. Server-side checks are decision-http.test.mjs's and the bus side is +// packages/bus/tests/console-decide.test.mjs's. The decisions come from an in-process broker +// through its reader session. Refresh timers fire after 150 ms. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../../bus/src/store.mjs'; +import { Broker } from '../../bus/src/broker.mjs'; +import { views } from '../../bus/src/views.mjs'; +import { grantDecision } from '../../bus/tests/host-fixtures.mjs'; +import { startServer } from '../src/serve.mjs'; +import { browser } from './browser.mjs'; +import { fixture, close } from './fixture.mjs'; + +const HOSTILE = ''; +const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } } }; + +// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect` +// what fetch gives for the sign-in proxy's redirect under redirect: 'manual'. +const STUB = `(() => { + const real = window.fetch.bind(window); + window.__posts = []; window.__answers = []; + const timeout = window.setTimeout.bind(window); + window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest); + const q = new URLSearchParams(location.search); + const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + window.fetch = async (url, init = {}) => { + const u = String(url); + if (u === '/api/config') { + 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.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(); + if (!a) throw new TypeError('no answer queued'); + if (a.hold) await new Promise(r => { window.__release = r; }); + if (a.reject) throw new TypeError('Failed to fetch'); + if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } }; + return answer(a.status, a.body); + }; +})();`; + +async function setup() { + 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 = grantDecision(store, { grant, close: null }); + store.run('UPDATE decisions SET question=? WHERE id=?', `Enroll web1? ${HOSTILE}`, open); + const deploy = grantDecision(store, { action: 'deploy', grant, close: null }); + const bare = grantDecision(store, { close: 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(); + const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); + await b.viewport(1280); + const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); + const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); + const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`); + const posts = () => b.evaluate('window.__posts'); + const queue = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`); + const click = step => b.evaluate(`document.querySelector('#decide-section [data-decide="${step}"]').click(), true`); + const page = async (query, id) => { + await b.navigate(`${base}?${query}#/inbox/${id}`); + await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && !document.querySelector("#bus-view [aria-busy]")'); + // The config read redraws once it lands; a refresh tick after it settles the page. + await new Promise(r => setTimeout(r, 400)); + }; + 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 }; +} + +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) } }); + +test('J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel', { timeout: 120000 }, async () => { + const s = await setup(); + try { + // Without SSO: no panel, no button, and the reason names both flags. + await s.page('x', s.open); + assert.equal(await s.has('#decide-section'), false); + assert.equal(await s.has('[data-decide]'), false); + assert.match(await s.text('#bus-view'), /needs sign-in \(--sso\) and writes enabled \(--send\)/); + assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2); + // SSO without --send: read-only, still the terminal lines. + await s.page('sso', s.open); + assert.equal(await s.has('#decide-section'), false); + assert.match(await s.text('#bus-view'), /read-only for decisions: it was started without writes enabled \(--send\)/); + // send alone, without decide, still draws nothing. + await s.page('sso&send', s.open); + assert.equal(await s.has('#decide-section'), false); + // A tester Console never answers, whatever the config says. + await s.page('sso&decide&tester', s.open); + assert.equal(await s.has('#decide-section'), false); + // Eligible: the panel, both buttons, the escaped question and the terminal lines too. + await s.page('sso&decide', s.open); + assert.equal(await s.text('#decide-section h2'), 'Answer in Console'); + assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => [e.dataset.decide, e.textContent, e.disabled])'), [['approve', 'Approve', false], ['deny', 'Deny', false]]); + assert.match(await s.text('#decide-section'), /Grant: method host-enroll · host web1\./); + assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2); + assert.equal(await s.b.evaluate('window.injected ?? null'), null); + assert.ok((await s.text('#bus-view .s1-q')).includes(HOSTILE)); + // A grant raise with another action, and a credential.mint with no grant, keep the terminal only. + for (const id of [s.deploy, s.bare]) { + await s.page('sso&decide', id); + assert.equal(await s.has('#decide-section'), false); + assert.equal(await s.has('[data-decide]'), false); + } + // The Inbox says so, and marks only the eligible row. + 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.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(); } +}); + +test('J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry', { timeout: 120000 }, async () => { + const s = await setup(); + const enc = encodeURIComponent; + try { + await s.page('sso&decide', s.open); + + // Approve asks for a confirmation and answers nothing. The button holds while the request is out. + await s.queue({ ...CONFIRM(s, 'approve', 'yes', 'Yes'), hold: true }); + await s.click('approve'); + await s.wait('document.querySelector("#decide-section [data-decide=approve]")?.textContent === "Checking…"'); + assert.equal(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].every(e => e.disabled)'), true); + await s.click('approve'); + await s.b.evaluate('window.__release(), true'); + await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); + let sent = await s.posts(); + assert.equal(sent.length, 1); + assert.deepEqual(sent[0], { url: `/api/decisions/${enc(s.open)}/confirmation`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }), cache: 'no-store', redirect: 'manual' }); + assert.equal(await s.text('#decide-section [data-decide=confirm]'), 'Confirm approve'); + assert.match(await s.text('#decide-section .s1-confirm'), /Approving starts this grant\./); + assert.equal(await s.b.evaluate('document.activeElement?.dataset.decide ?? null'), 'confirm'); + assert.equal(await s.b.evaluate('window.injected ?? null'), null); + + // A refresh keeps the confirmation open. + await s.settle(); + assert.equal(await s.has('#decide-section [data-decide=confirm]'), true); + + // Cancel drops it without a request. + await s.click('cancel'); + await s.wait('document.querySelector("#decide-section [data-decide=approve]")'); + assert.equal((await s.posts()).length, 1); + + // A refusal the Console wrote shows its code and message; a 408 or a lost answer is unavailable. + await s.queue({ status: 409, body: { error: 'decision-stale', message: 'The choices changed. Review the decision again.' } }); + await s.click('deny'); + await s.wait('document.querySelector("#decide-status")'); + assert.equal(await s.text('#decide-status'), 'Not answered here: The choices changed. Review the decision again. (decision-stale).'); + await s.queue({ status: 408, body: { error: 'x', message: 'y' } }); + await s.click('deny'); + await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable"'); + await s.queue({ reject: true }); + await s.click('deny'); + await s.wait('document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2'); + assert.equal(await s.b.evaluate('document.querySelector("#decide-status code")?.textContent'), 'confirmation-unavailable'); + assert.equal((await s.posts()).length, 4); + + // A reply that doesn't match what was asked is not a confirmation. + await s.queue({ status: 200, body: { ...CONFIRM(s, 'approve', 'yes', 'Yes').body } }); + await s.click('deny'); + await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable" && document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2'); + assert.equal(await s.has('#decide-section [data-decide=confirm]'), false); + + // An unknown outcome at the answer says to refresh, drops the confirmation and sends nothing again. + await s.queue(CONFIRM(s, 'deny', 'no', 'No')); + await s.click('deny'); + await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); + assert.match(await s.text('#decide-section .s1-confirm'), /Denying starts nothing\./); + await s.queue({ status: 503, body: { error: 'outcome-unknown', message: 'The answer may have reached the bus. Refresh before trying again.' } }); + await s.click('confirm'); + await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"'); + assert.match(await s.text('#decide-status'), /Nothing was sent again\.$/); + sent = await s.posts(); + assert.equal(sent.length, 7); + assert.deepEqual(sent[6], { url: `/api/decisions/${enc(s.open)}/resolve`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'no', confirmation: 'c'.repeat(64) }), cache: 'no-store', redirect: 'manual' }); + assert.equal(await s.has('#decide-section [data-decide=confirm]'), false); + await s.settle(); + await s.settle(); + assert.equal((await s.posts()).length, 7, 'the page sent again by itself'); + + // A confirmed approve: one answer, a receipt, and no buttons after it, across refreshes. + await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes')); + await s.click('approve'); + await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); + await s.queue({ status: 200, body: { id: s.open, op: 'resolved', choice: 'yes', grant: 'started' } }); + await s.click('confirm'); + await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Approved")'); + assert.equal(await s.text('#decide-status'), 'Approved: you chose “yes”, Yes. The grant executor started. History below shows it once the bus is read again.'); + await s.settle(); + assert.equal(await s.has('[data-decide]'), false); + assert.equal((await s.posts()).length, 9); + + // Closed elsewhere: the next read drops the panel and shows the recorded answer with its provenance. + s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.open, '2026-10-22T00:00:00.000Z', 'resolved', 'jason', 'yes', 'console'); + await s.page('sso&decide', s.open); + assert.equal(await s.has('#decide-section'), false); + assert.match(await s.text('#bus-view'), /Resolved: jason chose “yes” via console/); + assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0); + } finally { await s.done(); } +}); + +test('a sign-in redirect at either step signs the page out and answers nothing', { timeout: 120000 }, async () => { + const s = await setup(); + try { + await s.page('sso&decide', s.open); + await s.queue({ redirect: true }); + await s.click('approve'); + await s.wait('document.body.classList.contains("signed-out")'); + assert.equal((await s.posts()).length, 1); + await s.page('sso&decide', s.open); + await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes')); + await s.click('approve'); + await s.wait('document.querySelector("#decide-section [data-decide=confirm]")'); + await s.queue({ redirect: true }); + await s.click('confirm'); + await s.wait('document.body.classList.contains("signed-out")'); + assert.equal((await s.posts()).length, 2); + } finally { await s.done(); } +}); diff --git a/packages/webui/tests/decide-sso.test.mjs b/packages/webui/tests/decide-sso.test.mjs new file mode 100644 index 000000000..0500da2da --- /dev/null +++ b/packages/webui/tests/decide-sso.test.mjs @@ -0,0 +1,154 @@ +// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed +// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub, +// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the +// endpoint's own table and console-decide.test.mjs the bus. The public name is console.example.test, +// sent only as a Host header to the test's own listener. sso.test.mjs pins its own log lines, so the +// decide lines are checked here. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { startServer } from '../src/serve.mjs'; +import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; +import { DECISION_REFUSALS } from '../src/decision-http.mjs'; +import { consoleCall } from '../src/bus.mjs'; +import { close } from './fixture.mjs'; +import { fakeOidc } from './helpers/fake-oidc.mjs'; + +const HOST = 'console.example.test', ORIGIN = `https://${HOST}`; +const DIGEST = 'b'.repeat(64); +const PREVIEW = { id: 'd1', business: 'demo', question: 'Enroll web1?', digest: DIGEST, approve: { key: 'yes', text: 'Yes' }, deny: { key: 'no', text: 'No' }, grant: { method: 'host-enroll', host: 'web1' } }; + +// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site. +function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) { + return new Promise((resolve, reject) => { + const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) }; + const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => { + let text = ''; + res.on('data', c => (text += c)); + 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); + req.end(body); + }); +} +const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }); + +// The fake OP, createSso over it, and the human listener. `server` overrides startServer options. +// The decision transport records each call and answers a preview or a resolve. +async function setup(t, server = {}) { + const op = await fakeOidc(); + t.after(() => op.close()); + const iss = op.issuer('a'); + const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] }; + const lines = []; + const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l) }); + t.after(() => sso.close()); + const calls = []; + const decisionWrite = async (verb, args, ctx) => { + calls.push({ verb, args, ctx }); + return verb === 'decision.preview.console' ? { ...PREVIEW } : { id: args.id, op: 'resolved', choice: args.choice }; + }; + 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', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...server }); + t.after(() => close(web)); + const port = web.address().port; + const signIn = async () => { + const start = await call(port, { path: '/auth/login' }); + assert.equal(start.status, 303); + const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0]; + const g = op.grant(iss, start.headers.location); + const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') }); + const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } }); + return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0]; + }; + const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) }); + return { port, iss, calls, lines, signIn, post }; +} +const refused = (r, code) => { + assert.equal(r.status, DECISION_REFUSALS[code][0], `${code}: ${r.status} ${JSON.stringify(r.body)}`); + assert.deepEqual(r.body, { error: code, message: DECISION_REFUSALS[code][1] }); +}; + +test('a signed-in owner confirms and answers, with the session\'s provider, issuer and sub as the subject', async t => { + const env = await setup(t); + const session = await env.signIn(); + const config = await call(env.port, { path: '/api/config', headers: { cookie: session } }); + assert.equal(config.body.decide, true); + assert.equal(config.body.sso, true); + const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }); + assert.equal(c.status, 200, JSON.stringify(c.body)); + const r = await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.deepEqual(r.body, { id: 'd1', op: 'resolved', choice: 'yes' }); + const subject = { provider: 'a', iss: env.iss.issuer, sub: 'user-a' }; + assert.deepEqual(env.calls, [ + { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject } }, + { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject, business: 'demo' } }, + ]); + assert.match(env.lines.at(-1), / decide user="person-a" decision="d1" step=resolve choice="yes"$/); + assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false); +}); + +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'); + const session = await env.signIn(); + refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { origin: 'https://evil.test' }), 'cross-origin'); + refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { 'sec-fetch-site': 'cross-site' }), 'cross-origin'); + const loop = await call(env.port, { method: 'POST', path: '/api/decisions/d1/confirmation', host: `127.0.0.1:${env.port}`, headers: { cookie: session, 'content-type': 'application/json' }, body: '{"choice":"yes"}' }); + assert.equal(loop.status, 403); + refused(await call(env.port, { path: '/api/decisions/d1/confirmation', headers: { cookie: session } }), 'method-not-allowed'); + assert.equal(env.calls.length, 0); + const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }); + assert.equal(c.status, 200); + const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }); + assert.equal(out.status, 303); + refused(await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'sign-in-required'); + assert.equal(env.calls.length, 1); + // A new session can't use the old session's confirmation. + const again = await env.signIn(); + refused(await env.post(again, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'confirmation-required'); + assert.equal(env.calls.length, 1); +}); + +test('J16: without --send or a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send', async t => { + for (const server of [{ send: null }, { decisionWrite: null }]) { + const env = await setup(t, server); + const session = await env.signIn(); + const config = await call(env.port, { path: '/api/config', headers: { cookie: session } }); + assert.equal(config.body.decide, false); + assert.equal(config.body.sso, true); + refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'read-only'); + assert.equal(env.calls.length, 0); + } + const write = async () => ({}); + await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/); + await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/); + const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' }); + t.after(() => close(plain)); + const port = plain.address().port, host = `127.0.0.1:${port}`; + const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' }); + refused(r, 'read-only'); + const config = await call(port, { path: '/api/config', host }); + assert.equal(config.body.decide, false); + assert.equal('sso' in config.body, false); +}); + +test('N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs', async t => { + const env = await setup(t); + const session = await env.signIn(); + for (const verb of ['decision.resolve', 'decision.resolve.console', 'decision.preview.console']) { + for (const method of ['GET', 'POST']) { + const r = await call(env.port, { method, path: `/api/bus/${verb}`, headers: writeHeaders(session), ...(method === 'POST' ? { body: '{}' } : {}) }); + assert.equal(r.status, 404, `${method} ${verb}`); + } + } + assert.equal(env.calls.length, 0); + const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch(cli, /['"]decision\./); + const c = consoleCall({ dataRoot: '/nonexistent', socket: '/nonexistent', business: 'demo', cli: '/nonexistent/console-cli.mjs', timeoutMs: 1000 }); + for (const verb of ['decision.resolve', 'decision.seen', 'message.send', 'inbox']) { + await assert.rejects(c(verb, { id: 'd1', choice: 'yes' }, { subject: { provider: 'a', iss: 'i', sub: 's' } }), /the Console does not call/); + } +}); diff --git a/packages/webui/tests/decision-http.test.mjs b/packages/webui/tests/decision-http.test.mjs new file mode 100644 index 000000000..245a1aa2e --- /dev/null +++ b/packages/webui/tests/decision-http.test.mjs @@ -0,0 +1,205 @@ +// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus +// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side +// in the browser tests. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BusReadError } from '../src/bus.mjs'; +import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs'; + +const ORIGIN = 'https://console.test'; +const DIGEST = 'a'.repeat(64); +const PREVIEW = { + id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST, + approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' }, + grant: { method: 'host-enroll', host: 'h1' }, +}; + +function request({ method = 'POST', body = {}, headers = {}, raw } = {}) { + const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers }; + for (const k of Object.keys(h)) if (h[k] === null) delete h[k]; + const data = Buffer.from(raw ?? JSON.stringify(body)); + return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } }; +} +function response() { + const r = { status: null, headers: null, text: '' }; + r.writeHead = (s, h) => { r.status = s; r.headers = h; }; + r.end = (t = '') => { r.text += t; }; + r.json = () => JSON.parse(r.text); + return r; +} +function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) { + const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }; + const calls = [], lines = []; + const s = { + session, calls, lines, current: session, + confirmations: createConfirmations(now ? { now } : {}), + decide: { + origin: ORIGIN, + call: async (verb, args, ctx) => { + calls.push({ verb, args, ctx }); + return verb === 'decision.preview.console' ? answer(args) : resolve(args); + }, + }, + }; + s.run = async (path, req) => { + const res = response(); + await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) }); + return res; + }; + s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } })); + s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body })); + return s; +} +const refused = (res, code) => { + assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`); + assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] }); + assert.equal(res.headers['cache-control'], 'no-store'); +}; + +test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => { + const s = setup(); + const c = await s.confirm('yes'); + assert.equal(c.status, 200); + const body = c.json(); + assert.match(body.confirmation, /^[a-f0-9]{64}$/); + assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' }); + assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } }); + const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation }); + assert.equal(r.status, 200); + assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' }); + assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } }); + // J12: the confirmation is spent, so a repeat never reaches the bus. + refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required'); + assert.equal(s.calls.length, 2); + assert.equal(s.lines.length, 3); + assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/); + assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/); + assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation)); +}); + +test('deny resolves without a grant field', async () => { + const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) }); + const c = (await s.confirm('no')).json(); + assert.equal(c.action, 'deny'); + assert.equal(c.text, 'No'); + const r = await s.resolve({ choice: 'no', confirmation: c.confirmation }); + assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' }); +}); + +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'); + const res = response(); + await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} }); + refused(res, 'read-only'); + const out = response(); + await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} }); + refused(out, 'sign-in-required'); + for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) { + refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin'); + } + assert.equal(s.calls.length, 0); +}); + +test('a bad body, id or content type refuses before the bus', async () => { + const s = setup(); + const path = '/api/decisions/d1/confirmation'; + refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request'); + refused(await s.run(path, request({ raw: '{' })), 'invalid-request'); + refused(await s.run(path, request({ raw: '[]' })), 'invalid-request'); + refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request'); + refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request'); + refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large'); + refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found'); + refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found'); + refused(await s.resolve({ choice: 'yes' }), 'invalid-request'); + refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request'); + assert.equal(s.calls.length, 0); +}); + +test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => { + refused(await setup().confirm('maybe'), 'decision-stale'); + for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) { + const s = setup({ answer: () => bad }); + refused(await s.confirm('yes'), 'confirmation-unavailable'); + assert.equal(s.confirmations.size, 0); + } +}); + +test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => { + const cases = [ + ['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'], + ['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'], + ['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'], + ['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'], + ]; + for (const [bus, code] of cases) { + const s = setup({ answer: () => { throw new BusReadError(bus); } }); + refused(await s.confirm('yes'), code); + } + refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable'); + for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) { + const s = setup({ resolve: () => { throw new BusReadError(bus); } }); + const c = (await s.confirm('yes')).json(); + refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code); + // Nothing retries, and the confirmation stays spent. + assert.equal(s.calls.length, 2); + refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required'); + } + for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) { + const s = setup({ resolve: () => reply }); + const c = (await s.confirm('yes')).json(); + refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown'); + } +}); + +test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => { + const s = setup(); + let c = (await s.confirm('yes')).json(); + refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required'); + refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required'); + c = (await s.confirm('yes')).json(); + refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required'); + c = (await s.confirm('yes')).json(); + const other = setup(); + other.confirmations = s.confirmations; + other.session = { ...s.session }; + other.current = other.session; + refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required'); + refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required'); + assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0); + assert.equal(other.calls.length, 0); +}); + +test('a session that ends between the steps refuses before the bus', async () => { + const s = setup(); + s.current = null; + refused(await s.confirm('yes'), 'sign-in-required'); + assert.equal(s.calls.length, 0); + s.current = s.session; + const c = (await s.confirm('yes')).json(); + s.current = { ...s.session }; + refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required'); + assert.equal(s.calls.length, 1); + // A sign-out during the preview also refuses, and no confirmation is issued. + const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } }); + refused(await t.confirm('yes'), 'sign-in-required'); + assert.equal(t.confirmations.size, 0); +}); + +test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => { + let t = 0; + const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t }); + const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST }; + const a = store.issue(bind), b = store.issue(bind); + assert.equal(store.issue(bind), null); + assert.equal(store.size, 2); + assert.equal(store.consume(a, bind).digest, DIGEST); + assert.equal(store.consume(a, bind), null); + t = 1000; + assert.equal(store.consume(b, bind), null); + assert.equal(store.size, 0); + assert.match(store.issue(bind), /^[a-f0-9]{64}$/); + assert.equal(store.consume(undefined, bind), null); + assert.equal(store.consume('A'.repeat(64), bind), null); +}); diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs index 778c77e3f..65dad1b79 100644 --- a/packages/webui/tests/shell.test.mjs +++ b/packages/webui/tests/shell.test.mjs @@ -54,10 +54,14 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () = // row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path, // to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role // instance add or change to /api/instances, and row 65 the routing table's save to /api/routes. + // Row 252 (#1753) adds the Console's grant Approve and Deny: one fetch(url, ...) in decidePost, + // called only for a decision's confirmation and its resolve. Its labels come from ACTIONS, so no + // button carries a literal Resolve, Decide or Approve. assert.doesNotMatch(src, /]*>[^<]*(Resolve|Decide|Approve)/i); const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? []; - assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); - assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7); + assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]); + assert.equal(read('bus.js').toString().split(/method:/).length - 1, 8); + assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ['url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']); // Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal. // Row 176 (#1674): the tester name form adds a second /api/name caller. assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]); diff --git a/packages/webui/tests/tester-listener.test.mjs b/packages/webui/tests/tester-listener.test.mjs index 7d85bb76f..0623bfeb0 100644 --- a/packages/webui/tests/tester-listener.test.mjs +++ b/packages/webui/tests/tester-listener.test.mjs @@ -183,3 +183,14 @@ test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and wr assert.equal((await http(web, '/api/chat/threads', { headers: signed })).status, 200); assert.deepEqual(calls, ['threads']); }); + +test('Row 252: the tester listener has no decision answer routes, and its config never offers them', async t => { + const s = await testerFixture(t); + for (const step of ['confirmation', 'resolve']) { + const r = await post(s.web, `/api/decisions/d1/${step}`, step === 'confirmation' ? { choice: 'yes' } : { choice: 'yes', confirmation: 'a'.repeat(64) }); + assert.equal(r.status, 404, r.text); + assert.equal(r.body.error, 'not-found'); + } + assert.equal((await http(s.web, '/api/config')).body.decide, false); + assert.deepEqual(s.calls, []); +});