diff --git a/agents/dewey/work/queue-74/build.md b/agents/dewey/work/queue-74/build.md new file mode 100644 index 000000000..327d1f5b8 --- /dev/null +++ b/agents/dewey/work/queue-74/build.md @@ -0,0 +1,186 @@ +# Row 74 (#1568): WebUI preview behind a sign-in proxy, round 1 + +Owner: Dewey. Reviewers: Darkwing and Filbert. Brief: +`docs/plans/2026-10-11_webui-preview.md`, blob `1fe76f32` (rev 487; the +change from `59fd510f` is the out-of-scope line saying Jason starts the +preview, and nothing in the code depends on it). Base `bc8e1ee7`. None of +the four files changed between the base and `refactor` HEAD when the +packet was built. + +`build.patch` is `git diff --binary bc8e1ee7 -- packages/webui`. Apply it +with `git apply --index` to a detached worktree of the base; +`candidate-manifest.sha256` then checks 4/4 OK. + +## What ships + +`serve --public-host NAME --auth-header HEADER --allow-user ID`, all three +or none. Off by default, and without the flags the Console behaves as +before (`serve.test.mjs` unchanged and green, plus a test that NAME is +refused and a POST still reaches the board). + +- `previewOptions` (serve.mjs) validates and lowercases NAME and HEADER. + NAME is a DNS name with at least one dot: no scheme, port, path or IP + literal, and not `localhost`. HEADER is a header name, 1 to 64 of + `[a-z0-9-]`, not `host` or `origin`. ID is 1 to 256 visible ASCII + characters without a comma. A partial set throws. `startServer` + revalidates its `preview` option the same way, so a direct caller can't + skip the check, and `preview: {}` refuses instead of serving without it. +- The CLI collects the three flags and calls `previewOptions` before any + config is read, so a partial or bad set exits 2 with `refused: …` and + prints nothing on stdout. With all three it prints a `Preview:` line. +- A request is a NAME request when its raw Host, lowercased, equals NAME + exactly. Anything else goes through the existing loopback Host check + unchanged, so `NAME:443` and `NAME.evil.example` get the existing 403. +- The Origin, when sent, must be `https://NAME` for a NAME request and the + existing `http://` for a loopback one. +- A NAME request must carry HEADER exactly once + (`req.headersDistinct`), with a value equal to ID. Otherwise 401 + `{error: 'sign-in required'}`, before routing, so no bus read or board + call is made. Node lowercases header names and trims surrounding + whitespace from values; the compare is otherwise exact. +- In preview mode every method other than GET and HEAD answers 403 + `{error: 'read-only', message: 'the Console preview is read-only'}`, + whatever the Host, after the sign-in check. `/api/seen` and `/api/reply` + never reach the board. +- Loopback requests need no header and read as before. The GET routes are + unchanged. + +Order of checks for a NAME request: Host, Origin, header (401), method +(403). An unsigned write gets 401, not 403, so nothing about the routes is +answered before sign-in. + +## Decisions and tradeoffs + +1. **Every non-GET/HEAD method gets 403, not only POST.** The brief says + "every POST". The Console's only write routes are POST today, and every + other non-GET/HEAD method already gets 404 or 405 on the base, so the + superset changes no allowed request. It + means a write route added later is refused in preview mode until + someone opens it on purpose (row 76 does, under `--send`). +2. **A port on NAME is refused.** The README tells the proxy to preserve + `Host: NAME`. A proxy that sends `NAME:443` fails closed with a 403 + rather than being matched loosely. If ops-01's proxy adds the port, + the fix is in the proxy config, not here. +3. **Repeated header is 401.** Two values could come from a client copy + the proxy failed to strip plus the proxy's own. Refusing is the safe + reading. A comma is refused in ID for the same reason: a proxy that + joins values with a comma would make `ID,ID` look like one value. +4. **HEAD is allowed** with GET. It reads nothing GET doesn't. +5. **Provider-neutral.** No identity provider is named in code, tests or + README. The tests use `remote-user`/`jason` and, for the first + deployment's shape, `X-authentik-uid` with a 64-hex stand-in id. The + README example uses `X-Example-Uid`. + +## Where the brief and the code differ (for Sage) + +- The superset in decision 1. +- With row 60 landed, `/api/bus/credentials` is also readable through the + preview by the signed-in user: credential states and dates, no tokens. + `/api/bus/decisions` is readable today. Both are GET routes that "stay as + they are"; I mention them because the brief lists inbox, tasks, agents + and trails as the minimum. +- Overlap with row 60: both change `packages/webui/src/serve.mjs` (row 60 + only the `BUS_VERBS` line) and `packages/webui/README.md`. The two + patches apply on top of each other in either order without conflict, but + whichever lands second has different digests for those files, so its + manifest needs a new round. +- A refused Reply in the page shows `read-only Delivery may be unknown; + check the seat before sending again.` The second sentence is wrong for a + 403. It comes from `src/public/app.js`, which isn't in this row's files, + and row 76 changes that path. +- The brief's line references (`serve.mjs` 82, 92, 96) match the base. + +## Files + +| File | Change | +|---|---| +| `packages/webui/src/serve.mjs` | `previewOptions`, the `preview` option, the NAME, Origin, header and read-only checks | +| `packages/webui/src/cli.mjs` | the three flags, checked before config; the `Preview:` line | +| `packages/webui/README.md` | the preview section, the proxy contract, the local exposure; two lines of the old "do not expose" text replaced | +| `packages/webui/tests/preview.test.mjs` | new, 9 tests | + +The README's bus-reads exposure paragraph is untouched, to keep the +overlap with row 60 small. + +## Tests (`preview.test.mjs`) + +1. Options are all three or none, and each is checked (hosts, headers, + users; `startServer` with a partial set or `{}`). +2. The CLI refuses a partial or invalid flag set at start: exit 2, + `refused:`, empty stdout. +3. The CLI with all three flags serves preview mode: the `Preview:` line, + 401 unsigned, 200 signed in, 403 for a loopback POST. +4. 401 with nothing read: no header, another user, a suffix, a different + header, two values, across a bus read, a trail, the board and `/`. +5. Header name case and exact value: `X-authentik-uid` on the flag with a + 64-hex id; lower, mixed and upper names pass; an uppercased value, a + truncated value, a suffix, or `ID,ID` gets 401. +6. Signed-in reads of inbox, tasks, agents, a trail, the board and `/`; + NAME in mixed case; wrong Host, `NAME:443` and `NAME.evil.example` get + 403; wrong Origins get 403. +7. Every write gets 403 read-only from NAME and from loopback, with and + without Origin, for both write routes, a bus route and `/`; PUT, + DELETE, PATCH and OPTIONS too; an unsigned write gets 401; nothing + reaches the board or bus. +8. Loopback is unchanged in preview mode, and with the board down its + route answers the existing 502 while the bus views answer. +9. Without the flags NAME is refused and a POST still reaches the board. + +## Mutants + +22 single-site mutants in a separate worktree, run against +`preview.test.mjs` and `serve.test.mjs` (`mutants.py`, output in +`mutants.txt`). **22 of 22 +killed**; the worktree was clean after the run. + +Partial set allowed; NAME not lowercased; HEADER not lowercased; IP +allowed; `host` allowed as HEADER; comma allowed in ID; `preview: {}` +served; NAME compared case-sensitively; NAME as a prefix; NAME with any +port; `http://NAME` Origin allowed; `https://NAME` Origin allowed on +loopback; header check skipped; any of several values accepted; value as +a prefix; value compared without case; header looked up by the raw flag; +read-only only for NAME; read-only only for POST; read-only without the +flags; CLI drops the option; CLI never validates. + +The first run found two faults in the tests, not the code, both fixed +before the gate. `setup()` registered the fake board's cleanup after +`startServer`, so a mutant that made `startServer` throw left the board +open and the run hung instead of failing. And test 1 left open any server +a mutant let start. Both now close what they open, and the mutants that +had hung fail in about a second. Test 3 was added because no test started +the CLI with all three flags, so a CLI that dropped the option would have +passed. + +## Demonstration + +`demo.sh` starts the CLI in preview mode on loopback port 0 with no system +config and a closed board port, then drives it with `curl`, showing each +refusal and the allowed reads. `demo.txt` is its output, run on the gate +worktree, with the server's start lines. Nothing leaves the host. With no +bus configured the inbox read answers 503 `not-configured`, which shows it +got past the sign-in check; tests 6 and 8 show the bus reads themselves. + +## Gate (my run on the candidate) + +All suites ran one after another in a detached worktree of `bc8e1ee7` with +`build.patch` applied, `TMPDIR` in my scratch and Docker on, from +2026-10-11T22:26:06Z to 22:35:49Z. The manifest checked 4/4 OK before the +run, and `core.hooksPath` stayed unset. + +| Suite | Result | +|---|---| +| `packages/webui` | 49 pass, 0 fail (9 new), the browser tests included | +| `packages/control-board` | 124 pass, 0 fail | +| `packages/cli` | 112 pass, 0 fail | +| bus, business, conversation, discord, harness, ledger, mosaic, queue, runs, seat, tasks | all 0 fail (80, 60, 182, 178, 57, 78, 69, 150, 41, 27, 71 pass) | +| build-tokens `--check` | exit 0 | +| test-auth, test-conductor, test-config, test-discord, test-extension-package, test-foundation, test-queue | exit 0 (15, 17, 24, 66, 18, 44, 27 passed) | +| test-release | 14 passed, 0 failed (Docker, nothing skipped) | +| test-task | 98 passed, 0 failed (Docker, nothing skipped) | + +## Questions for reviewers + +1. Is refusing `NAME:443` (decision 2) right, or should the default port be + accepted in case the proxy adds it? +2. Anything in the README's proxy contract that ops-01 would need and + doesn't have? diff --git a/agents/dewey/work/queue-74/build.patch b/agents/dewey/work/queue-74/build.patch new file mode 100644 index 000000000..b059e00b7 --- /dev/null +++ b/agents/dewey/work/queue-74/build.patch @@ -0,0 +1,392 @@ +diff --git a/packages/webui/README.md b/packages/webui/README.md +index e43118d5..ff277b76 100644 +--- a/packages/webui/README.md ++++ b/packages/webui/README.md +@@ -16,12 +16,63 @@ Open http://127.0.0.1:7330/. Optional `--port N` and + `--business ID` picks the business the bus views read; without it they read + the running bus host's business (`mosaic bus start `). + Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon, +-installation, account, authentication or deployment is added. ++installation, account, login or deployment is added. The preview mode below ++checks one header a sign-in proxy sets; it signs nobody in itself. + + Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback + by default, rejects nonlocal Host and cross-origin requests, sends no CORS + headers, refuses redirects and accepts only JSON object POST bodies up to 4096 +-bytes. Do not expose either unauthenticated server through a public proxy. ++bytes. Do not put the board behind a public proxy. Put the Console behind one ++only in the preview mode below, with a proxy that meets its contract. ++ ++## Preview behind a sign-in proxy (row 74, #1568) ++ ++```sh ++node packages/webui/src/cli.mjs serve --port 7332 \ ++ --public-host NAME --auth-header HEADER --allow-user ID ++``` ++ ++The three flags go together; one or two refuse at start, before any config ++is read. NAME is a DNS name, no scheme or port. HEADER is a header name other ++than Host or Origin; Node lowercases incoming header names, so it matches ++without regard to case (`X-Example-Uid` on the flag finds `x-example-uid`). ++ID is the one user id, 1 to 256 visible ASCII characters without a comma, ++and the value compare is exact. The Console still binds to loopback only. It ++names no identity provider and has no login code: the proxy signs the user ++in and limits sign-in to the right group. ++ ++In preview mode: ++ ++- A request whose Host is exactly NAME must carry HEADER once, with a value ++ equal to ID. Missing, different or repeated answers 401 `sign-in required` ++ before any route runs, so no bus read or board call is made. An Origin, ++ when sent, must be `https://NAME`. ++- A request whose Host is loopback on the Console's port is checked as ++ before and needs no HEADER, so the operator's browser on the host still ++ works. Any other Host is 403. ++- Every method other than GET and HEAD answers 403 `read-only`, whatever the ++ Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply ++ fail in the page with that refusal. ++- The GET routes are unchanged: the bus views, the Queue, Business and ++ Settings reads, the board panel and the assets. If the board is down, its ++ panel shows the existing error and the bus views still answer. ++ ++What the proxy must do. The Console trusts HEADER only because the proxy ++controls it: ++ ++- It terminates TLS for NAME and is the only thing that reaches the port ++ from off the host. ++- It strips HEADER from every client request and sets it only after the ++ identity provider signs the user in. ++- It forwards only requests whose Host is NAME, and preserves that Host. A ++ loopback Host is the operator's path and skips the header check, so a ++ proxy that forwarded a client's `Host: 127.0.0.1:7332` would let anyone ++ through without signing in. ++ ++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. + + ## Use + +diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs +index 64b3e7b4..5c6a5931 100644 +--- a/packages/webui/src/cli.mjs ++++ b/packages/webui/src/cli.mjs +@@ -1,16 +1,16 @@ + #!/usr/bin/env node +-import { startServer, DEFAULT_BOARD } from './serve.mjs'; ++import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs'; + import { busReader } from './bus.mjs'; + import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs'; + import { configDir } from '../../business/src/business.mjs'; + import { consoleReads, redactor } from './reads.mjs'; + 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] [--board http://127.0.0.1:7331] [--business ID]'; ++const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID]'; + try { + if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } + if (args.shift() !== 'serve') throw new Error(usage); +- const options = { board: DEFAULT_BOARD, port: 7330 }; ++ const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}; + while (args.length) { + const flag = args.shift(), value = args.shift(); + if (!value) throw new Error(`missing value for ${flag}`); +@@ -22,8 +22,13 @@ try { + if (!ID_PATTERN.test(value)) throw new Error('business must be a business id'); + options.business = value; + } ++ // Row 74 (#1568): the preview mode, all three or none, checked before any config is read. ++ else if (flag === '--public-host') preview.publicHost = value; ++ else if (flag === '--auth-header') preview.authHeader = value; ++ else if (flag === '--allow-user') preview.allowUser = value; + else throw new Error(`unknown option: ${flag}`); + } ++ options.preview = previewOptions(preview); + // 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 +@@ -37,8 +42,9 @@ try { + busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`; + } catch (err) { busLine = `Bus: not configured (${redactor({ configDir: configDir() })(err.message)})`; } + const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); +- const server = await startServer({ board: options.board, port: options.port, bus, reads }); +- console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}\nCtrl-C stops this server.`); ++ const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview }); ++ const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : ''; ++ console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${previewLine}\nCtrl-C stops this server.`); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close()); + } catch (err) { + console.error(`refused: ${err.message}`); +diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs +index 4d51942a..34c4aca4 100644 +--- a/packages/webui/src/serve.mjs ++++ b/packages/webui/src/serve.mjs +@@ -78,22 +78,54 @@ async function consoleRead(res, read) { + return json(res, readStatus(code), { error: code, message: err instanceof ReadError ? err.message : code }); + } + } +-export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null } = {}) { ++// Row 74 (#1568, lead decision 93): the preview mode behind the edge proxy. Off unless ++// all three are given. The bind stays loopback; the proxy signs the user in ++// and sets the header; the Console checks it against the one user id and serves reads only. ++// Nothing here names an identity provider: one header name and one user id, from the flags. ++const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; ++const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/; ++const USER = /^[\x21-\x2b\x2d-\x7e]{1,256}$/; ++export function previewOptions({ publicHost, authHeader, allowUser } = {}) { ++ const given = [publicHost, authHeader, allowUser].filter(v => v !== undefined && v !== null); ++ if (!given.length) return null; ++ if (given.length !== 3) throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); ++ const host = String(publicHost).toLowerCase(), header = String(authHeader).toLowerCase(); ++ if (!HOSTNAME.test(host) || isIP(host)) throw new Error('public host must be a DNS name without scheme or port'); ++ if (!HEADER.test(header) || ['host', 'origin'].includes(header)) throw new Error('auth header must be a header name other than Host or Origin'); ++ if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma'); ++ return { host, header, user: allowUser }; ++} ++export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null } = {}) { + if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); + if (host === 'localhost') host = '127.0.0.1'; + const upstream = boardURL(board); ++ // An object with none of the three is refused too: asking for the preview never serves without it. ++ if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { ++ throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); ++ } + const server = createServer(async (req, res) => { + res.setHeader('x-content-type-options', 'nosniff'); + res.setHeader('referrer-policy', 'no-referrer'); + res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); + let path, search; ++ // Preview mode: a request for the public name, exactly, as the proxy forwards it. ++ const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host; + try { +- const authority = new URL(`http://${req.headers.host}`); +- if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' }); ++ if (!named) { ++ const authority = new URL(`http://${req.headers.host}`); ++ if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' }); ++ } + ({ pathname: path, search } = new URL(req.url, 'http://localhost')); + } catch { return json(res, 400, { error: 'invalid URL' }); } + // No CORS. JSON content type and same-origin checks keep browser forms out. +- if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' }); ++ if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' }); ++ if (named) { ++ // One value, equal to the user id; the proxy strips client copies and sets it after sign-in. ++ const values = req.headersDistinct[preview.header]; ++ if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' }); ++ } ++ // Nothing but GET and HEAD gets past here in preview mode, whatever the Host. ++ if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' }); + // The conversation routes (#1507, CHAT-02) keep their query; the board validates it. + const conversation = path === '/api/conversations' || path === '/api/conversation'; + const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null; +diff --git a/packages/webui/tests/preview.test.mjs b/packages/webui/tests/preview.test.mjs +new file mode 100644 +index 00000000..97abba89 +--- /dev/null ++++ b/packages/webui/tests/preview.test.mjs +@@ -0,0 +1,203 @@ ++// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the ++// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing ++// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. ++import { test } from 'node:test'; ++import assert from 'node:assert/strict'; ++import { createServer, request } from 'node:http'; ++import { spawn, spawnSync } from 'node:child_process'; ++import { fileURLToPath } from 'node:url'; ++import { startServer, previewOptions } from '../src/serve.mjs'; ++import { close } from './fixture.mjs'; ++ ++const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason'; ++const preview = { host: NAME, header: HEADER, user: ID }; ++const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); ++ ++// node:http, not fetch: fetch can't set Host. The length is explicit because node sends a ++// DELETE or OPTIONS body without one, which breaks the next request on a kept-alive socket. ++function send(port, { method = 'GET', path = '/api/bus/inbox', headers = {}, body } = {}) { ++ 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: NAME, ...length, ...headers } }, res => { ++ let text = ''; ++ res.on('data', c => (text += c)); ++ res.on('end', () => resolve({ status: res.statusCode, body: (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text })); ++ }); ++ req.on('error', reject); ++ req.end(body); ++ }); ++} ++const signedIn = { [HEADER]: ID }; ++const json = { 'content-type': 'application/json' }; ++ ++// A board that counts what reaches it, and a bus that counts its reads. ++async function setup(t, options = {}) { ++ const boardHits = []; ++ const upstream = createServer((req, res) => { ++ boardHits.push(`${req.method} ${req.url}`); ++ res.writeHead(200, json); ++ res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}'); ++ }); ++ await new Promise(r => upstream.listen(0, '127.0.0.1', r)); ++ // Closed first and on its own, so a refused startServer doesn't leave it holding the process open. ++ t.after(() => close(upstream)); ++ const busCalls = []; ++ const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }])); ++ const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options }); ++ t.after(() => close(web)); ++ return { port: web.address().port, boardHits, busCalls }; ++} ++ ++// startServer must refuse; a server it starts anyway is closed so the failure can't hang the run. ++async function refused(options) { ++ const result = await startServer({ port: 0, preview: options }).catch(err => err); ++ if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${JSON.stringify(options)}`); } ++ assert.match(result.message, /together/); ++} ++ ++test('preview options are all three or none, and each is checked', async () => { ++ assert.equal(previewOptions({}), null); ++ assert.deepEqual(previewOptions({ publicHost: 'Mosaic-Preview.Example.com', authHeader: 'Remote-User', allowUser: ID }), preview); ++ for (const partial of [{ publicHost: NAME }, { authHeader: HEADER }, { allowUser: ID }, { publicHost: NAME, authHeader: HEADER }, { publicHost: NAME, allowUser: ID }, { authHeader: HEADER, allowUser: ID }]) { ++ assert.throws(() => previewOptions(partial), /all|together/, JSON.stringify(partial)); ++ await refused({ host: partial.publicHost, header: partial.authHeader, user: partial.allowUser }); ++ } ++ await refused({}); ++ for (const host of ['https://' + NAME, NAME + ':443', NAME + '/', 'localhost', '127.0.0.1', '::1', 'preview', '', 'a..b.com', '-a.example.com']) ++ assert.throws(() => previewOptions({ publicHost: host, authHeader: HEADER, allowUser: ID }), /public host/, host); ++ for (const header of ['host', 'Origin', 'remote user', 'x:y', '', 'a'.repeat(65)]) ++ assert.throws(() => previewOptions({ publicHost: NAME, authHeader: header, allowUser: ID }), /auth header/, header); ++ for (const user of ['', 'jason woltje', 'a,b', 'é', 'a'.repeat(257)]) ++ assert.throws(() => previewOptions({ publicHost: NAME, authHeader: HEADER, allowUser: user }), /allowed user/, user); ++}); ++ ++test('the CLI refuses a partial or invalid preview flag set at start', () => { ++ const sets = [ ++ ['--public-host', NAME], ['--auth-header', HEADER], ['--allow-user', ID], ++ ['--public-host', NAME, '--auth-header', HEADER], ['--public-host', NAME, '--allow-user', ID], ['--auth-header', HEADER, '--allow-user', ID], ++ ['--public-host', NAME + ':443', '--auth-header', HEADER, '--allow-user', ID], ++ ]; ++ for (const flags of sets) { ++ const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000 }); ++ assert.equal(result.status, 2, flags.join(' ')); ++ assert.match(result.stderr, /^refused: (preview mode needs|public host)/, flags.join(' ')); ++ assert.equal(result.stdout, ''); ++ } ++}); ++ ++test('the CLI with all three flags serves the preview mode', async t => { ++ // No system config, so the bus routes answer not-configured; the gate runs before them. ++ const env = { ...process.env, MOSAIC_CONFIG: fileURLToPath(new URL('./no-such-config.json', import.meta.url)) }; ++ const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] }); ++ t.after(() => child.kill()); ++ let out = ''; ++ const port = await new Promise((resolve, reject) => { ++ child.stdout.on('data', c => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); }); ++ child.on('exit', code => reject(new Error(`exited ${code}: ${out}`))); ++ }); ++ assert.match(out, new RegExp(`^Preview: https://${NAME}/ through the proxy, signed-in user from remote-user, read-only$`, 'm')); ++ assert.deepEqual((await send(port, {})).body, { error: 'sign-in required' }); ++ assert.equal((await send(port, { path: '/healthz', headers: signedIn })).status, 200); ++ assert.equal((await send(port, { method: 'POST', path: '/api/seen', headers: { host: `127.0.0.1:${port}`, ...json }, body: '{}' })).status, 403); ++}); ++ ++test('a request for the public name needs the header equal to the user id, else 401 and nothing read', async t => { ++ const { port, boardHits, busCalls } = await setup(t); ++ for (const [why, headers] of [ ++ ['no header', {}], ++ ['another user', { [HEADER]: 'mallory' }], ++ ['the id with a suffix', { [HEADER]: ID + 'x' }], ++ ['a different header', { 'x-remote-user': ID }], ++ ['two values', { [HEADER]: [ID, ID] }], ++ ]) { ++ for (const path of ['/api/bus/inbox', '/api/bus/trail?subject=row-1', '/api/board', '/']) { ++ const r = await send(port, { path, headers }); ++ assert.equal(r.status, 401, `${why} ${path}`); ++ assert.deepEqual(r.body, { error: 'sign-in required' }); ++ } ++ } ++ assert.deepEqual(busCalls, []); ++ assert.deepEqual(boardHits, []); ++}); ++ ++test('the header name matches without regard to case, and the value compare is exact', async t => { ++ // The shape of the first deployment: a mixed-case header flag and a 64-hex user id. ++ const uid = 'a3f1'.repeat(16); ++ const { port, busCalls } = await setup(t, { preview: { host: NAME, header: 'X-authentik-uid', user: uid } }); ++ for (const name of ['x-authentik-uid', 'X-authentik-uid', 'X-AUTHENTIK-UID']) ++ assert.equal((await send(port, { headers: { [name]: uid } })).status, 200, name); ++ for (const value of [uid.toUpperCase(), uid.slice(1), ` ${uid}x`, uid + ',' + uid]) ++ assert.equal((await send(port, { headers: { 'x-authentik-uid': value } })).status, 401, value); ++ assert.equal(busCalls.length, 3); ++}); ++ ++test('the public name with the header reads the bus views, and the Host and Origin are checked first', async t => { ++ const { port, boardHits, busCalls } = await setup(t); ++ for (const verb of ['inbox', 'tasks', 'agents']) { ++ const r = await send(port, { path: `/api/bus/${verb}`, headers: signedIn }); ++ assert.equal(r.status, 200, verb); assert.deepEqual(r.body.rows, [{ verb }]); ++ } ++ const trail = await send(port, { path: '/api/bus/trail?subject=row-1', headers: { ...signedIn, origin: `https://${NAME}` } }); ++ assert.equal(trail.status, 200); ++ assert.equal((await send(port, { path: '/api/board', headers: signedIn })).status, 200); ++ // A hostname compares without regard to case. ++ assert.equal((await send(port, { path: '/healthz', headers: { ...signedIn, host: 'Mosaic-Preview.Example.COM' } })).status, 200); ++ assert.equal((await send(port, { path: '/', headers: { 'Remote-User': ID } })).status, 200); ++ assert.deepEqual(busCalls, [['inbox', undefined], ['tasks', undefined], ['agents', undefined], ['trail', 'row-1']]); ++ assert.deepEqual(boardHits, ['GET /api/board']); ++ // Wrong Host, the public name with a port, and the wrong Origin refuse even with the header. ++ for (const headers of [{ host: 'evil.example' }, { host: NAME + ':443' }, { host: `${NAME}.evil.example` }]) { ++ const r = await send(port, { headers: { ...signedIn, ...headers } }); ++ assert.equal(r.status, 403, headers.host); assert.deepEqual(r.body, { error: 'non-local Host refused' }); ++ } ++ for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, `http://127.0.0.1:${port}`]) { ++ const r = await send(port, { headers: { ...signedIn, origin } }); ++ assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }); ++ } ++ assert.equal(busCalls.length, 4); ++}); ++ ++test('in preview mode every write answers 403 read-only, from the public name and loopback, and reaches nothing', async t => { ++ const { port, boardHits, busCalls } = await setup(t); ++ const loopback = { host: `127.0.0.1:${port}` }; ++ const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' }); ++ for (const headers of [signedIn, loopback, { ...loopback, origin: `http://127.0.0.1:${port}` }]) { ++ for (const [path, body] of [['/api/seen', seen], ['/api/reply', reply], ['/api/bus/inbox', '{}'], ['/', '{}']]) { ++ const r = await send(port, { method: 'POST', path, headers: { ...headers, ...json }, body }); ++ assert.equal(r.status, 403, `${headers.host ?? NAME} POST ${path}`); ++ assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }); ++ } ++ for (const method of ['PUT', 'DELETE', 'PATCH', 'OPTIONS']) ++ assert.equal((await send(port, { method, path: '/api/seen', headers: { ...headers, ...json }, body: seen })).status, 403, method); ++ } ++ // A write without the header is still a sign-in refusal first. ++ assert.equal((await send(port, { method: 'POST', path: '/api/reply', headers: json, body: reply })).status, 401); ++ assert.deepEqual(boardHits, []); ++ assert.deepEqual(busCalls, []); ++}); ++ ++test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => { ++ const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r)); ++ const dead = `http://127.0.0.1:${probe.address().port}`; await close(probe); ++ const { port, busCalls } = await setup(t, { board: dead, timeout: 200 }); ++ const loopback = { host: `127.0.0.1:${port}` }; ++ const inbox = await send(port, { headers: loopback }); ++ assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]); ++ assert.equal((await send(port, { path: '/healthz', headers: { host: `localhost:${port}` } })).status, 200); ++ assert.equal((await send(port, { headers: { ...loopback, origin: `https://${NAME}` } })).status, 403); ++ assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403); ++ // The board panel's existing error; the bus views still answer. ++ const board = await send(port, { path: '/api/board', headers: signedIn }); ++ assert.equal(board.status, 502); assert.equal(board.body.board, dead); ++ assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200); ++ assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']); ++}); ++ ++test('without the preview flags the public name is refused and writes still reach the board', async t => { ++ const { port, boardHits } = await setup(t, { preview: null }); ++ const r = await send(port, { headers: signedIn }); ++ assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'non-local Host refused' }); ++ const seen = await send(port, { method: 'POST', path: '/api/seen', headers: { host: `127.0.0.1:${port}`, ...json }, body: '{"seen":true}' }); ++ assert.equal(seen.status, 200); ++ assert.deepEqual(boardHits, ['POST /api/seen']); ++}); diff --git a/agents/dewey/work/queue-74/candidate-manifest.sha256 b/agents/dewey/work/queue-74/candidate-manifest.sha256 new file mode 100644 index 000000000..e4f78f607 --- /dev/null +++ b/agents/dewey/work/queue-74/candidate-manifest.sha256 @@ -0,0 +1,4 @@ +35fb02a5bcb0625a40f7aaf80e588c7acf7de1ac4ef7986c492bb9d097c0d5e4 packages/webui/README.md +e0970dfdb3c930b622ec40d5fb60ca3ca0d95f05f604f561a1a97b1192a84d2a packages/webui/src/cli.mjs +fe27eaadfee4b2c1df89304f4b562420634fec948648e36d7ef3677e4d64e24f packages/webui/src/serve.mjs +50db4c28ff6c4f299bb03cfe194404843beb0fb8166ee63929d28833833680d7 packages/webui/tests/preview.test.mjs diff --git a/agents/dewey/work/queue-74/demo.sh b/agents/dewey/work/queue-74/demo.sh new file mode 100644 index 000000000..971f0a0ef --- /dev/null +++ b/agents/dewey/work/queue-74/demo.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Row 74 (#1568): the Console in preview mode, driven with curl on loopback only. +# Run from a worktree with the candidate applied: bash demo.sh . +# No system config (MOSAIC_CONFIG names a missing file), so the bus routes answer +# not-configured once past the sign-in check; the board flag names a closed port. +set -u +TREE=$1 SCRATCH=$2 +NAME=mosaic-preview.woltje.com HEADER=X-authentik-uid +ID=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef # a stand-in; ops-01 sends the real id +LOG=$SCRATCH/demo-server.txt +cd "$TREE" || exit 1 +echo "== partial flag set, refused at start" +MOSAIC_CONFIG=$SCRATCH/no-config.json node packages/webui/src/cli.mjs serve --port 0 --public-host "$NAME" --auth-header "$HEADER"; echo "exit $?" +MOSAIC_CONFIG=$SCRATCH/no-config.json node packages/webui/src/cli.mjs serve --port 0 \ + --board http://127.0.0.1:9 --public-host "$NAME" --auth-header "$HEADER" --allow-user "$ID" > "$LOG" 2>&1 & +PID=$! +trap 'kill "$PID" 2>/dev/null' EXIT +for _ in $(seq 50); do grep -q '^Mosaic Console' "$LOG" && break; sleep 0.1; done +cat "$LOG" +PORT=$(sed -n 's|^Mosaic Console: http://127.0.0.1:\([0-9]*\)/$|\1|p' "$LOG") +U=http://127.0.0.1:$PORT +c() { local what=$1 out; shift; out=$(curl -s -w '\n%{http_code}' "$@"); printf '%-42s %s %.110s\n' "$what" "${out##*$'\n'}" "$(printf '%s' "${out%$'\n'*}" | tr -d '\n')"; } +echo "== refusals" +c "wrong Host" -H "Host: evil.example" -H "$HEADER: $ID" "$U/api/bus/inbox" +c "public name with a port" -H "Host: $NAME:443" -H "$HEADER: $ID" "$U/api/bus/inbox" +c "wrong Origin" -H "Host: $NAME" -H "$HEADER: $ID" -H "Origin: https://evil.example" "$U/api/bus/inbox" +c "missing header" -H "Host: $NAME" "$U/api/bus/inbox" +c "wrong user" -H "Host: $NAME" -H "$HEADER: ${ID%?}0" "$U/api/bus/inbox" +c "POST /api/seen, signed in" -H "Host: $NAME" -H "$HEADER: $ID" -H 'Content-Type: application/json' -d '{"seen":true}' "$U/api/seen" +c "POST /api/reply, signed in" -H "Host: $NAME" -H "$HEADER: $ID" -H 'Content-Type: application/json' -d '{"agent":"p/a","text":"x"}' "$U/api/reply" +c "POST /api/reply, loopback Host" -H 'Content-Type: application/json' -d '{"agent":"p/a","text":"x"}' "$U/api/reply" +echo "== allowed" +c "GET /healthz, signed in, lowercase header" -H "Host: $NAME" -H "x-authentik-uid: $ID" -H "Origin: https://$NAME" "$U/healthz" +c "GET /api/config, signed in" -H "Host: $NAME" -H "$HEADER: $ID" "$U/api/config" +c "GET / (the page), signed in" -H "Host: $NAME" -H "$HEADER: $ID" "$U/" +c "GET /api/bus/inbox, signed in (no bus)" -H "Host: $NAME" -H "$HEADER: $ID" "$U/api/bus/inbox" +c "GET /api/board, signed in (board down)" -H "Host: $NAME" -H "$HEADER: $ID" "$U/api/board" +c "GET /healthz, loopback, no header" "$U/healthz" diff --git a/agents/dewey/work/queue-74/demo.txt b/agents/dewey/work/queue-74/demo.txt new file mode 100644 index 000000000..183532071 --- /dev/null +++ b/agents/dewey/work/queue-74/demo.txt @@ -0,0 +1,24 @@ +== partial flag set, refused at start +refused: preview mode needs --public-host, --auth-header and --allow-user together +exit 2 +Mosaic Console: http://127.0.0.1:44305/ +Board: http://127.0.0.1:9 +Bus: not configured (system config problem (mosaic-config exit 3): mosaic-config: configuration not found: /no-config.json (run scripts/bootstrap.sh to create it)) +Preview: https://mosaic-preview.woltje.com/ through the proxy, signed-in user from x-authentik-uid, read-only +Ctrl-C stops this server. +== refusals +wrong Host 403 {"error":"non-local Host refused"} +public name with a port 403 {"error":"non-local Host refused"} +wrong Origin 403 {"error":"cross-origin request refused"} +missing header 401 {"error":"sign-in required"} +wrong user 401 {"error":"sign-in required"} +POST /api/seen, signed in 403 {"error":"read-only","message":"the Console preview is read-only"} +POST /api/reply, signed in 403 {"error":"read-only","message":"the Console preview is read-only"} +POST /api/reply, loopback Host 403 {"error":"read-only","message":"the Console preview is read-only"} +== allowed +GET /healthz, signed in, lowercase header 200 {"ok":true} +GET /api/config, signed in 200 {"board":"http://127.0.0.1:9"} +GET / (the page), signed in 200