From 9936a1be1a37fdb30ba53cf2f14b7897225c3e4a Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Oct 2026 00:19:02 -0500 Subject: [PATCH] fix(webui): require verified OIDC for human writes (#385) --- BUILD-LOG.md | 2 + docs/SESSIONS.md | 1 + docs/TOOLS.md | 8 +- packages/webui/README.md | 37 ++-- packages/webui/src/cli.mjs | 6 +- packages/webui/src/serve.mjs | 10 +- packages/webui/tests/agents.test.mjs | 41 ++--- packages/webui/tests/assistant.test.mjs | 21 +-- packages/webui/tests/channel-chat.test.mjs | 20 ++- packages/webui/tests/chat.test.mjs | 67 +++++-- packages/webui/tests/helpers/sso-session.mjs | 94 ++++++++++ packages/webui/tests/instances.test.mjs | 23 +-- packages/webui/tests/library-routes.test.mjs | 33 ++-- packages/webui/tests/limits-route.test.mjs | 13 +- .../tests/preview-write-refusal.test.mjs | 50 ++++++ packages/webui/tests/processing.test.mjs | 31 ++-- packages/webui/tests/project-file.test.mjs | 29 +-- packages/webui/tests/proof.test.mjs | 8 +- packages/webui/tests/proxy-canary.test.mjs | 8 +- packages/webui/tests/proxy-page.test.mjs | 38 ++-- packages/webui/tests/room-http.test.mjs | 10 +- packages/webui/tests/routes-browser.test.mjs | 11 +- packages/webui/tests/routes.test.mjs | 21 +-- packages/webui/tests/send.test.mjs | 166 ++++++++---------- packages/webui/tests/service.test.mjs | 47 ++--- packages/webui/tests/settings-write.test.mjs | 17 +- packages/webui/tests/sso.test.mjs | 21 ++- packages/webui/tests/tester-listener.test.mjs | 7 +- scripts/webui-service.sh | 12 +- 29 files changed, 524 insertions(+), 328 deletions(-) create mode 100644 packages/webui/tests/helpers/sso-session.mjs create mode 100644 packages/webui/tests/preview-write-refusal.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index b912071b1..58ca35875 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5816,3 +5816,5 @@ Before: agents can read the library; supporting-path and push-credential depende 2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog. 2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved. + +2026-10-29T03:27:31Z | Codex implementation sub-agent | #385 OIDC-only writer preparation on exact base 09a847edaa25b609bac7728ff83f224708a29399 | Added fail-closed CLI, programmatic and service-render refusals for `--send` without `--sso`; retained read-only header preview/proof; migrated inventoried writer tests to real in-process OIDC login/callback sessions and updated docs. Focused regression 5/5 and selected service cases 4/4 pass; syntax and diff checks pass. The broader writer suite is blocked before execution because the clean checkout has no `node_modules` and exact pinned `oauth4webapi@3.8.8` is unavailable; no install, commit, push, PR, queue move or green/completion claim. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 70d32e024..8984dbd08 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1879,3 +1879,4 @@ are never rewritten or removed; corrections are new entries. 2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog. 2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved. +2026-10-29T03:27:31Z | Codex Daybreak Blue xhigh implementation sub-agent | #385 OIDC-only Console writes, branch work/vikunja-19-385-q284-complete at exact base 09a847edaa25b609bac7728ff83f224708a29399 | Source/test/docs preparation complete for review; focused refusal 5/5 and selected service 4/4 pass, broader real-OIDC writer tests blocked by absent pinned oauth4webapi@3.8.8; no install/commit/push/PR/queue move or overall-green claim. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index d8d009ade..d98c6e398 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -982,16 +982,16 @@ scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-relo scripts/webui-service.sh uninstall [--dir DIR] [--no-reload] scripts/webui-service.sh status [--dir DIR] # FLAGS: [--port N] [--business ID] [--board URL] -# [--public-host NAME --auth-header HEADER --allow-user ID [--send]] +# [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID] ``` The systemd user unit `mosaic-webui.service` runs the Console, `node packages/webui/src/cli.mjs serve`, with serve's flags as given: `--port` defaults to 7332 (7330 is the operator's own Console), and the -preview set goes all three or none. `--send` (row 83) goes only with the -preview set and is refused otherwise (exit 1, nothing written); ExecStart +preview set goes all three or none and stays read-only. `--send` (row 83) +requires `--sso` and is refused otherwise (exit 1, nothing written); ExecStart then ends with `--send`, and the Console needs the system config at start -(`--send needs the bus configured`, exit 2). Without `--send` the unit is +including `console.sso` (exit 2 if missing). Without `--send` the unit is read-only. The template is `packages/webui/systemd/mosaic-webui.service.in`. diff --git a/packages/webui/README.md b/packages/webui/README.md index 2f5b71d6b..ab3cb23ae 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -96,7 +96,8 @@ The local exposure doesn't change ("Data and boundaries"): any process on the host can still connect to the port and send `Host: NAME` with HEADER and ID, or just a loopback Host, and read what a reader capability reads. The user id is not a secret; the sign-in at the edge is what keeps others out. -Under `--send` the first of those can also send (next section). +Header preview is read-only. A configured proxy proof does not permit writes; +`--send` requires the Console's verified OpenID Connect `--sso` mode. To run the Console as a systemd user unit, `scripts/webui-service.sh` renders and installs one with these flags (row 75; `docs/TOOLS.md` @@ -104,7 +105,7 @@ renders and installs one with these flags (row 75; `docs/TOOLS.md` decision 102, which accepts that sends from a Console Mos started enter the trail as Jason. Any other agent starting it works around the human proof (lead decisions 62 and 94). Without `--send` the unit is read-only. The script takes `--send` (row 83) -only with the three preview flags; otherwise it exits 1 and writes nothing. +only with `--sso`; otherwise it exits 1 and writes nothing. With it, the unit's ExecStart ends with `--send`, and the Console needs the system config at start: without it serve refuses with `--send needs the bus configured` and exits 2, which the unit doesn't restart. `status` says @@ -147,7 +148,7 @@ and one or more providers, each with its issuer, client id, a client secret slot, the signing algorithms it accepts and the subjects allowed in). The design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send` -takes `--sso` in place of the preview set. +requires `--sso`. Header-preview mode cannot enable the human writer. - The client secret is read once at start from `/secrets/mosaic-stack/`. The start refuses a slot that is @@ -181,13 +182,12 @@ takes `--sso` in place of the preview set. ## Message a role (row 76, #1570) ```sh -node packages/webui/src/cli.mjs serve --port 7332 \ - --public-host NAME --auth-header HEADER --allow-user ID --send +node packages/webui/src/cli.mjs serve --port 7332 --sso --send ``` -`--send` is off by default. It goes only with the three preview flags and -refuses at start without them. It also needs the system config: without it -the start refuses with `--send needs the bus configured`, where a read-only +`--send` is off by default. It requires verified OIDC through `--sso` and +refuses header-preview configurations, including those with proxy proof. +It also needs the system config, including `console.sso`, where a read-only Console would still serve the board. A unit that sends comes from `scripts/webui-service.sh install` with `--send` (above). With `--send` the Inbox has a "Message a role" box: a role picker filled from the agents read, @@ -199,23 +199,16 @@ decision 95). Decisions are still answered in a terminal. There are no other message classes, no corrections and no seat conversations here. -What `--send` grants: two kinds of client can send a human instruction to -any role while it runs. - -- Anyone the proxy lets through as ID. -- Any local process that reaches the port and sets HEADER to ID with - `Host: NAME`. The user id is not a secret. This is the same class of - exposure as the human CLI (lead decision 62), accepted for the PoC by - lead decision 95. +With `--send`, the Console authenticates each user through its OIDC session +and uses that user's authorized transport. Caller-supplied identity headers +and a loopback connection do not authenticate a writer. The request is POST `/api/bus/message` with a JSON object `{to, body, in_reply_to?}`. Nothing is sent unless all of these hold, each checked on every request: -- Host is NAME and HEADER is ID, once: otherwise 401 `sign-in required`. - There is no loopback path. The operator's browser on `127.0.0.1` reads - but can't send, and a loopback Host with HEADER is 403 `proxy Host - refused`, as above. +- Host is the configured OIDC public Host and the request has a valid + signed-in session. There is no loopback operator path under `--sso`. - Origin is exactly `https://NAME`, else 403 `cross-origin request refused`. - `Sec-Fetch-Site` is sent once and is exactly `same-origin`, else 403 `cross-site request refused`. Browsers set it, and page script can't. @@ -996,8 +989,8 @@ threads and read/send messages. `POST /api/chat/threads/UUID/open` is the explic **Resume assistant** action. Reading a thread never opens it or acknowledges a message. Sending uses `thread.send` with class REQUEST, as the human CLI does. -All four POST operations need `--send`, the named preview Host, the exact -single authenticated user header, the Console's HTTPS Origin, same-origin Fetch +All four POST operations need `--sso --send`, the configured public Host, a +valid OIDC session, the Console's HTTPS Origin, same-origin Fetch Metadata and a strict JSON object within 16 KiB. The broker binds the business human and enforces ownership. A same-business thread owned by someone else returns 403 `thread-not-owned`; a missing/foreign thread returns 404. No browser diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index cd4e1648c..a37bc4a4d 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -18,7 +18,7 @@ import { statSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; const args = process.argv.slice(2); -const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]'; +const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]'; try { if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } if (args.shift() !== 'serve') throw new Error(usage); @@ -60,8 +60,8 @@ try { } if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*'); options.preview = previewOptions(preview); + if (sendOn && !ssoOn) throw new Error('--send needs --sso; header preview is read-only'); const proof = proofOptions(proofFlags, options.preview); - if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso'); // The bus views (slice 1 S5) need the system config. Without it the // board still serves and the bus routes answer not-configured. // The Queue, Business and Settings reads (row 54) need no bus; without @@ -127,7 +127,7 @@ try { }); } catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; } } - const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : ''; + const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : ''; 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() ?? {}; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 1ff5e4ed5..a41af6067 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -187,14 +187,14 @@ async function busRead(res, bus, verb, search) { return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code }); } } -// Row 76 (#1570, lead decision 95): with --send, the one POST preview mode allows, a human -// message to a role. Checked here again whatever passed above: the header on this request, its own +// Row 76 (#1570, lead decision 95): with OIDC --sso --send, a human message to a role. +// Checked here again whatever passed above: the session on this request, its own // Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB, and only these keys. `to` and the body are the // broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown. const SEND_KEYS = ['to', 'body', 'in_reply_to']; const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); // Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked, -// so it carries `sso: true` and the public origin; the preview writer still checks its header. +// so it carries `sso: true` and the public origin. The header-preview signer is read-only. const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user; const ownOrigin = p => p.origin ?? `https://${p.host}`; async function busSend(req, res, { send, preview, log }) { @@ -648,7 +648,7 @@ async function libraryStatusRead(res, read, search) { return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code }); } } -// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`. +// `send` is the bus write (row 76): `args => Promise<{id}>`, only with `sso`. // `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself. // `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. @@ -678,7 +678,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); } if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode'); - if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso'); + if (send !== null && (sso === null || typeof send !== 'function')) throw new Error('--send needs --sso; header preview is read-only'); const server = createServer(async (req, res) => { // A granted user's request rebinds these below; every other request keeps the owner's. let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, upstream, files } = owner, granted = false, chatGrants = []; diff --git a/packages/webui/tests/agents.test.mjs b/packages/webui/tests/agents.test.mjs index 0130eb7b5..dab91a445 100644 --- a/packages/webui/tests/agents.test.mjs +++ b/packages/webui/tests/agents.test.mjs @@ -13,9 +13,8 @@ import { startServer } from '../src/serve.mjs'; import { startTesterSocket } from '../src/tester-socket.mjs'; import { testerRequest } from '../../cli/src/tester-http.mjs'; import { BusReadError } from '../src/bus.mjs'; +import { createSsoSession } from './helpers/sso-session.mjs'; -const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; -const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; const REV = 'a'.repeat(64); const ID = '0b5e8f1c-3a2d-4c6e-9f10-1234567890ab'; const read = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false, kept: false } }, definitions: {}, noHire: [], retired: [], ...extra }); @@ -42,9 +41,11 @@ async function setup(t, { on = true, agent = async (verb, args) => ANSWERS[verb] t.after(() => new Promise(r => server.close(r))); return { calls, lines, dataRoot }; } - const server = await startServer({ port: 0, preview, send: on ? async () => ({}) : null, ...options }); + const auth = await createSsoSession(t, { host: 'console.example' }); + const server = await startServer({ port: 0, sso: auth.sso, send: on ? async () => ({}) : null, ...options }); t.after(() => new Promise(r => server.close(r))); - return { calls, lines, port: server.address().port, headers }; + const session = await auth.signIn(server.address().port); + return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) }; } const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => { @@ -84,20 +85,20 @@ test('row 194 T16: each agent action makes one call with only its keys, logs the test('row 194 T16: the boundary refuses before any bus call', async t => { const s = await setup(t); const cases = [ - ['/api/agent/launch', { instance: 'coder' }, { ...headers, 'x-user': 'mallory' }, 401], - ['/api/agent/launch', { instance: 'coder' }, { ...headers, origin: 'https://evil.example' }, 403], - ['/api/agent/launch', { instance: 'coder' }, { ...headers, 'sec-fetch-site': 'cross-site' }, 403], - ['/api/agent/launch', { instance: 'coder', extra: 'x' }, headers, 400], - ['/api/agent/launch', {}, headers, 400], - ['/api/agent/launch', { instance: 7 }, headers, 400], - ['/api/agent/launch', ['coder'], headers, 400], - ['/api/agent/launch', 'not json', headers, 400], - ['/api/agent/launch', { instance: 'x'.repeat(17000) }, headers, 400], - ['/api/agent/terminate', { instance: 'coder', reason: 3 }, headers, 400], - ['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, headers, 400], - ['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, headers, 400], - ['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, headers, 400], - ['/api/veto', { op: 'keep', name: 'coder' }, headers, 400], + ['/api/agent/launch', { instance: 'coder' }, { ...s.headers, cookie: '__Host-mosaic_session=invalid' }, 401], + ['/api/agent/launch', { instance: 'coder' }, { ...s.headers, origin: 'https://evil.example' }, 403], + ['/api/agent/launch', { instance: 'coder' }, { ...s.headers, 'sec-fetch-site': 'cross-site' }, 403], + ['/api/agent/launch', { instance: 'coder', extra: 'x' }, s.headers, 400], + ['/api/agent/launch', {}, s.headers, 400], + ['/api/agent/launch', { instance: 7 }, s.headers, 400], + ['/api/agent/launch', ['coder'], s.headers, 400], + ['/api/agent/launch', 'not json', s.headers, 400], + ['/api/agent/launch', { instance: 'x'.repeat(17000) }, s.headers, 400], + ['/api/agent/terminate', { instance: 'coder', reason: 3 }, s.headers, 400], + ['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, s.headers, 400], + ['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, s.headers, 400], + ['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, s.headers, 400], + ['/api/veto', { op: 'keep', name: 'coder' }, s.headers, 400], ]; for (const [path, body, hs, status] of cases) { const r = await send(s, path, body, hs); @@ -213,11 +214,11 @@ test('row 194 T16 D4.1: the tester listener launches, terminates and reads reque test('row 194 N5: agents.js is served as JavaScript and index.html loads it once', async t => { const s = await setup(t); - const js = await get(s, '/agents.js', { host: 'console.example', 'x-user': 'jason' }); + const js = await get(s, '/agents.js'); assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/); assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/); assert.match(js.text, /Agent actions are unavailable here\. Use mosaic business instance from the instance CLI\./); - const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' }); + const html = await get(s, '/'); assert.equal(html.text.match(/