fix(webui): require verified OIDC for human writes (#385)

This commit is contained in:
jason.woltje committed 2026-10-29 00:19:02 -05:00
1 parent 09a847edaa
commit 9936a1be1a
29 files changed
+524 -328

No files matched your search

+2
View File
@@ -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 | 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-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 `[email protected]` is unavailable; no install, commit, push, PR, queue move or green/completion claim.
+1
View File
@@ -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 | 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-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.
+4 -4
View File
@@ -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 uninstall [--dir DIR] [--no-reload]
scripts/webui-service.sh status [--dir DIR] scripts/webui-service.sh status [--dir DIR]
# FLAGS: [--port N] [--business ID] [--board URL] # 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, The systemd user unit `mosaic-webui.service` runs the Console,
`node packages/webui/src/cli.mjs serve`, with serve's flags as given: `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 `--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 goes all three or none and stays read-only. `--send` (row 83)
preview set and is refused otherwise (exit 1, nothing written); ExecStart requires `--sso` and is refused otherwise (exit 1, nothing written); ExecStart
then ends with `--send`, and the Console needs the system config at start 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 read-only. The template is
`packages/webui/systemd/mosaic-webui.service.in`. `packages/webui/systemd/mosaic-webui.service.in`.
+15 -22
View File
@@ -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 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 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. 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` 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` 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 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 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) (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 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 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 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 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 design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send` 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 - The client secret is read once at start from
`<config dir>/secrets/mosaic-stack/<slot>`. The start refuses a slot that is `<config dir>/secrets/mosaic-stack/<slot>`. 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) ## Message a role (row 76, #1570)
```sh ```sh
node packages/webui/src/cli.mjs serve --port 7332 \ node packages/webui/src/cli.mjs serve --port 7332 --sso --send
--public-host NAME --auth-header HEADER --allow-user ID --send
``` ```
`--send` is off by default. It goes only with the three preview flags and `--send` is off by default. It requires verified OIDC through `--sso` and
refuses at start without them. It also needs the system config: without it refuses header-preview configurations, including those with proxy proof.
the start refuses with `--send needs the bus configured`, where a read-only 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 Console would still serve the board. A unit that sends comes from
`scripts/webui-service.sh install` with `--send` (above). With `--send` the `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, 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 terminal. There are no other message classes, no corrections and no seat
conversations here. conversations here.
What `--send` grants: two kinds of client can send a human instruction to With `--send`, the Console authenticates each user through its OIDC session
any role while it runs. and uses that user's authorized transport. Caller-supplied identity headers
and a loopback connection do not authenticate a writer.
- 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.
The request is POST `/api/bus/message` with a JSON object `{to, body, 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 in_reply_to?}`. Nothing is sent unless all of these hold, each checked on
every request: every request:
- Host is NAME and HEADER is ID, once: otherwise 401 `sign-in required`. - Host is the configured OIDC public Host and the request has a valid
There is no loopback path. The operator's browser on `127.0.0.1` reads signed-in session. There is no loopback operator path under `--sso`.
but can't send, and a loopback Host with HEADER is 403 `proxy Host
refused`, as above.
- Origin is exactly `https://NAME`, else 403 `cross-origin request refused`. - Origin is exactly `https://NAME`, else 403 `cross-origin request refused`.
- `Sec-Fetch-Site` is sent once and is exactly `same-origin`, else 403 - `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. `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 **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. 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 All four POST operations need `--sso --send`, the configured public Host, a
single authenticated user header, the Console's HTTPS Origin, same-origin Fetch 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 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 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 returns 403 `thread-not-owned`; a missing/foreign thread returns 404. No browser
+3 -3
View File
@@ -18,7 +18,7 @@ import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path'; import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs'; import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2); 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 { try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); } if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage); 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-*'); 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); options.preview = previewOptions(preview);
if (sendOn && !ssoOn) throw new Error('--send needs --sso; header preview is read-only');
const proof = proofOptions(proofFlags, options.preview); 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 // The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured. // board still serves and the bus routes answer not-configured.
// The Queue, Business and Settings reads (row 54) need no bus; without // 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; } } 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'}`; const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub. // One line per provider: id, issuer, subject count and state. No client id, secret or sub.
const health = sso?.health() ?? {}; const health = sso?.health() ?? {};
+5 -5
View File
@@ -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 }); 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 // Row 76 (#1570, lead decision 95): with OIDC --sso --send, a human message to a role.
// message to a role. Checked here again whatever passed above: the header on this request, its own // 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 // 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. // 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 SEND_KEYS = ['to', 'body', 'in_reply_to'];
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); 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, // 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 signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`; const ownOrigin = p => p.origin ?? `https://${p.host}`;
async function busSend(req, res, { send, preview, log }) { 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 }); 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. // `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 // `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. // 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'); 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 (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) => { const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's. // 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 = []; let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, upstream, files } = owner, granted = false, chatGrants = [];
+21 -20
View File
@@ -13,9 +13,8 @@ import { startServer } from '../src/serve.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs'; import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs'; import { testerRequest } from '../../cli/src/tester-http.mjs';
import { BusReadError } from '../src/bus.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 REV = 'a'.repeat(64);
const ID = '0b5e8f1c-3a2d-4c6e-9f10-1234567890ab'; 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 }); 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))); t.after(() => new Promise(r => server.close(r)));
return { calls, lines, dataRoot }; 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))); 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 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 => { 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 => { test('row 194 T16: the boundary refuses before any bus call', async t => {
const s = await setup(t); const s = await setup(t);
const cases = [ const cases = [
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'x-user': 'mallory' }, 401], ['/api/agent/launch', { instance: 'coder' }, { ...s.headers, cookie: '__Host-mosaic_session=invalid' }, 401],
['/api/agent/launch', { instance: 'coder' }, { ...headers, origin: 'https://evil.example' }, 403], ['/api/agent/launch', { instance: 'coder' }, { ...s.headers, origin: 'https://evil.example' }, 403],
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'sec-fetch-site': 'cross-site' }, 403], ['/api/agent/launch', { instance: 'coder' }, { ...s.headers, 'sec-fetch-site': 'cross-site' }, 403],
['/api/agent/launch', { instance: 'coder', extra: 'x' }, headers, 400], ['/api/agent/launch', { instance: 'coder', extra: 'x' }, s.headers, 400],
['/api/agent/launch', {}, headers, 400], ['/api/agent/launch', {}, s.headers, 400],
['/api/agent/launch', { instance: 7 }, headers, 400], ['/api/agent/launch', { instance: 7 }, s.headers, 400],
['/api/agent/launch', ['coder'], headers, 400], ['/api/agent/launch', ['coder'], s.headers, 400],
['/api/agent/launch', 'not json', headers, 400], ['/api/agent/launch', 'not json', s.headers, 400],
['/api/agent/launch', { instance: 'x'.repeat(17000) }, headers, 400], ['/api/agent/launch', { instance: 'x'.repeat(17000) }, s.headers, 400],
['/api/agent/terminate', { instance: 'coder', reason: 3 }, headers, 400], ['/api/agent/terminate', { instance: 'coder', reason: 3 }, s.headers, 400],
['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, headers, 400], ['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, s.headers, 400],
['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, headers, 400], ['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, s.headers, 400],
['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, headers, 400], ['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, s.headers, 400],
['/api/veto', { op: 'keep', name: 'coder' }, headers, 400], ['/api/veto', { op: 'keep', name: 'coder' }, s.headers, 400],
]; ];
for (const [path, body, hs, status] of cases) { for (const [path, body, hs, status] of cases) {
const r = await send(s, path, body, hs); 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 => { test('row 194 N5: agents.js is served as JavaScript and index.html loads it once', async t => {
const s = await setup(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.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/); 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\./); 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(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1); assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
}); });
+11 -10
View File
@@ -5,21 +5,22 @@ import assert from 'node:assert/strict';
import { request } from 'node:http'; import { request } from 'node:http';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.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' };
async function setup(t, { on = true, name = async args => ({ ...args }), stop = async () => ({ stopped: [{ thread: 't-1', run: 'a-run' }] }), bus = null } = {}) { async function setup(t, { on = true, name = async args => ({ ...args }), stop = async () => ({ stopped: [{ thread: 't-1', run: 'a-run' }] }), bus = null } = {}) {
const calls = [], lines = []; const calls = [], lines = [];
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ const server = await startServer({
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null, port: 0, sso: auth.sso, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
nameWrite: on ? async args => { calls.push(['name.set', args]); return name(args); } : null, nameWrite: on ? async args => { calls.push(['name.set', args]); return name(args); } : null,
assistantStop: on ? async args => { calls.push(['assistant.stop', args]); return stop(args); } : null, assistantStop: on ? async args => { calls.push(['assistant.stop', args]); return stop(args); } : null,
}); });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
return { calls, lines, port: server.address().port }; 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 = headers, method = 'POST') => new Promise((resolve, reject) => { 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 => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null })); let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
}); });
@@ -46,12 +47,12 @@ test('Q169 the name and stop routes need send on, the public name, the user, the
const s = await setup(t); const s = await setup(t);
for (const path of ['/api/name', '/api/assistant-stop']) { for (const path of ['/api/name', '/api/assistant-stop']) {
const data = path === '/api/name' ? { instance: 'assistant', name: 'piper' } : { instance: 'assistant' }; const data = path === '/api/name' ? { instance: 'assistant', name: 'piper' } : { instance: 'assistant' };
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) { for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['cookie', undefined, 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
const hs = { ...headers }; const hs = { ...s.headers };
if (value === undefined) delete hs[key]; else hs[key] = value; if (value === undefined) delete hs[key]; else hs[key] = value;
assert.equal((await send(s, path, data, hs)).status, status, `${path} ${key}`); assert.equal((await send(s, path, data, hs)).status, status, `${path} ${key}`);
} }
assert.equal((await send(s, path, undefined, headers, 'GET')).status, 405); assert.equal((await send(s, path, undefined, s.headers, 'GET')).status, 405);
} }
assert.equal(s.calls.length, 0); assert.equal(s.calls.length, 0);
}); });
@@ -62,7 +63,7 @@ test('Q169 the name and stop routes take JSON within 4 KiB and only their own ke
assert.equal((await send(s, '/api/name', value)).status, 400, JSON.stringify(value).slice(0, 40)); assert.equal((await send(s, '/api/name', value)).status, 400, JSON.stringify(value).slice(0, 40));
for (const value of [{}, { instance: 'assistant', thread: 't-1' }, { instance: null }]) for (const value of [{}, { instance: 'assistant', thread: 't-1' }, { instance: null }])
assert.equal((await send(s, '/api/assistant-stop', value)).status, 400, JSON.stringify(value)); assert.equal((await send(s, '/api/assistant-stop', value)).status, 400, JSON.stringify(value));
assert.equal((await send(s, '/api/name', { instance: 'assistant', name: 'piper' }, { ...headers, 'content-type': 'text/plain' })).status, 400); assert.equal((await send(s, '/api/name', { instance: 'assistant', name: 'piper' }, { ...s.headers, 'content-type': 'text/plain' })).status, 400);
assert.equal(s.calls.length, 0); assert.equal(s.calls.length, 0);
}); });
@@ -102,7 +103,7 @@ test('Q169 a bus refusal says nothing changed; a lost or odd answer says the cha
test('Q169 the names read is a bus read like the others', async t => { test('Q169 the names read is a bus read like the others', async t => {
const rows = [{ instance: 'assistant', name: 'piper', at: '2026-10-18T00:00:00.000Z' }]; const rows = [{ instance: 'assistant', name: 'piper', at: '2026-10-18T00:00:00.000Z' }];
const s = await setup(t, { bus: { names: async () => rows } }); const s = await setup(t, { bus: { names: async () => rows } });
const r = await send(s, '/api/bus/names', undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET'); const r = await send(s, '/api/bus/names', undefined, s.headers, 'GET');
assert.equal(r.status, 200); assert.equal(r.status, 200);
assert.deepEqual(r.body.rows, rows); assert.deepEqual(r.body.rows, rows);
}); });
+11 -9
View File
@@ -6,13 +6,10 @@ import { createChat } from "../src/chat.mjs";
import { startServer } from "../src/serve.mjs"; import { startServer } from "../src/serve.mjs";
import { BusReadError } from "../src/bus.mjs"; import { BusReadError } from "../src/bus.mjs";
import { close } from "./fixture.mjs"; import { close } from "./fixture.mjs";
import { createSsoSession } from "./helpers/sso-session.mjs";
const P = "10000000-0000-4000-8000-000000000001"; const P = "10000000-0000-4000-8000-000000000001";
const T = "20000000-0000-4000-8000-000000000002"; const T = "20000000-0000-4000-8000-000000000002";
const preview = { host: "chat.example.com", header: "remote-user", user: "jason" }; function http(port, headers, path, body) {
const headers = { host: preview.host, "remote-user": "jason", origin: "https://chat.example.com", "sec-fetch-site": "same-origin", "content-type": "application/json" };
function http(port, path, body) {
const raw = body === undefined ? undefined : JSON.stringify(body); const raw = body === undefined ? undefined : JSON.stringify(body);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method: raw === undefined ? 'GET' : 'POST', const req = request({ host: '127.0.0.1', port, path, method: raw === undefined ? 'GET' : 'POST',
@@ -28,15 +25,18 @@ function http(port, path, body) {
async function setup(t, selected) { async function setup(t, selected) {
const calls = [], logs = []; const calls = [], logs = [];
const auth = await createSsoSession(t, { host: 'chat.example.com' });
const chat = createChat({ call: async (verb, args) => { const chat = createChat({ call: async (verb, args) => {
calls.push([verb, args]); calls.push([verb, args]);
if (verb === "plugins") return { business: "demo", revision: "a".repeat(64), plugins: selected === undefined ? {} : { channel: selected } }; if (verb === "plugins") return { business: "demo", revision: "a".repeat(64), plugins: selected === undefined ? {} : { channel: selected } };
return []; return [];
} }); } });
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: line => logs.push(line) }); const server = await startServer({ port: 0, chat, sso: auth.sso, send: async () => ({}), log: line => logs.push(line) });
t.after(() => close(server)); t.after(() => close(server));
const session = await auth.signIn(server.address().port);
const headers = auth.writeHeaders(session);
return { calls, logs, async request(path, body) { return { calls, logs, async request(path, body) {
return http(server.address().port, path, body); return http(server.address().port, headers, path, body);
} }; } };
} }
@@ -72,9 +72,11 @@ test("WebUI only selection keeps web chat enabled while retaining the old read e
test("WebUI channel-disabled recovery text is fixed and never echoes transport text", async t => { test("WebUI channel-disabled recovery text is fixed and never echoes transport text", async t => {
const chat = createChat({ call: async () => { throw new BusReadError("channel-disabled", "SENTINEL-private-input"); } }); const chat = createChat({ call: async () => { throw new BusReadError("channel-disabled", "SENTINEL-private-input"); } });
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: () => {} }); const auth = await createSsoSession(t, { host: 'chat.example.com' });
const server = await startServer({ port: 0, chat, sso: auth.sso, send: async () => ({}), log: () => {} });
t.after(() => close(server)); t.after(() => close(server));
const res = await http(server.address().port, '/api/chat/projects'); const session = await auth.signIn(server.address().port);
const res = await http(server.address().port, auth.writeHeaders(session), '/api/chat/projects');
const result = res.data; const result = res.data;
assert.equal(res.status, 503); assert.equal(res.status, 503);
assert.equal(result.error, "channel-disabled"); assert.equal(result.error, "channel-disabled");
+48 -19
View File
@@ -5,6 +5,8 @@ import { createChat } from '../src/chat.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs'; import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a // Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id. // legacy thread, with the thread's id.
const DEFAULT_SESSION = [ const DEFAULT_SESSION = [
@@ -23,21 +25,22 @@ const posts = [
[`/api/chat/threads/${T}/messages`, { body: ' hi\nthere ' }, 'thread.send', { thread: T, body: ' hi\nthere ', class: 'REQUEST' }, { id: M }], [`/api/chat/threads/${T}/messages`, { body: ' hi\nthere ' }, 'thread.send', { thread: T, body: ' hi\nthere ', class: 'REQUEST' }, { id: M }],
[`/api/chat/threads/${T}/open`, {}, 'thread.open', { thread: T }, { opened: T }], [`/api/chat/threads/${T}/open`, {}, 'thread.open', { thread: T }, { opened: T }],
]; ];
function http(port, path, { method = 'GET', value, raw, extra = {}, host = preview.host } = {}) { function http(port, path, { method = 'GET', value, raw, extra = {}, host = preview.host, baseHeaders = headers } = {}) {
const data = raw ?? (value === undefined ? undefined : JSON.stringify(value)); const data = raw ?? (value === undefined ? undefined : JSON.stringify(value));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, method, path, headers: { const req = request({ host: '127.0.0.1', port, method, path, headers: {
host, ...headers, ...(data === undefined ? {} : { 'content-length': Buffer.byteLength(data) }), ...extra, host, ...baseHeaders, ...(data === undefined ? {} : { 'content-length': Buffer.byteLength(data) }), ...extra,
} }, res => { } }, res => {
let text = ''; let text = '';
res.on('data', c => { text += c; }); res.on('data', c => { text += c; });
res.on('end', () => resolve({ status: res.statusCode, data: JSON.parse(text), cache: res.headers['cache-control'] })); res.on('end', () => resolve({ status: res.statusCode, data: text ? JSON.parse(text) : null, cache: res.headers['cache-control'], headers: res.headers }));
}); });
req.on('error', reject); req.end(data); req.on('error', reject); req.end(data);
}); });
} }
async function setup(t, options = {}) { async function setup(t, options = {}) {
const calls = [], logs = []; const calls = [], logs = [];
const auth = await createSsoSession(t, { host: preview.host });
const state = { answer: [] }; const state = { answer: [] };
const managementReads = []; const managementReads = [];
const chat = createChat({ call: async (verb, args) => { const chat = createChat({ call: async (verb, args) => {
@@ -49,9 +52,10 @@ async function setup(t, options = {}) {
if (state.answer instanceof Error) throw state.answer; if (state.answer instanceof Error) throw state.answer;
return typeof state.answer === 'function' ? state.answer(verb, args) : state.answer; return typeof state.answer === 'function' ? state.answer(verb, args) : state.answer;
} }); } });
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: l => logs.push(l), ...options }); const server = await startServer({ port: 0, chat, sso: auth.sso, send: async () => ({}), log: l => logs.push(l), ...options });
t.after(() => close(server)); t.after(() => close(server));
return { calls, logs, state, get: (path, opt) => http(server.address().port, path, opt), port: server.address().port }; const session = await auth.signIn(server.address().port), baseHeaders = auth.writeHeaders(session);
return { calls, logs, state, get: (path, opt = {}) => http(server.address().port, path, { ...opt, baseHeaders }), port: server.address().port };
} }
test('chat reads map only fixed verbs, strict forward paging and pure GET', async t => { test('chat reads map only fixed verbs, strict forward paging and pure GET', async t => {
@@ -97,7 +101,7 @@ for (const [path, value, verb, args, answer] of posts) {
test(`chat ${verb}: each existing Console write fence makes zero bus calls`, async t => { test(`chat ${verb}: each existing Console write fence makes zero bus calls`, async t => {
const c = await setup(t); const c = await setup(t);
for (const extra of [ for (const extra of [
{ 'remote-user': '' }, { 'remote-user': 'other' }, { 'remote-user': ['jason', 'jason'] }, { cookie: '' }, { cookie: '__Host-mosaic_session=invalid' }, { cookie: ['invalid', 'invalid'] },
{ origin: '' }, { origin: 'https://evil.example' }, { origin: ['https://chat.example.com', 'https://chat.example.com'] }, { origin: '' }, { origin: 'https://evil.example' }, { origin: ['https://chat.example.com', 'https://chat.example.com'] },
{ 'sec-fetch-site': '' }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': ['same-origin', 'same-origin'] }, { 'sec-fetch-site': '' }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': ['same-origin', 'same-origin'] },
{ 'content-type': 'text/plain' }, { 'content-type': 'text/plain' },
@@ -109,7 +113,7 @@ for (const [path, value, verb, args, answer] of posts) {
assert.deepEqual(c.calls, []); assert.deepEqual(c.calls, []);
const ro = await setup(t, { send: null }); const ro = await setup(t, { send: null });
assert.equal((await ro.get(path, { method: 'POST', value })).status, 403); assert.deepEqual(ro.calls, []); assert.equal((await ro.get(path, { method: 'POST', value })).status, 403); assert.deepEqual(ro.calls, []);
assert.equal((await c.get(path, { method: 'POST', value, host: `127.0.0.1:${c.port}`, extra: { 'remote-user': '', origin: '' } })).status, 403); assert.equal((await c.get(path, { method: 'POST', value, host: `127.0.0.1:${c.port}`, extra: { origin: '' } })).status, 403);
assert.deepEqual(c.calls, []); assert.deepEqual(c.calls, []);
}); });
test(`chat ${verb}: refusal or lost/malformed receipt never retries or invents success`, async t => { test(`chat ${verb}: refusal or lost/malformed receipt never retries or invents success`, async t => {
@@ -210,9 +214,11 @@ test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated histor
try { return structuredClone(await b.request(cap, { verb, args })); } try { return structuredClone(await b.request(cap, { verb, args })); }
catch (err) { throw new BusReadError(err.code); } catch (err) { throw new BusReadError(err.code); }
} }); } });
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: () => {} }); const auth = await createSsoSession(t, { host: preview.host });
const server = await startServer({ port: 0, chat, sso: auth.sso, send: async () => ({}), log: () => {} });
t.after(() => close(server)); t.after(() => close(server));
const get = (p, opt) => http(server.address().port, p, opt); const session = await auth.signIn(server.address().port), baseHeaders = auth.writeHeaders(session);
const get = (p, opt = {}) => http(server.address().port, p, { ...opt, baseHeaders });
const write = (p, value) => get(p, { method: 'POST', value }); const write = (p, value) => get(p, { method: 'POST', value });
const project = (await write('/api/chat/projects', { name: 'Project one', repo: 'stack' })).data.id; const project = (await write('/api/chat/projects', { name: 'Project one', repo: 'stack' })).data.id;
assert.match(project, /^[a-f0-9-]{36}$/); assert.match(project, /^[a-f0-9-]{36}$/);
@@ -268,22 +274,22 @@ test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated histor
} }
}); });
test('chat repeats every POST boundary even when called behind an admitted request', async () => { test('chat repeats every reachable POST boundary even when called behind an admitted SSO request', async () => {
const { Readable } = await import('node:stream'); const { Readable } = await import('node:stream');
const { chatHTTP } = await import('../src/chat-http.mjs'); const { chatHTTP } = await import('../src/chat-http.mjs');
for (const [path, value] of posts) for (const fence of ['send', 'named', 'identity', 'origin', 'site']) { const signer = { sso: true, user: 'jason', host: preview.host, origin: `https://${preview.host}` };
for (const [path, value] of posts) for (const fence of ['send', 'named', 'origin', 'site']) {
const req = Readable.from([Buffer.from(JSON.stringify(value))]); const req = Readable.from([Buffer.from(JSON.stringify(value))]);
req.method = 'POST'; req.headers = { ...headers }; req.method = 'POST'; req.headers = { ...headers };
req.headersDistinct = Object.fromEntries(Object.entries(headers).map(([k,v]) => [k,[v]])); req.headersDistinct = Object.fromEntries(Object.entries(headers).map(([k,v]) => [k,[v]]));
if (fence === 'identity') req.headersDistinct['remote-user'] = ['jason', 'jason'];
if (fence === 'origin') req.headersDistinct.origin = []; if (fence === 'origin') req.headersDistinct.origin = [];
if (fence === 'site') req.headersDistinct['sec-fetch-site'] = []; if (fence === 'site') req.headersDistinct['sec-fetch-site'] = [];
let status, result, calls = 0; let status, result, calls = 0;
const chat = createChat({ call: async () => { calls++; return { id: M }; } }); const chat = createChat({ call: async () => { calls++; return { id: M }; } });
const res = { writeHead(code) { status = code; }, end(bytes) { result = JSON.parse(bytes); } }; const res = { writeHead(code) { status = code; }, end(bytes) { result = JSON.parse(bytes); } };
await chatHTTP(req, res, { path, search: '', chat, named: fence !== 'named', await chatHTTP(req, res, { path, search: '', chat, named: fence !== 'named',
send: fence === 'send' ? null : async () => ({}), preview, log: () => {} }); send: fence === 'send' ? null : async () => ({}), preview: signer, log: () => {} });
assert.equal(status, fence === 'identity' ? 401 : 403, path + ' ' + fence); assert.equal(status, 403, path + ' ' + fence);
assert.equal(result.sent, false); assert.equal(calls, 0); assert.equal(result.sent, false); assert.equal(calls, 0);
} }
}); });
@@ -291,12 +297,26 @@ test('chat repeats every POST boundary even when called behind an admitted reque
test('configured Console process injects chat through its existing human transport', { timeout: 15000 }, async t => { test('configured Console process injects chat through its existing human transport', { timeout: 15000 }, async t => {
const { spawn } = await import('node:child_process'); const { spawn } = await import('node:child_process');
const { tmp, fixture } = await import('../../cli/tests/helpers.mjs'); const { tmp, fixture } = await import('../../cli/tests/helpers.mjs');
const { chmodSync, mkdirSync, readFileSync, writeFileSync } = await import('node:fs');
const { dirname, join } = await import('node:path');
const { fileURLToPath } = await import('node:url'); const { fileURLToPath } = await import('node:url');
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root); const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
const op = await fakeOidc(); t.after(() => op.close());
const issuer = op.issuer('writer');
const config = JSON.parse(readFileSync(env.MOSAIC_CONFIG, 'utf8'));
config.console = { sso: { publicOrigin: `https://${preview.host}`, providers: [{
id: issuer.name, issuer: issuer.issuer, clientId: issuer.clientId,
clientSecret: { slot: issuer.name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }],
}] } };
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(config));
const secrets = join(dirname(env.MOSAIC_CONFIG), 'secrets', 'mosaic-stack');
mkdirSync(secrets, { recursive: true });
const secret = join(secrets, issuer.name); writeFileSync(secret, `${issuer.clientSecret}\n`, { mode: 0o600 }); chmodSync(secret, 0o600);
const ca = join(root, 'fake-oidc-ca.pem'); writeFileSync(ca, op.cert);
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'], '--sso', '--send'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] }); { env: { ...env, NODE_EXTRA_CA_CERTS: ca }, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => { t.after(async () => {
if (child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); } if (child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
}); });
@@ -306,10 +326,19 @@ test('configured Console process injects chat through its existing human transpo
child.stdout.on('data', c => { output += c; const m = output.match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//); if(m) resolve(Number(m[1])); }); child.stdout.on('data', c => { output += c; const m = output.match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//); if(m) resolve(Number(m[1])); });
child.once('exit', code => reject(Error(`Console fixture exited ${code}: ${errors}`))); child.once('exit', code => reject(Error(`Console fixture exited ${code}: ${errors}`)));
}); });
const login = await http(port, '/auth/login', { baseHeaders: {} });
assert.equal(login.status, 303);
const loginCookie = [login.headers['set-cookie'] ?? []].flat()[0].split(';')[0];
const grant = op.grant(issuer, login.headers.location, { sub: 'owner-sub' });
const state = new URL(login.headers.location).searchParams.get('state');
const callback = await http(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state })}`, { baseHeaders: { cookie: loginCookie } });
assert.equal(callback.status, 303);
const session = [callback.headers['set-cookie'] ?? []].flat().find(value => value.startsWith('__Host-mosaic_session=') && !value.includes('Max-Age=0')).split(';')[0];
const signed = { cookie: session, origin: `https://${preview.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
// With a private fixture config but no fixture host, reaching the configured human // With a private fixture config but no fixture host, reaching the configured human
// transport gives no-bus-host; missing adapter injection would give not-configured. // transport gives no-bus-host; missing adapter injection would give not-configured.
for (const [path, options] of [['/api/chat/projects', {}], ['/api/chat/projects', { method:'POST', value:{name:'fixture'} }]]) { for (const [path, options] of [['/api/chat/projects', {}], ['/api/chat/projects', { method:'POST', value:{name:'fixture'} }]]) {
const r = await http(port, path, options); const r = await http(port, path, { ...options, baseHeaders: signed });
assert.equal(r.status, 503); assert.equal(r.data.error, 'no-bus-host'); assert.equal(r.status, 503); assert.equal(r.data.error, 'no-bus-host');
} }
}); });
@@ -381,7 +410,7 @@ test('264e session catalog/create and explicit selectors keep the human transpor
}); });
test('264e session writes retain Origin/identity fences and explicit selector errors', async t => { test('264e session writes retain Origin/identity fences and explicit selector errors', async t => {
const c = await setup(t); const c = await setup(t);
for (const extra of [{origin:'https://evil.example'}, {'remote-user':'other'}, {'sec-fetch-site':'cross-site'}]) { for (const extra of [{origin:'https://evil.example'}, {cookie:'__Host-mosaic_session=invalid'}, {'sec-fetch-site':'cross-site'}]) {
assert.ok((await c.get(`/api/chat/threads/${T}/sessions`, { method:'POST', value:{name:'x',op:'new-x'}, extra })).status >= 400); assert.ok((await c.get(`/api/chat/threads/${T}/sessions`, { method:'POST', value:{name:'x',op:'new-x'}, extra })).status >= 400);
} }
assert.deepEqual(c.calls, []); assert.deepEqual(c.calls, []);
@@ -424,7 +453,7 @@ test('264e cancel is an authenticated, confirmed human write and retains retirem
} }
const before=c.calls.length; const before=c.calls.length;
for(const value of [{op:'clear-one'}, {op:'clear-one',confirm:false}, {op:'clear-one',confirm:true,user:'other'}]) assert.equal((await c.get(path,{method:'POST',value})).status,400); for(const value of [{op:'clear-one'}, {op:'clear-one',confirm:false}, {op:'clear-one',confirm:true,user:'other'}]) assert.equal((await c.get(path,{method:'POST',value})).status,400);
for(const extra of [{origin:'https://evil.example'}, {'remote-user':'other'}, {'sec-fetch-site':'cross-site'}]) assert.ok((await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true},extra})).status>=400); for(const extra of [{origin:'https://evil.example'}, {cookie:'__Host-mosaic_session=invalid'}, {'sec-fetch-site':'cross-site'}]) assert.ok((await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true},extra})).status>=400);
assert.equal(c.calls.length,before); assert.equal(c.calls.length,before);
for(const code of ['session-clear-complete','session-name-too-long','session-op-conflict']) { for(const code of ['session-clear-complete','session-name-too-long','session-op-conflict']) {
c.state.answer=new BusReadError(code); c.state.answer=new BusReadError(code);
@@ -0,0 +1,94 @@
// Q284 / #385: real OIDC sessions for writer-route tests. The provider is the existing
// in-process fake and is shared only within one test-file process; each test gets its own
// createSso instance and completes the real login, code and callback flow.
import { after } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { createSso, SESSION_COOKIE } from '../../src/sso.mjs';
import { fakeOidc } from './fake-oidc.mjs';
export const SSO_HOST = 'console.example.test';
export const SSO_ORIGIN = `https://${SSO_HOST}`;
let providerPromise = null;
async function provider() {
if (!providerPromise) providerPromise = (async () => {
const op = await fakeOidc();
return { op, issuer: op.issuer('writer') };
})();
return providerPromise;
}
after(async () => {
if (providerPromise) await (await providerPromise).op.close();
});
function call(port, { host, path, headers = {} }) {
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, headers: { host, ...headers } }, res => {
let body = '';
res.on('data', chunk => { body += chunk; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
});
req.on('error', reject);
req.end();
});
}
const cookies = response => [response.headers['set-cookie'] ?? []].flat();
export async function createSsoSession(t, {
host = SSO_HOST,
owner = { sub: 'owner-sub', name: 'jason' },
subjects = null,
users = null,
log = () => {},
} = {}) {
const { op, issuer } = await provider();
const publicOrigin = `https://${host}`;
const configuredSubjects = subjects ?? [owner];
const sso = await createSso({
publicOrigin,
providers: [{
id: issuer.name,
issuer: issuer.issuer,
clientId: issuer.clientId,
clientSecret: { slot: issuer.name },
algs: ['RS256'],
subjects: configuredSubjects,
}],
}, {
secrets: new Map([[issuer.name, issuer.clientSecret]]),
fetch: op.fetch,
log,
...(users ? { users } : {}),
});
t.after(() => sso.close());
async function signIn(port, { sub = owner.sub } = {}) {
const begin = await call(port, { host, path: '/auth/login' });
assert.equal(begin.status, 303, `OIDC login start: ${begin.status} ${begin.body}`);
const loginCookie = cookies(begin)[0]?.split(';')[0];
assert.ok(loginCookie, 'OIDC login did not set its transaction cookie');
const grant = op.grant(issuer, begin.headers.location, { sub });
const state = new URL(begin.headers.location).searchParams.get('state');
const callback = await call(port, {
host,
path: `/auth/callback?${new URLSearchParams({ code: grant.code, state })}`,
headers: { cookie: loginCookie },
});
assert.equal(callback.status, 303, `OIDC callback: ${callback.status} ${callback.body}`);
const session = cookies(callback).find(value => value.startsWith(`${SESSION_COOKIE}=`) && !value.includes('Max-Age=0'))?.split(';')[0];
assert.ok(session, 'OIDC callback did not set a session cookie');
return session;
}
return {
sso,
host,
publicOrigin,
signIn,
readHeaders: session => ({ host, ...(session ? { cookie: session } : {}) }),
writeHeaders: session => ({ host, ...(session ? { cookie: session } : {}), origin: publicOrigin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }),
};
}
+12 -11
View File
@@ -16,23 +16,24 @@ import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs'; import { startBroker } from '../../bus/src/runtime.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs'; import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs'; import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url)); const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url));
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 REV = 'a'.repeat(64);
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra }); const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra });
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: false }), bus = null } = {}) { async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: false }), bus = null } = {}) {
const calls = [], lines = []; const calls = [], lines = [];
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ const server = await startServer({
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null, port: 0, sso: auth.sso, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
instancesWrite: writer ? async args => { calls.push(args); return write(args); } : null, instancesWrite: writer ? async args => { calls.push(args); return write(args); } : null,
}); });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
return { calls, lines, port: server.address().port }; 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 = headers, method = 'POST') => new Promise((resolve, reject) => { 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 => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null })); let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
}); });
@@ -58,7 +59,7 @@ test('row 165 Console: the signed-in human adds an instance through a real broke
const s = await setup(t, { bus: { instances: () => call('instances') }, write: args => call('instances.set', args) }); const s = await setup(t, { bus: { instances: () => call('instances') }, write: args => call('instances.set', args) });
const file = join(dir, 'businesses', 'acme.json'); const file = join(dir, 'businesses', 'acme.json');
const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l)); const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l));
const read = await send(s, '/api/bus/instances', undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET'); const read = await send(s, '/api/bus/instances', undefined, s.headers, 'GET');
assert.equal(read.status, 200); assert.equal(read.status, 200);
const [current] = read.body.rows; const [current] = read.body.rows;
assert.equal(current.definitions['security-reviewer'].accepted, true); assert.equal(current.definitions['security-reviewer'].accepted, true);
@@ -105,12 +106,12 @@ test('row 165 Console: the instance write needs send on, the public name, the us
assert.equal((await send(unsent, '/api/instances', data)).status, 403); assert.equal((await send(unsent, '/api/instances', data)).status, 403);
assert.equal(unsent.calls.length, 0); assert.equal(unsent.calls.length, 0);
const s = await setup(t); const s = await setup(t);
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) { for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['cookie', undefined, 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
const hs = { ...headers }; const hs = { ...s.headers };
if (value === undefined) delete hs[key]; else hs[key] = value; if (value === undefined) delete hs[key]; else hs[key] = value;
assert.equal((await send(s, '/api/instances', data, hs)).status, status, key); assert.equal((await send(s, '/api/instances', data, hs)).status, status, key);
} }
assert.equal((await send(s, '/api/instances', undefined, headers, 'GET')).status, 405); assert.equal((await send(s, '/api/instances', undefined, s.headers, 'GET')).status, 405);
assert.equal(s.calls.length, 0); assert.equal(s.calls.length, 0);
}); });
@@ -122,7 +123,7 @@ test('row 165 Console: the instance write takes JSON within 16 KiB and only its
{ ...base, revision: 'stale' }, { ...base, op: 'remove' }, { ...base, instance: 7 }, { ...base, definition: 7 }, { ...base, revision: 'stale' }, { ...base, op: 'remove' }, { ...base, instance: 7 }, { ...base, definition: 7 },
{ ...base, vars: [] }, { ...base, vars: null }, { ...base, launch: 'yes' }, [], 'bad', { ...base, vars: { pad: 'x'.repeat(17000) } }, { ...base, vars: [] }, { ...base, vars: null }, { ...base, launch: 'yes' }, [], 'bad', { ...base, vars: { pad: 'x'.repeat(17000) } },
]) assert.equal((await send(s, '/api/instances', value)).status, 400, JSON.stringify(value).slice(0, 60)); ]) assert.equal((await send(s, '/api/instances', value)).status, 400, JSON.stringify(value).slice(0, 60));
assert.equal((await send(s, '/api/instances', base, { ...headers, 'content-type': 'text/plain' })).status, 400); assert.equal((await send(s, '/api/instances', base, { ...s.headers, 'content-type': 'text/plain' })).status, 400);
assert.equal(s.calls.length, 0); assert.equal(s.calls.length, 0);
assert.ok(s.lines.every(l => !l.includes('17000') && !l.includes('xxxx')), 'vars are never logged'); assert.ok(s.lines.every(l => !l.includes('17000') && !l.includes('xxxx')), 'vars are never logged');
}); });
@@ -151,7 +152,7 @@ test('row 165 Console: a bus refusal says nothing changed; a lost or odd answer
}); });
test('row 165 Console: the instances read takes no arguments, needs a bus and refuses an answer without a revision', async t => { test('row 165 Console: the instances read takes no arguments, needs a bus and refuses an answer without a revision', async t => {
const get = (s, path) => send(s, path, undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET'); const get = (s, path) => send(s, path, undefined, s.headers, 'GET');
const none = await setup(t); const none = await setup(t);
assert.equal((await get(none, '/api/bus/instances')).status, 503); assert.equal((await get(none, '/api/bus/instances')).status, 503);
const s = await setup(t, { bus: { instances: async () => view() } }); const s = await setup(t, { bus: { instances: async () => view() } });
+20 -13
View File
@@ -7,23 +7,26 @@ import { request } from 'node:http';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { startServer, userRoute } from '../src/serve.mjs'; import { startServer, userRoute } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs'; import { BusReadError } from '../src/bus.mjs';
import { consoleAccess } from '../src/access.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; 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 C1 = 'a'.repeat(40), C2 = 'b'.repeat(40); const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40);
const payload = { project: 'books', commit: C1 }; const payload = { project: 'books', commit: C1 };
const STATUS = { project: 'books', version: 1, branch: 'main', served: C2, lastFetch: '2026-10-23T18:00:00.000Z', lastError: null, diverged: null, problems: [] }; const STATUS = { project: 'books', version: 1, branch: 'main', served: C2, lastFetch: '2026-10-23T18:00:00.000Z', lastError: null, diverged: null, problems: [] };
async function setup(t, { enabled = true, revert, status, lines = [] } = {}) { async function setup(t, { enabled = true, revert, status, lines = [] } = {}) {
const calls = { revert: [], status: [] }; const calls = { revert: [], status: [] };
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ const server = await startServer({
port: 0, preview, log: l => lines.push(l), port: 0, sso: auth.sso, log: l => lines.push(l),
send: enabled ? async () => ({}) : null, send: enabled ? async () => ({}) : null,
libraryStatus: status === null ? null : async args => { calls.status.push(args); return status ? status(args) : STATUS; }, libraryStatus: status === null ? null : async args => { calls.status.push(args); return status ? status(args) : STATUS; },
libraryRevert: enabled ? async args => { calls.revert.push(args); return revert ? revert(args) : { project: args.project, path: 'Notes/Eyes.md', reverted: args.commit, commit: C2 }; } : null, libraryRevert: enabled ? async args => { calls.revert.push(args); return revert ? revert(args) : { project: args.project, path: 'Notes/Eyes.md', reverted: args.commit, commit: C2 }; } : null,
}); });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
return { calls, lines, port: server.address().port }; const session = await auth.signIn(server.address().port);
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
} }
function call(s, { method = 'POST', path = '/api/library/revert', hs = headers, data = payload } = {}) { function call(s, { method = 'POST', path = '/api/library/revert', hs = s.headers, data = payload } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; let text = '';
@@ -34,7 +37,7 @@ function call(s, { method = 'POST', path = '/api/library/revert', hs = headers,
req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data)); req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
}); });
} }
const get = (s, path, hs = headers) => call(s, { method: 'GET', path, hs, data: null }); const get = (s, path, hs = s.headers) => call(s, { method: 'GET', path, hs, data: null });
const refusal = code => { throw new BusReadError(code); }; const refusal = code => { throw new BusReadError(code); };
test('a signed-in same-origin revert reaches exactly one library.revert and returns the new commit', async t => { test('a signed-in same-origin revert reaches exactly one library.revert and returns the new commit', async t => {
@@ -49,9 +52,9 @@ test('a revert needs send, the named Host, the user, the exact Origin and same-o
const off = await setup(t, { enabled: false }); const off = await setup(t, { enabled: false });
assert.deepEqual((await call(off)).body, { error: 'read-only', sent: false }); assert.deepEqual((await call(off)).body, { error: 'read-only', sent: false });
const s = await setup(t); const s = await setup(t);
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403], for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403],
['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['x-user', ['jason', 'jason'], 401]]) { ['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['cookie', undefined, 401]]) {
const hs = { ...headers }; const hs = { ...s.headers };
if (value === undefined) delete hs[key]; else hs[key] = value; if (value === undefined) delete hs[key]; else hs[key] = value;
const r = await call(s, { hs }); const r = await call(s, { hs });
assert.equal(r.status, status, key); assert.equal(r.status, status, key);
@@ -154,14 +157,18 @@ test('a revert receipt for another project is unknown and is never retried', asy
// Catch a route being added ahead of the granted-user owner fence: neither transport may be used. // Catch a route being added ahead of the granted-user owner fence: neither transport may be used.
test('a granted-user HTTP session cannot reach owner library callbacks', async t => { test('a granted-user HTTP session cannot reach owner library callbacks', async t => {
let ownerCalls = 0, userCalls = 0; let ownerCalls = 0, userCalls = 0;
const sso = { publicHost: 'console.example', publicOrigin: 'https://console.example', const access = { accessVersion: 1, revision: 1,
formTargets: () => [], session: () => ({ name: 'Alice', user: 'alice' }) }; users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
const server = await startServer({ port: 0, sso, send: async () => ({}), grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] };
const users = consoleAccess({ load: () => access, business: () => 'demo' });
const auth = await createSsoSession(t, { host: 'console.example', users });
const server = await startServer({ port: 0, sso: auth.sso, send: async () => ({}),
libraryStatus: async () => { ownerCalls++; return STATUS; }, libraryStatus: async () => { ownerCalls++; return STATUS; },
libraryRevert: async () => { ownerCalls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; }, libraryRevert: async () => { ownerCalls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; },
userOptions: () => { userCalls++; return {}; }, log: () => {} }); userOptions: () => { userCalls++; return {}; }, log: () => {} });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
const s = { port: server.address().port }; const session = await auth.signIn(server.address().port, { sub: 'alice-sub' });
const s = { port: server.address().port, headers: auth.writeHeaders(session) };
for (const options of [{ method: 'GET', path: '/api/library/status?project=books', data: null }, {}]) { for (const options of [{ method: 'GET', path: '/api/library/status?project=books', data: null }, {}]) {
const r = await call(s, options); const r = await call(s, options);
assert.equal(r.status, 403); assert.equal(r.status, 403);
@@ -177,7 +184,7 @@ test('a configured revert callback remains unreachable when send is off', async
const server = await startServer({ port: 0, preview, const server = await startServer({ port: 0, preview,
libraryRevert: async () => { calls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; } }); libraryRevert: async () => { calls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; } });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
const r = await call({ port: server.address().port }); const r = await call({ port: server.address().port, headers: { host: preview.host, 'x-user': preview.user, origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(r.status, 403); assert.equal(r.status, 403);
assert.equal(r.body.error, 'read-only'); assert.equal(r.body.error, 'read-only');
assert.equal(r.body.sent, false); assert.equal(r.body.sent, false);
+7 -6
View File
@@ -5,6 +5,7 @@ import {request} from 'node:http';
import {startServer} from '../src/serve.mjs'; import {startServer} from '../src/serve.mjs';
import {close} from './fixture.mjs'; import {close} from './fixture.mjs';
import {views} from '../../bus/src/views.mjs'; import {views} from '../../bus/src/views.mjs';
import {createSsoSession} from './helpers/sso-session.mjs';
const SINCE = '2026-10-18T07:00:00.000Z'; const SINCE = '2026-10-18T07:00:00.000Z';
const TOKENS = [ const TOKENS = [
@@ -36,9 +37,7 @@ test('REQ-LIMIT-3 a limits answer without a token list is refused, not shown as
} }
}); });
const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; function post(port, headers, data) {
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
function post(port, data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port, path: '/api/settings', method: 'POST', headers }, r => { const req = request({ hostname: '127.0.0.1', port, path: '/api/settings', method: 'POST', headers }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, text })); let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, text }));
@@ -50,14 +49,16 @@ function post(port, data) {
test('REQ-LIMIT-3 a Settings reset reaches the human write with its paths; a reset alone is enough', async t => { test('REQ-LIMIT-3 a Settings reset reaches the human write with its paths; a reset alone is enough', async t => {
const calls = []; const calls = [];
const server = await startServer({ port: 0, preview, send: async () => ({}), settingsWrite: async args => { calls.push(args); return { ...args, business: 'acme' }; } }); const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), settingsWrite: async args => { calls.push(args); return { ...args, business: 'acme' }; } });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
const port = server.address().port; const port = server.address().port;
const session = await auth.signIn(port), headers = auth.writeHeaders(session);
const reset = { revision: 'a'.repeat(64), reset: [['tokens', 'instances', 'coder']] }; const reset = { revision: 'a'.repeat(64), reset: [['tokens', 'instances', 'coder']] };
const r = await post(port, reset); const r = await post(port, headers, reset);
assert.equal(r.status, 200, r.text); assert.equal(r.status, 200, r.text);
assert.deepEqual(calls, [reset]); assert.deepEqual(calls, [reset]);
assert.equal((await post(port, { revision: 'a'.repeat(64), resets: [] })).status, 400); assert.equal((await post(port, headers, { revision: 'a'.repeat(64), resets: [] })).status, 400);
assert.equal(calls.length, 1); assert.equal(calls.length, 1);
}); });
@@ -0,0 +1,50 @@
// Q284 / #385: returning header-preview writer support must fail this regression.
// No OIDC, broker, credentials or host configuration are constructed by these tests.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { startServer } from '../src/serve.mjs';
const preview = { host: 'console.example.test', header: 'x-authentik-username', user: 'jason' };
const cli = new URL('../src/cli.mjs', import.meta.url);
const service = new URL('../../../scripts/webui-service.sh', import.meta.url);
const send = async () => { assert.fail('refused configuration must never invoke its writer'); };
for (const [name, proof] of [['without proof', null], ['with proof', { header: 'x-console-proof', value: Buffer.from('fixture-proof-value-that-does-not-authenticate-a-human') }]]) {
test(`header-preview cannot bind a human writer ${name}`, async () => {
let web;
try {
web = await startServer({ port: 0, preview, proof, send });
assert.fail('header-preview writer configuration was accepted');
} catch (error) {
assert.match(error.message, /--send needs --sso/);
} finally {
if (web) await new Promise(resolve => web.close(resolve));
}
});
}
test('CLI refuses header-preview writes before loading the system configuration', () => {
const env = { ...process.env, MOSAIC_CONFIG: '/fixture-q284-missing-config/config.json' };
for (const flags of [['--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user], []]) {
const r = spawnSync(process.execPath, [cli.pathname, 'serve', '--port', '0', '--send', ...flags], { env, encoding: 'utf8', timeout: 5000 });
assert.equal(r.error, undefined);
assert.equal(r.status, 2);
assert.match(r.stderr, /refused: --send needs --sso/);
assert.equal(r.stdout, '');
assert.doesNotMatch(r.stderr, /fixture-q284-missing-config/);
}
});
test('read-only preview can still bind without any human writer', async () => {
const web = await startServer({ port: 0, preview });
await new Promise(resolve => web.close(resolve));
});
test('service renderer cannot produce a header-preview human-writer unit', () => {
const r = spawnSync(service.pathname, ['render', '--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'], { env: { PATH: '/usr/bin:/bin', HOME: '/fixture-q284-no-home' }, encoding: 'utf8', timeout: 5000 });
assert.equal(r.error, undefined);
assert.equal(r.status, 1);
assert.match(r.stderr, /--send needs --sso/);
assert.equal(r.stdout, '');
});
+15 -16
View File
@@ -7,12 +7,13 @@ import { tmpdir } from 'node:os';
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs'; import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const GEN = '12345678-1234-4234-8234-123456789abc'; const GEN = '12345678-1234-4234-8234-123456789abc';
function http(server, path, { method = 'GET', headers = {}, body = '' } = {}) { function http(server, path, { method = 'GET', headers = {}, body = '' } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const address = server.address(); const address = server.address();
const req = request({ ...(typeof address === 'string' ? { socketPath: address } : { host: '127.0.0.1', port: address.port }), path, method, const req = request({ ...(typeof address === 'string' ? { socketPath: address } : { host: '127.0.0.1', port: address.port }), path, method,
headers: Object.fromEntries(Object.entries({ host: 'console.example', 'x-user': 'jason', ...headers }).filter(([, v]) => v !== undefined)) }, res => { headers: Object.fromEntries(Object.entries({ ...(server.authHeaders ?? { host: 'console.example' }), ...headers }).filter(([, v]) => v !== undefined)) }, res => {
let text = ''; res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) })); let text = ''; res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
}); req.on('error', reject); req.end(body); }); req.on('error', reject); req.end(body);
}); });
@@ -28,9 +29,13 @@ async function fixture(t, { send = true, tester = false } = {}) {
}; };
const root = tester ? mkdtempSync(join(tmpdir(), 'processing-web-')) : null; const root = tester ? mkdtempSync(join(tmpdir(), 'processing-web-')) : null;
const options = { processing, log: () => {} }; const options = { processing, log: () => {} };
const auth = tester ? null : await createSsoSession(t, { host: 'console.example' });
const server = tester ? await startTesterSocket({ dataRoot: root, ...options }) : await startServer({ port: 0, const server = tester ? await startTesterSocket({ dataRoot: root, ...options }) : await startServer({ port: 0,
preview: { host: 'console.example', header: 'x-user', user: 'jason' }, sso: auth.sso, send: send ? async () => {} : null, ...options });
send: send ? async () => {} : null, ...options }); if (auth) {
const session = await auth.signIn(server.address().port);
server.authHeaders = auth.writeHeaders(session);
}
t.after(async () => { await close(server); if (root) rmSync(root, { recursive: true, force: true }); }); t.after(async () => { await close(server); if (root) rmSync(root, { recursive: true, force: true }); });
return { server, calls, state, processing }; return { server, calls, state, processing };
} }
@@ -55,8 +60,8 @@ test('processing write fences refuse cross-site, wrong origin, unnamed host, dup
['wrong origin', { headers: { ...POST.headers, origin: 'https://elsewhere' } }, 403, 'cross-origin request refused'], ['wrong origin', { headers: { ...POST.headers, origin: 'https://elsewhere' } }, 403, 'cross-origin request refused'],
['cross-site', { headers: { ...POST.headers, 'sec-fetch-site': 'cross-site' } }, 403, 'cross-site'], ['cross-site', { headers: { ...POST.headers, 'sec-fetch-site': 'cross-site' } }, 403, 'cross-site'],
['unnamed host', { headers: { ...POST.headers, host: `127.0.0.1:${f.server.address().port}`, ['unnamed host', { headers: { ...POST.headers, host: `127.0.0.1:${f.server.address().port}`,
origin: `http://127.0.0.1:${f.server.address().port}`, 'x-user': undefined } }, 403, 'read-only'], origin: `http://127.0.0.1:${f.server.address().port}` } }, 403, 'public Host required'],
['duplicate identity', { headers: { ...POST.headers, 'x-user': ['jason', 'jason'] } }, 401, 'sign-in required'], ['invalid session', { headers: { ...POST.headers, cookie: '__Host-mosaic_session=invalid' } }, 401, 'sign-in required'],
['content type', { headers: { ...POST.headers, 'content-type': 'text/plain' } }, 415, 'invalid-request'], ['content type', { headers: { ...POST.headers, 'content-type': 'text/plain' } }, 415, 'invalid-request'],
['extra key', { body: '{"human":"jason"}' }, 400, 'invalid-request'], ['extra key', { body: '{"human":"jason"}' }, 400, 'invalid-request'],
['array', { body: '[]' }, 400, 'invalid-request'], ['array', { body: '[]' }, 400, 'invalid-request'],
@@ -85,20 +90,14 @@ test('tester cannot invoke either control writer and failures never echo backend
}); });
// Rebase on row 192: the server verifies the cookie before passing its signer. // Rebase on row 192: the server verifies the cookie before passing its signer.
test('processing writes use the existing SSO signer and still require --send and same-origin', async t => { test('processing writes use a real OIDC session and still require --send and same-origin', async t => {
for (const enabled of [false, true]) { for (const enabled of [false, true]) {
const calls = []; const f = await fixture(t, { send: enabled }), calls = f.calls, server = f.server;
const sso = { formTargets: () => [], publicHost: 'console.example', publicOrigin: 'https://console.example', const headers = { ...POST.headers };
session: req => req.headers.cookie === 'fixture=authenticated' ? { name: 'jason' } : null };
const state = { version: 1, generation: GEN, halted: true, quiescent: true };
const server = await startServer({ port: 0, sso, send: enabled ? async () => {} : null,
processing: { halt: async () => { calls.push('halt'); return state; } } });
t.after(() => close(server));
const headers = { ...POST.headers, cookie: 'fixture=authenticated', 'x-user': undefined };
assert.equal((await http(server, '/api/processing/halt', { ...POST, headers })).status, enabled ? 200 : 403); assert.equal((await http(server, '/api/processing/halt', { ...POST, headers })).status, enabled ? 200 : 403);
assert.equal((await http(server, '/api/processing/halt', { ...POST, headers: { ...headers, cookie: undefined } })).status, 401); assert.equal((await http(server, '/api/processing/halt', { ...POST, headers: { ...headers, cookie: '__Host-mosaic_session=invalid' } })).status, 401);
assert.equal((await http(server, '/api/processing/halt', { ...POST, headers: { ...headers, origin: 'https://elsewhere' } })).status, 403); assert.equal((await http(server, '/api/processing/halt', { ...POST, headers: { ...headers, origin: 'https://elsewhere' } })).status, 403);
assert.deepEqual(calls, enabled ? ['halt'] : []); assert.deepEqual(calls, enabled ? [['halt', {}]] : []);
} }
}); });
+16 -13
View File
@@ -5,14 +5,15 @@ import assert from 'node:assert/strict';
import { request } from 'node:http'; import { request } from 'node:http';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.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 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 C1 = 'a'.repeat(40), C2 = 'b'.repeat(40); const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40);
const payload = { project: 'stack', path: 'spec/north-star.md', base: C1, content: '# Edited\n' }; const payload = { project: 'stack', path: 'spec/north-star.md', base: C1, content: '# Edited\n' };
async function setup(t, { enabled = true, save, lines = [] } = {}) { async function setup(t, { enabled = true, save, lines = [] } = {}) {
const calls = { files: [], file: [], save: [] }; const calls = { files: [], file: [], save: [] };
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ const server = await startServer({
port: 0, preview, log: l => lines.push(l), port: 0, sso: auth.sso, log: l => lines.push(l),
send: enabled ? async () => ({}) : null, send: enabled ? async () => ({}) : null,
projectFiles: async args => { calls.files.push(args); return { head: C1, files: [{ path: 'docs/README.md' }, { path: 'spec/north-star.md' }] }; }, projectFiles: async args => { calls.files.push(args); return { head: C1, files: [{ path: 'docs/README.md' }, { path: 'spec/north-star.md' }] }; },
projectFile: async args => { calls.file.push(args); return { path: args.path, head: C1, commit: C1, content: '# Spec\n', author: 'jason', at: '2026-10-16T00:00:00Z' }; }, projectFile: async args => { calls.file.push(args); return { path: args.path, head: C1, commit: C1, content: '# Spec\n', author: 'jason', at: '2026-10-16T00:00:00Z' }; },
@@ -20,9 +21,10 @@ async function setup(t, { enabled = true, save, lines = [] } = {}) {
projectSave: enabled ? async args => { calls.save.push(args); return save ? save(args) : { path: args.path, commit: C2 }; } : null, projectSave: enabled ? async args => { calls.save.push(args); return save ? save(args) : { path: args.path, commit: C2 }; } : null,
}); });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
return { calls, lines, port: server.address().port }; const session = await auth.signIn(server.address().port);
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
} }
function call(s, { method = 'POST', path = '/api/project-file', hs = headers, data = payload } = {}) { function call(s, { method = 'POST', path = '/api/project-file', hs = s.headers, data = payload } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; let text = '';
@@ -33,7 +35,7 @@ function call(s, { method = 'POST', path = '/api/project-file', hs = headers, da
req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data)); req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
}); });
} }
const get = (s, path, hs = headers) => call(s, { method: 'GET', path, hs, data: null }); const get = (s, path, hs = s.headers) => call(s, { method: 'GET', path, hs, data: null });
test('a signed-in same-origin save reaches exactly one human operation and returns the new commit', async t => { test('a signed-in same-origin save reaches exactly one human operation and returns the new commit', async t => {
const s = await setup(t), r = await call(s); const s = await setup(t), r = await call(s);
@@ -49,9 +51,9 @@ test('a save needs send, the named Host, the user, the exact Origin and same-ori
const refused = await call(off); const refused = await call(off);
assert.equal(refused.status, 403); assert.equal(refused.body.sent, false); assert.equal(off.calls.save.length, 0); assert.equal(refused.status, 403); assert.equal(refused.body.sent, false); assert.equal(off.calls.save.length, 0);
const s = await setup(t); const s = await setup(t);
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403], for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403],
['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['x-user', ['jason', 'jason'], 401]]) { ['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['cookie', undefined, 401]]) {
const hs = { ...headers }; const hs = { ...s.headers };
if (value === undefined) delete hs[key]; else hs[key] = value; if (value === undefined) delete hs[key]; else hs[key] = value;
const r = await call(s, { hs }); const r = await call(s, { hs });
assert.equal(r.status, status, key); assert.equal(r.status, status, key);
@@ -64,7 +66,7 @@ test('a save validates JSON, strict keys, text values and the 1 MiB body before
const s = await setup(t); const s = await setup(t);
for (const data of [{ ...payload, extra: 1 }, { project: 'stack', path: payload.path, base: C1 }, { ...payload, content: 5 }, { ...payload, base: null }, [], 'null', 'bad', '{'] ) for (const data of [{ ...payload, extra: 1 }, { project: 'stack', path: payload.path, base: C1 }, { ...payload, content: 5 }, { ...payload, base: null }, [], 'null', 'bad', '{'] )
assert.equal((await call(s, { data })).status, 400, JSON.stringify(data)); assert.equal((await call(s, { data })).status, 400, JSON.stringify(data));
assert.equal((await call(s, { hs: { ...headers, 'content-type': 'text/plain' } })).status, 400); assert.equal((await call(s, { hs: { ...s.headers, 'content-type': 'text/plain' } })).status, 400);
assert.equal((await call(s, { data: { ...payload, content: 'x'.repeat(1024 * 1024) } })).status, 400, 'over 1 MiB with the envelope'); assert.equal((await call(s, { data: { ...payload, content: 'x'.repeat(1024 * 1024) } })).status, 400, 'over 1 MiB with the envelope');
assert.equal(s.calls.save.length, 0); assert.equal(s.calls.save.length, 0);
const near = await call(s, { data: { ...payload, content: 'x'.repeat(1024 * 1024 - 400) } }); const near = await call(s, { data: { ...payload, content: 'x'.repeat(1024 * 1024 - 400) } });
@@ -116,7 +118,7 @@ test('the file list and one file read take strict queries and arrive in the expe
'/api/project-file?project=stack&path=..%2Fx', '/api/project-file?project=stack&path=a&extra=1', '/api/project-files?project=%20x']) '/api/project-file?project=stack&path=..%2Fx', '/api/project-file?project=stack&path=a&extra=1', '/api/project-files?project=%20x'])
assert.equal((await get(s, path)).status, 400, path); assert.equal((await get(s, path)).status, 400, path);
assert.equal((await call(s, { method: 'PUT', path: '/api/project-files', data: '{}' })).status, 403); assert.equal((await call(s, { method: 'PUT', path: '/api/project-files', data: '{}' })).status, 403);
assert.equal((await get(s, '/api/project-files?project=stack', { ...headers, 'x-user': 'other' })).status, 401); assert.equal((await get(s, '/api/project-files?project=stack', { ...s.headers, cookie: '__Host-mosaic_session=invalid' })).status, 401);
}); });
test('reads work without --send; an unusable bus answer is a bad response, a refusal keeps its status', async t => { test('reads work without --send; an unusable bus answer is a bad response, a refusal keeps its status', async t => {
@@ -124,17 +126,18 @@ test('reads work without --send; an unusable bus answer is a bad response, a ref
assert.equal((await get(s, '/api/project-files?project=stack')).status, 200); assert.equal((await get(s, '/api/project-files?project=stack')).status, 200);
const bad = await startServer({ port: 0, preview, log() {}, projectFile: async () => ({ content: 5 }), projectFiles: async () => { throw new BusReadError('project-not-declared'); } }); const bad = await startServer({ port: 0, preview, log() {}, projectFile: async () => ({ content: 5 }), projectFiles: async () => { throw new BusReadError('project-not-declared'); } });
t.after(() => new Promise(r => bad.close(r))); t.after(() => new Promise(r => bad.close(r)));
const b = { port: bad.address().port }; const previewHeaders = { host: preview.host, 'x-user': preview.user };
const b = { port: bad.address().port, headers: previewHeaders };
assert.equal((await get(b, '/api/project-file?project=stack&path=docs%2Fa.md')).status, 502); assert.equal((await get(b, '/api/project-file?project=stack&path=docs%2Fa.md')).status, 502);
assert.equal((await get(b, '/api/project-files?project=nope')).status, 400); assert.equal((await get(b, '/api/project-files?project=nope')).status, 400);
const none = await startServer({ port: 0, preview, log() {} }); const none = await startServer({ port: 0, preview, log() {} });
t.after(() => new Promise(r => none.close(r))); t.after(() => new Promise(r => none.close(r)));
assert.equal((await get({ port: none.address().port }, '/api/project-files?project=stack')).status, 503); assert.equal((await get({ port: none.address().port, headers: previewHeaders }, '/api/project-files?project=stack')).status, 503);
}); });
test('/api/config says edit only where a save can pass', async t => { test('/api/config says edit only where a save can pass', async t => {
const on = await setup(t), off = await setup(t, { enabled: false }); const on = await setup(t), off = await setup(t, { enabled: false });
assert.equal((await get(on, '/api/config')).body.edit, true); assert.equal((await get(on, '/api/config')).body.edit, true);
assert.equal((await get(off, '/api/config')).body.edit, false); assert.equal((await get(off, '/api/config')).body.edit, false);
assert.equal((await get(on, '/api/config', { host: `127.0.0.1:${on.port}` })).body.edit, false); assert.equal((await get(on, '/api/config', { ...on.headers, host: `127.0.0.1:${on.port}` })).status, 403);
}); });
+4 -4
View File
@@ -27,18 +27,18 @@ async function setup(t) {
const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); }); const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); });
await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board)); await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board));
const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`, const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) }); bus: { tasks: async () => { calls.push('tasks'); return []; } }, log: line => logs.push(line) });
t.after(() => close(server)); t.after(() => close(server));
return { port: server.address().port, calls, logs, proof, file }; return { port: server.address().port, calls, logs, proof, file };
} }
test('proxy proof admits named reads and sends, reads its fixture once, and never exposes the value', async t => { test('proxy proof admits named reads but preview remains read-only, reads its fixture once, and never exposes the value', async t => {
const f = await setup(t), headers = { [HEADER]: VALUE }; const f = await setup(t), headers = { [HEADER]: VALUE };
writeFileSync(f.file, 'changed_fixture_value_012345678901234567890'); writeFileSync(f.file, 'changed_fixture_value_012345678901234567890');
assert.equal((await ask(f.port, { headers })).status, 200); assert.equal((await ask(f.port, { headers })).status, 200);
assert.equal((await ask(f.port, { headers, path: '/api/board' })).status, 200); assert.equal((await ask(f.port, { headers, path: '/api/board' })).status, 200);
const sent = await ask(f.port, { headers: { ...headers, origin: `https://${HOST}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, method: 'POST', path: '/api/bus/message', body: JSON.stringify({ to: 'coder', body: 'fixture' }) }); const sent = await ask(f.port, { headers: { ...headers, origin: `https://${HOST}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, method: 'POST', path: '/api/bus/message', body: JSON.stringify({ to: 'coder', body: 'fixture' }) });
assert.equal(sent.status, 200); assert.equal(sent.body.id, 'receipt-1'); assert.equal(sent.status, 403); assert.equal(sent.body.error, 'read-only');
assert.deepEqual(f.calls, ['tasks', 'board', 'send']); assert.deepEqual(f.calls, ['tasks', 'board']);
const config = await ask(f.port, { headers, path: '/api/config' }); const config = await ask(f.port, { headers, path: '/api/config' });
assert.equal(config.status, 200); assert.ok(!config.text.includes(VALUE)); assert.equal(config.status, 200); assert.ok(!config.text.includes(VALUE));
assert.ok(!f.logs.join('\n').includes(VALUE)); assert.ok(!f.logs.join('\n').includes(VALUE));
+6 -2
View File
@@ -10,6 +10,7 @@ import { fakeManagement } from '../../bus/tests/proxy-page-fixture.mjs';
import { Store } from '../../bus/src/store.mjs'; import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs'; import { Broker } from '../../bus/src/broker.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const scan = root => readdirSync(root, { withFileTypes: true }).flatMap(e => e.isDirectory() ? scan(join(root, e.name)) : [readFileSync(join(root, e.name))]); const scan = root => readdirSync(root, { withFileTypes: true }).flatMap(e => e.isDirectory() ? scan(join(root, e.name)) : [readFileSync(join(root, e.name))]);
test('API-key canary reaches fake once and zero responses/logs/receipts/store files', async t => { test('API-key canary reaches fake once and zero responses/logs/receipts/store files', async t => {
const canary = 'p4-canary-key'; const canary = 'p4-canary-key';
@@ -21,12 +22,15 @@ test('API-key canary reaches fake once and zero responses/logs/receipts/store fi
businesses: { demo: { id: 'demo', human: 'owner', roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } } }); businesses: { demo: { id: 'demo', human: 'owner', roles: { coder: { authority: { withinRole: [], crossRole: [] } } }, arbiters: { technical: 'coder', delivery: 'coder' } } } });
const cap = b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }); const cap = b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true });
const logs = [], output = []; const logs = [], output = [];
const server = await startServer({ port: 0, preview: { host: 'console.example', header: 'x-user', user: 'owner' }, const auth = await createSsoSession(t, { host: 'console.example', owner: { sub: 'owner-sub', name: 'owner' } });
const server = await startServer({ port: 0, sso: auth.sso,
send: async () => ({}), proxyCall: (verb, args) => b.request(cap, { verb, args }), log: line => logs.push(line) }); send: async () => ({}), proxyCall: (verb, args) => b.request(cap, { verb, args }), log: line => logs.push(line) });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
const session = await auth.signIn(server.address().port);
const headers = auth.writeHeaders(session);
const call = (path, data) => new Promise((resolve, reject) => { const call = (path, data) => new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: server.address().port, path, method: data ? 'POST' : 'GET', const req = request({ hostname: '127.0.0.1', port: server.address().port, path, method: data ? 'POST' : 'GET',
headers: { host: 'console.example', 'x-user': 'owner', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, res => { headers }, res => {
let text = ''; res.on('data', c => text += c); res.on('end', () => { output.push(text); resolve(res.statusCode); }); let text = ''; res.on('data', c => text += c); res.on('end', () => { output.push(text); resolve(res.statusCode); });
}); req.on('error', reject); req.end(data ? JSON.stringify(data) : undefined); }); req.on('error', reject); req.end(data ? JSON.stringify(data) : undefined);
}); });
+24 -14
View File
@@ -4,9 +4,8 @@ import assert from 'node:assert/strict';
import { request } from 'node:http'; import { request } from 'node:http';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { consoleBus } from '../src/console-bus.mjs'; import { consoleBus } from '../src/console-bus.mjs';
const preview = { host: 'console.example', header: 'x-user', user: 'owner' }; import { consoleAccess } from '../src/access.mjs';
const headers = { host: preview.host, 'x-user': 'owner', origin: 'https://console.example', import { createSsoSession } from './helpers/sso-session.mjs';
'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const routes = [ const routes = [
['accounts', 'GET', 'proxy.accounts', {}], ['keys', 'GET', 'proxy.keys', {}], ['accounts', 'GET', 'proxy.accounts', {}], ['keys', 'GET', 'proxy.keys', {}],
['login/start', 'POST', 'proxy.login.start', { provider: 'claude' }], ['login/start', 'POST', 'proxy.login.start', { provider: 'claude' }],
@@ -16,7 +15,7 @@ const routes = [
['apikey/add', 'POST', 'proxy.apikey.add', { provider: 'z.ai', key: 'p4-canary-key' }], ['apikey/add', 'POST', 'proxy.apikey.add', { provider: 'z.ai', key: 'p4-canary-key' }],
['priority/set', 'POST', 'proxy.priority.set', { accountId: 'a'.repeat(12), priority: 5 }], ['priority/set', 'POST', 'proxy.priority.set', { accountId: 'a'.repeat(12), priority: 5 }],
]; ];
const call = (s, path, method = 'GET', value, hs = headers) => new Promise((resolve, reject) => { const call = (s, path, method = 'GET', value, hs = s.headers) => new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.address().port, path, method, headers: hs }, res => { const req = request({ hostname: '127.0.0.1', port: s.address().port, path, method, headers: hs }, res => {
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text })); let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text }));
}); });
@@ -24,10 +23,13 @@ const call = (s, path, method = 'GET', value, hs = headers) => new Promise((reso
}); });
async function setup(t, options = {}) { async function setup(t, options = {}) {
const calls = [], logs = []; const calls = [], logs = [];
const s = await startServer({ port: 0, preview, send: async () => ({}), const auth = await createSsoSession(t, { host: 'console.example', owner: { sub: 'owner-sub', name: 'owner' } });
const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}),
proxyCall: async (verb, args) => { calls.push([verb, args]); return { ok: true }; }, proxyCall: async (verb, args) => { calls.push([verb, args]); return { ok: true }; },
log: l => logs.push(l), ...options }); log: l => logs.push(l), ...options });
t.after(() => new Promise(r => s.close(r))); t.after(() => new Promise(r => s.close(r)));
const session = await auth.signIn(s.address().port);
s.headers = auth.writeHeaders(session);
return { s, calls, logs }; return { s, calls, logs };
} }
test('owner proxy routes each make exactly one allowlisted call', async t => { test('owner proxy routes each make exactly one allowlisted call', async t => {
@@ -40,13 +42,14 @@ test('owner proxy routes each make exactly one allowlisted call', async t => {
assert.equal(calls.length, routes.length); assert.equal(calls.length, routes.length);
for (const value of ['p4-canary-key', 'fixture', 'redirect_url']) assert.equal(logs.join('\n').includes(value), false); for (const value of ['p4-canary-key', 'fixture', 'redirect_url']) assert.equal(logs.join('\n').includes(value), false);
}); });
for (const [name, hs, code] of [ for (const [name, change, code] of [
['signed-out', { ...headers, 'x-user': 'other' }, 401], ['signed-out', hs => { delete hs.cookie; }, 401],
['loopback', { ...headers, host: '127.0.0.1' }, 403], ['loopback', hs => { hs.host = '127.0.0.1'; }, 403],
['cross-origin', { ...headers, origin: 'https://other.example' }, 403], ['cross-origin', hs => { hs.origin = 'https://other.example'; }, 403],
['missing Fetch Metadata', Object.fromEntries(Object.entries(headers).filter(([k]) => k !== 'sec-fetch-site')), 403], ['missing Fetch Metadata', hs => { delete hs['sec-fetch-site']; }, 403],
]) test(`proxy write refuses ${name}`, async t => { ]) test(`proxy write refuses ${name}`, async t => {
const { s, calls } = await setup(t); const { s, calls } = await setup(t);
const hs = { ...s.headers }; change(hs);
assert.equal((await call(s, '/api/proxy/apikey/add', 'POST', { provider: 'z.ai', key: 'fixture-key' }, hs)).status, code); assert.equal((await call(s, '/api/proxy/apikey/add', 'POST', { provider: 'z.ai', key: 'fixture-key' }, hs)).status, code);
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
@@ -57,9 +60,16 @@ test('all proxy writes are read-only without --send', async t => {
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('granted user never reaches owner page or any proxy route', async t => { test('granted user never reaches owner page or any proxy route', async t => {
const sso = { publicHost: preview.host, publicOrigin: headers.origin, formTargets: () => [], const access = { accessVersion: 1, revision: 1,
session: () => ({ name: 'Alice', user: 'alice' }) }; users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
const { s, calls } = await setup(t, { preview: null, sso, userOptions: () => { throw Error('must not bind'); } }); grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] };
const users = consoleAccess({ load: () => access, business: () => 'demo' });
const auth = await createSsoSession(t, { host: 'console.example', users });
const calls = [];
const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), proxyCall: async (...args) => { calls.push(args); return {}; }, userOptions: () => { throw Error('must not bind'); } });
t.after(() => new Promise(resolve => s.close(resolve)));
const session = await auth.signIn(s.address().port, { sub: 'alice-sub' });
s.headers = auth.writeHeaders(session);
assert.equal((await call(s, '/proxy')).status, 403); assert.equal((await call(s, '/proxy')).status, 403);
for (const [path, method, , args] of routes) for (const [path, method, , args] of routes)
assert.equal((await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined)).status, 403, path); assert.equal((await call(s, '/api/proxy/' + path, method, method === 'POST' ? args : undefined)).status, 403, path);
@@ -105,7 +115,7 @@ test('malformed API-key JSON never appears in errors or logs', async t => {
const { s, calls, logs } = await setup(t); const { s, calls, logs } = await setup(t);
const canary = 'p4-canary-key'; const canary = 'p4-canary-key';
const r = await new Promise((resolve, reject) => { const r = await new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.address().port, path: '/api/proxy/apikey/add', method: 'POST', headers }, res => { const req = request({ hostname: '127.0.0.1', port: s.address().port, path: '/api/proxy/apikey/add', method: 'POST', headers: s.headers }, res => {
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text })); let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, text }));
}); req.on('error', reject); req.end('{"key":"' + canary); }); req.on('error', reject); req.end('{"key":"' + canary);
}); });
+6 -4
View File
@@ -4,9 +4,9 @@ import { request } from 'node:http';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { createChat } from '../src/chat.mjs'; import { createChat } from '../src/chat.mjs';
import { BusReadError } from '../src/bus.mjs'; import { BusReadError } from '../src/bus.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const P = '10000000-0000-4000-8000-000000000001', T = '20000000-0000-4000-8000-000000000001'; const P = '10000000-0000-4000-8000-000000000001', T = '20000000-0000-4000-8000-000000000001';
const M = '30000000-0000-4000-8000-000000000001', N = '30000000-0000-4000-8000-000000000002'; const M = '30000000-0000-4000-8000-000000000001', N = '30000000-0000-4000-8000-000000000002';
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const lines = { const lines = {
'unknown-addressee': 'Unknown addressee. Choose a room member.', 'unknown-addressee': 'Unknown addressee. Choose a room member.',
'addressee-ambiguous': "That addressee is ambiguous. Use a room member's instance name.", 'addressee-ambiguous': "That addressee is ambiguous. Use a room member's instance name.",
@@ -15,21 +15,23 @@ const lines = {
}; };
async function setup(t, { enabled = true } = {}) { async function setup(t, { enabled = true } = {}) {
const calls = [], state = { value: null }; const calls = [], state = { value: null };
const auth = await createSsoSession(t, { host: 'console.example' });
const chat = createChat({ call: async (verb, args) => { const chat = createChat({ call: async (verb, args) => {
if (verb === 'plugins') return { plugins: { channel: ['webui'] } }; if (verb === 'plugins') return { plugins: { channel: ['webui'] } };
calls.push([verb, args]); calls.push([verb, args]);
if (state.value instanceof Error) throw state.value; if (state.value instanceof Error) throw state.value;
return state.value; return state.value;
} }); } });
const server = await startServer({ port: 0, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, send: enabled ? async () => ({}) : null, chat, log: () => {} }); const server = await startServer({ port: 0, sso: auth.sso, send: enabled ? async () => ({}) : null, chat, log: () => {} });
t.after(() => new Promise(r => { server.close(r); server.closeIdleConnections(); })); t.after(() => new Promise(r => { server.close(r); server.closeIdleConnections(); }));
const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session);
const call = (action, { method = 'GET', value, hs = headers, query = '' } = {}) => new Promise((resolve, reject) => { const call = (action, { method = 'GET', value, hs = headers, query = '' } = {}) => new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port: server.address().port, path: `/api/chat/rooms/${P}/${action}${query}`, method, headers: hs }, res => { const req = request({ host: '127.0.0.1', port: server.address().port, path: `/api/chat/rooms/${P}/${action}${query}`, method, headers: hs }, res => {
let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) })); let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}); });
req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value)); req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value));
}); });
return { calls, state, call, chat }; return { calls, state, call, chat, headers };
} }
test('C2-10c seam exposes three fixed room calls without thread.messages fanout', async t => { test('C2-10c seam exposes three fixed room calls without thread.messages fanout', async t => {
const f = await setup(t); const f = await setup(t);
@@ -68,7 +70,7 @@ test('C2-10c room write fences and strict keys reject before transport', async t
assert.equal((await f.call('messages', { method: 'POST', value })).status, 400); assert.equal((await f.call('messages', { method: 'POST', value })).status, 400);
for (const query of ['?limit=0', '?limit=201', '?after=x', '?project=other', `?after=${M}&after=${N}`]) for (const query of ['?limit=0', '?limit=201', '?after=x', '?project=other', `?after=${M}&after=${N}`])
assert.equal((await f.call('messages', { query })).status, 400); assert.equal((await f.call('messages', { query })).status, 400);
for (const hs of [{ ...headers, origin: 'https://evil.example' }, { ...headers, 'sec-fetch-site': 'cross-site' }, { ...headers, 'x-user': 'other' }]) for (const hs of [{ ...f.headers, origin: 'https://evil.example' }, { ...f.headers, 'sec-fetch-site': 'cross-site' }, Object.fromEntries(Object.entries(f.headers).filter(([key]) => key !== 'cookie'))])
assert.ok([401, 403].includes((await f.call('open', { method: 'POST', value: {}, hs })).status)); assert.ok([401, 403].includes((await f.call('open', { method: 'POST', value: {}, hs })).status));
assert.deepEqual(f.calls, []); assert.deepEqual(off.calls, []); assert.deepEqual(f.calls, []); assert.deepEqual(off.calls, []);
}); });
+6 -5
View File
@@ -16,11 +16,10 @@ import {BusError} from '../../bus/src/broker.mjs';
import {startBroker} from '../../bus/src/runtime.mjs'; import {startBroker} from '../../bus/src/runtime.mjs';
import {systemFor} from '../../business/tests/helpers.mjs'; import {systemFor} from '../../business/tests/helpers.mjs';
import {fixture,tmp} from '../../cli/tests/helpers.mjs'; import {fixture,tmp} from '../../cli/tests/helpers.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 KEYS=['harness','model','thinking','fallback.harness','fallback.model','fallback.thinking']; const KEYS=['harness','model','thinking','fallback.harness','fallback.model','fallback.thinking'];
const post=(port,data)=>new Promise((resolve,reject)=>{ const post=(port,headers,data)=>new Promise((resolve,reject)=>{
const req=request({hostname:'127.0.0.1',port,path:'/api/routes',method:'POST',headers},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,body:JSON.parse(text)}));}); const req=request({hostname:'127.0.0.1',port,path:'/api/routes',method:'POST',headers},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,body:JSON.parse(text)}));});
req.on('error',reject);req.end(JSON.stringify(data)); req.on('error',reject);req.end(JSON.stringify(data));
}); });
@@ -43,7 +42,9 @@ test('row 65 Settings: the routing table lists every instance, saves a harness,
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')}; const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}}); const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
const lines=[]; const lines=[];
const writer=await startServer({port:0,preview,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)}); const auth=await createSsoSession(t,{host:'console.example'});
const writer=await startServer({port:0,sso:auth.sso,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)});
const session=await auth.signIn(writer.address().port),headers=auth.writeHeaders(session);
const b=await browser(); const b=await browser();
try{ try{
// The stub holds each POST until the test answers it with what the write server said. // The stub holds each POST until the test answers it with what the write server said.
@@ -62,7 +63,7 @@ test('row 65 Settings: the routing table lists every instance, saves a harness,
// The last receipt is cleared here, so only the answer's redraw writes one. // The last receipt is cleared here, so only the answer's redraw writes one.
await b.evaluate('document.querySelector("#routes-receipt").textContent="";document.querySelector("#routes-form").requestSubmit();true'); await b.evaluate('document.querySelector("#routes-receipt").textContent="";document.querySelector("#routes-form").requestSubmit();true');
await wait(`posts.length>${sent}`);sent++; await wait(`posts.length>${sent}`);sent++;
const r=await post(writer.address().port,await b.evaluate('posts.at(-1)')); const r=await post(writer.address().port,headers,await b.evaluate('posts.at(-1)'));
await b.evaluate(`window.answer(${r.status},${JSON.stringify(r.body)});true`); await b.evaluate(`window.answer(${r.status},${JSON.stringify(r.body)});true`);
await wait('!document.querySelector("#routes-form button[type=submit]")?.textContent.startsWith("Saving")&&document.querySelector("#routes-receipt")?.textContent!==""'); await wait('!document.querySelector("#routes-form button[type=submit]")?.textContent.startsWith("Saving")&&document.querySelector("#routes-receipt")?.textContent!==""');
return r; return r;
+11 -10
View File
@@ -13,30 +13,31 @@ import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs'; import { startBroker } from '../../bus/src/runtime.mjs';
import { systemFor } from '../../business/tests/helpers.mjs'; import { systemFor } from '../../business/tests/helpers.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs'; import { fixture, tmp } from '../../cli/tests/helpers.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 REV = 'a'.repeat(64);
const ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low', fallback: null }; const ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low', fallback: null };
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), harnesses: ['pi'], models: ['glm-5.3-flash'], levels: { pi: ['low'] }, instances: { coder: ROUTE }, ...extra }); const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), harnesses: ['pi'], models: ['glm-5.3-flash'], levels: { pi: ['low'] }, instances: { coder: ROUTE }, ...extra });
async function setup(t, { on = true, writer = on, write = async () => view(), bus = null } = {}) { async function setup(t, { on = true, writer = on, write = async () => view(), bus = null } = {}) {
const calls = [], lines = []; const calls = [], lines = [];
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ const server = await startServer({
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null, port: 0, sso: auth.sso, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
routesWrite: writer ? async args => { calls.push(args); return write(args); } : null, routesWrite: writer ? async args => { calls.push(args); return write(args); } : null,
}); });
t.after(() => new Promise(r => server.close(r))); t.after(() => new Promise(r => server.close(r)));
return { calls, lines, port: server.address().port }; 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 = headers, method = 'POST') => new Promise((resolve, reject) => { 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 => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null })); let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
}); });
req.on('error', reject); req.on('error', reject);
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data)); req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
}); });
const get = (s, path) => send(s, path, undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET'); const get = (s, path) => send(s, path, undefined, s.headers, 'GET');
test('row 65 Console: the signed-in human saves a route through a real broker; each refusal says nothing changed and leaves the file and the log', async t => { test('row 65 Console: the signed-in human saves a route through a real broker; each refusal says nothing changed and leaves the file and the log', async t => {
const root = tmp(t, 'webui-routes-'); const root = tmp(t, 'webui-routes-');
@@ -105,12 +106,12 @@ test('row 65 Console: the routes write needs send on, a writer, the public name,
const unwritten = await setup(t, { writer: false }); const unwritten = await setup(t, { writer: false });
assert.equal((await send(unwritten, '/api/routes', data)).status, 403); assert.equal((await send(unwritten, '/api/routes', data)).status, 403);
const s = await setup(t); const s = await setup(t);
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) { for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['cookie', undefined, 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
const hs = { ...headers }; const hs = { ...s.headers };
if (value === undefined) delete hs[key]; else hs[key] = value; if (value === undefined) delete hs[key]; else hs[key] = value;
assert.equal((await send(s, '/api/routes', data, hs)).status, status, key); assert.equal((await send(s, '/api/routes', data, hs)).status, status, key);
} }
assert.equal((await send(s, '/api/routes', undefined, headers, 'GET')).status, 405); assert.equal((await send(s, '/api/routes', undefined, s.headers, 'GET')).status, 405);
assert.equal(s.calls.length, 0); assert.equal(s.calls.length, 0);
}); });
@@ -122,7 +123,7 @@ test('row 65 Console: the routes write takes JSON within 16 KiB with a revision
{ ...base, revision: 'stale' }, { ...base, revision: 7 }, { ...base, routes: {} }, { ...base, routes: [] }, { ...base, routes: null }, { ...base, revision: 'stale' }, { ...base, revision: 7 }, { ...base, routes: {} }, { ...base, routes: [] }, { ...base, routes: null },
[], 'bad', { ...base, routes: { coder: { ...ROUTE, model: 'x'.repeat(17000) } } }, [], 'bad', { ...base, routes: { coder: { ...ROUTE, model: 'x'.repeat(17000) } } },
]) assert.equal((await send(s, '/api/routes', value)).status, 400, JSON.stringify(value).slice(0, 60)); ]) assert.equal((await send(s, '/api/routes', value)).status, 400, JSON.stringify(value).slice(0, 60));
assert.equal((await send(s, '/api/routes', base, { ...headers, 'content-type': 'text/plain' })).status, 400); assert.equal((await send(s, '/api/routes', base, { ...s.headers, 'content-type': 'text/plain' })).status, 400);
assert.equal(s.calls.length, 0); assert.equal(s.calls.length, 0);
// A route with a model the bus refuses: the log names the instance and the code, never the route. // A route with a model the bus refuses: the log names the instance and the code, never the route.
const refused = await setup(t, { write: async () => { throw new BusReadError('unknown-model'); } }); const refused = await setup(t, { write: async () => { throw new BusReadError('unknown-model'); } });
+71 -95
View File
@@ -1,29 +1,28 @@
// Row 76 (#1570, lead decision 95): `serve --send`, a human message to a role from the Console. // Row 76 (#1570, lead decision 95): `serve --sso --send`, a human message to a role from the Console.
// Only with the preview flags; POST /api/bus/message needs the header on this request, its own // POST /api/bus/message needs a verified OIDC session on this request, its own Origin,
// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker // Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker
// refuses `to` and the body. An outcome the transport can't confirm is unknown and never retried. // refuses `to` and the body. An outcome the transport can't confirm is unknown and never retried.
// Every other write is still 403 read-only. Nothing here starts the real human transport. // Every other write is still 403 read-only. Nothing here starts the real human transport.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createServer, request } from 'node:http'; import { createServer, request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { BusReadError, humanCall } from '../src/bus.mjs'; import { BusReadError, humanCall } from '../src/bus.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { broker, fixture, tmp } from '../../cli/tests/helpers.mjs'; import { createSsoSession, SSO_HOST } from './helpers/sso-session.mjs';
import { broker, tmp } from '../../cli/tests/helpers.mjs';
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason'; const NAME = SSO_HOST;
const preview = { host: NAME, header: HEADER, user: ID };
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; const without = (headers, name) => Object.fromEntries(Object.entries(headers).filter(([k]) => k !== name));
const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
const msg = JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }); const msg = JSON.stringify({ to: 'coder', body: 'Please look at row 76.' });
// node:http, not fetch: fetch can't set Host or Sec-Fetch-Site. // node:http, not fetch: fetch can't set Host or Sec-Fetch-Site.
function post(port, { method = 'POST', path = '/api/bus/message', headers = ok, body = msg } = {}) { function post(port, { method = 'POST', path = '/api/bus/message', headers = {}, body = msg } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) }; 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 => { const req = request({ host: '127.0.0.1', port, method, path, headers: { host: NAME, ...length, ...headers } }, res => {
@@ -47,27 +46,32 @@ async function setup(t, options = {}) {
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }])); const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
const state = { answer: async () => ({ id: 'msg-0001' }) }; const state = { answer: async () => ({ id: 'msg-0001' }) };
const send = async args => { sends.push(args); return state.answer(args); }; const send = async args => { sends.push(args); return state.answer(args); };
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, send, log: l => lines.push(l), ...options }); const auth = await createSsoSession(t, { host: NAME, log: l => lines.push(l) });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, sso: auth.sso, send, log: l => lines.push(l), ...options });
t.after(() => close(web)); t.after(() => close(web));
return { port: web.address().port, boardHits, busCalls, sends, lines, state }; const port = web.address().port;
const session = await auth.signIn(port);
const headers = auth.writeHeaders(session);
return { port, post: (request = {}) => post(port, { headers, ...request }), headers, session, auth, boardHits, busCalls, sends, lines, state };
} }
test('startServer takes send only as a function and only with the preview mode', async () => { test('startServer takes send only as a function and only with verified OIDC', async t => {
for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: null }, { send: 'yes', preview }, { send: {}, preview }]) { const auth = await createSsoSession(t, { host: NAME });
for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: { host: NAME, header: 'x-user', user: 'jason' } }, { send: 'yes', sso: auth.sso }, { send: {}, sso: auth.sso }]) {
const result = await startServer({ port: 0, ...options }).catch(err => err); const result = await startServer({ port: 0, ...options }).catch(err => err);
if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${Object.keys(options)}`); } if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${Object.keys(options)}`); }
assert.match(result.message, /--send needs the preview mode/); assert.match(result.message, /--send needs --sso/);
} }
}); });
test('the CLI refuses --send without the preview flags, and without a configured bus', t => { test('the CLI refuses --send without --sso before loading a configured bus', t => {
const root = tmp(t, 'webui-send-cli-'); const root = tmp(t, 'webui-send-cli-');
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'no-such-config.json') }; const env = { ...process.env, MOSAIC_CONFIG: join(root, 'no-such-config.json') };
for (const [flags, why] of [ for (const [flags, why] of [
[['--send'], /^refused: --send needs the preview mode/], [['--send'], /^refused: --send needs --sso/],
[['--send', '--public-host', NAME, '--auth-header', HEADER], /^refused: preview mode needs/], [['--send', '--public-host', NAME, '--auth-header', 'x-user'], /^refused: preview mode needs/],
[['--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], /^refused: --send needs the bus configured \(/], [['--public-host', NAME, '--auth-header', 'x-user', '--allow-user', 'jason', '--send'], /^refused: --send needs --sso/],
[['--send', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID], /^refused: --send needs the bus configured \(/], [['--send', '--public-host', NAME, '--auth-header', 'x-user', '--allow-user', 'jason'], /^refused: --send needs --sso/],
]) { ]) {
const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000, env }); const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000, env });
assert.equal(result.status, 2, flags.join(' ')); assert.equal(result.status, 2, flags.join(' '));
@@ -77,82 +81,54 @@ test('the CLI refuses --send without the preview flags, and without a configured
} }
}); });
test('the CLI with --send and a configured bus sends through the human transport and logs one line', async t => {
// No bus host is running, so the send is a definite refusal: no-bus-host, nothing written.
const root = tmp(t, 'webui-send-cli-');
const { env } = fixture(root);
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { 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 ${HEADER}, reads and human messages to roles \\(--send\\); one line per send below$`, 'm'));
const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'secret-body-text' }) });
assert.equal(r.status, 503);
assert.deepEqual(r.body, { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false });
const SEND = /^\d{4}-\d\d-\d\dT[\d:.]+Z send /m;
for (let i = 0; i < 100 && !SEND.test(out); i++) await new Promise(res => setTimeout(res, 20));
const sendLines = out.split('\n').filter(l => SEND.test(l));
assert.equal(sendLines.length, 1, out);
assert.match(sendLines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" error=no-bus-host$/);
assert.equal(out.includes('secret-body-text'), false);
});
test('without --send the route is read-only like every other write, and config says no send', async t => { test('without --send the route is read-only like every other write, and config says no send', async t => {
const { port, sends, lines } = await setup(t, { send: null }); const { post, sends, lines } = await setup(t, { send: null });
const r = await post(port); const r = await post();
assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }); assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' });
const config = await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined }); const config = await post({ method: 'GET', path: '/api/config', body: undefined });
assert.equal(config.body.send, false); assert.equal(config.body.send, false);
assert.deepEqual(sends, []); assert.deepEqual(lines, []); assert.deepEqual(sends, []); assert.deepEqual(lines, []);
}); });
test('with --send every other write is still 403 read-only, and nothing reaches the board or bus', async t => { test('with --send every other write is still 403 read-only, and nothing reaches the board or bus', async t => {
const { port, sends, lines, boardHits, busCalls } = await setup(t); const { post, sends, lines, boardHits, busCalls } = await setup(t);
for (const [method, path] of [['POST', '/api/seen'], ['POST', '/api/reply'], ['POST', '/api/bus/inbox'], ['POST', '/api/bus/message/x'], ['POST', '/api/bus/messages'], ['POST', '/'], ['PUT', '/api/seen'], ['DELETE', '/api/reply'], ['PATCH', '/api/bus/inbox']]) { for (const [method, path] of [['POST', '/api/seen'], ['POST', '/api/reply'], ['POST', '/api/bus/inbox'], ['POST', '/api/bus/message/x'], ['POST', '/api/bus/messages'], ['POST', '/'], ['PUT', '/api/seen'], ['DELETE', '/api/reply'], ['PATCH', '/api/bus/inbox']]) {
const r = await post(port, { method, path }); const r = await post({ method, path });
assert.equal(r.status, 403, `${method} ${path}`); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }, `${method} ${path}`); assert.equal(r.status, 403, `${method} ${path}`); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }, `${method} ${path}`);
} }
assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]); assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]);
}); });
test('config says send only on the public name with --send on', async t => { test('config says send for the signed-in public name and refuses the loopback operator path', async t => {
const { port } = await setup(t); const { port, post, headers } = await setup(t);
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined })).body.send, true); assert.equal((await post({ method: 'GET', path: '/api/config', body: undefined })).body.send, true);
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { host: `127.0.0.1:${port}` }, body: undefined })).body.send, false); assert.equal((await post({ method: 'GET', path: '/api/config', headers: { ...headers, host: `127.0.0.1:${port}` }, body: undefined })).status, 403);
}); });
test('a send without the signed-in user is 401 and is neither sent nor logged, loopback included', async t => { test('a send without a verified OIDC session is 401 and is neither sent nor logged', async t => {
const { port, sends, lines } = await setup(t); const { post, headers, sends, lines } = await setup(t);
for (const [why, headers] of [ for (const [why, candidateHeaders] of [
['no header', without(HEADER)], ['no cookie', without(headers, 'cookie')],
['another user', { ...ok, [HEADER]: 'mallory' }], ['a forged legacy identity header', { ...without(headers, 'cookie'), 'x-authentik-username': 'jason' }],
['the id with a suffix', { ...ok, [HEADER]: ID + 'x' }], ['an invalid session', { ...headers, cookie: '__Host-mosaic_session=invalid' }],
['two values', { ...ok, [HEADER]: [ID, ID] }], ['two session cookies', { ...headers, cookie: `${headers.cookie}; ${headers.cookie}` }],
['loopback without the header', { ...without(HEADER), host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}` }],
['localhost, no Origin, no header', { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', host: `localhost:${port}` }],
]) { ]) {
const r = await post(port, { headers }); const r = await post({ headers: candidateHeaders });
assert.equal(r.status, 401, why); assert.deepEqual(r.body, { error: 'sign-in required' }, why); assert.equal(r.status, 401, why); assert.deepEqual(r.body, { error: 'sign-in required' }, why);
} }
// Loopback with the header is a proxy that rewrote Host: refused before the route (row 74).
const rewritten = await post(port, { headers: { ...ok, host: `127.0.0.1:${port}` } });
assert.equal(rewritten.status, 403); assert.equal(rewritten.body.error, 'proxy Host refused');
assert.deepEqual(sends, []); assert.deepEqual(lines, []); assert.deepEqual(sends, []); assert.deepEqual(lines, []);
}); });
test('each request check refuses before anything is sent, and logs the refusal without the body', async t => { test('each request check refuses before anything is sent, and logs the refusal without the body', async t => {
const { port, sends, lines } = await setup(t); const { post, headers, sends, lines } = await setup(t);
const cases = [ const cases = [
['no Origin', { headers: without('origin') }, 403, 'cross-origin request refused'], ['no Origin', { headers: without(headers, 'origin') }, 403, 'cross-origin request refused'],
['no Sec-Fetch-Site', { headers: without('sec-fetch-site') }, 403, 'cross-site request refused'], ['no Sec-Fetch-Site', { headers: without(headers, 'sec-fetch-site') }, 403, 'cross-site request refused'],
...['cross-site', 'same-site', 'none', 'Same-Origin', 'same-origin, same-origin'].map(v => [`Sec-Fetch-Site ${v}`, { headers: { ...ok, 'sec-fetch-site': v } }, 403, 'cross-site request refused']), ...['cross-site', 'same-site', 'none', 'Same-Origin', 'same-origin, same-origin'].map(v => [`Sec-Fetch-Site ${v}`, { headers: { ...headers, 'sec-fetch-site': v } }, 403, 'cross-site request refused']),
['two Sec-Fetch-Site', { headers: { ...ok, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'], ['two Sec-Fetch-Site', { headers: { ...headers, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'],
['no Content-Type', { headers: without('content-type') }, 400, 'invalid-request'], ['no Content-Type', { headers: without(headers, 'content-type') }, 400, 'invalid-request'],
['text/plain', { headers: { ...ok, 'content-type': 'text/plain' } }, 400, 'invalid-request'], ['text/plain', { headers: { ...headers, 'content-type': 'text/plain' } }, 400, 'invalid-request'],
['form', { headers: { ...ok, 'content-type': 'application/x-www-form-urlencoded' }, body: 'to=coder&body=hi' }, 400, 'invalid-request'], ['form', { headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' }, body: 'to=coder&body=hi' }, 400, 'invalid-request'],
['oversize', { body: JSON.stringify({ to: 'coder', body: 'x'.repeat(16384) }) }, 400, 'invalid-request'], ['oversize', { body: JSON.stringify({ to: 'coder', body: 'x'.repeat(16384) }) }, 400, 'invalid-request'],
['an extra key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', class: 'REQUEST' }) }, 400, 'invalid-request'], ['an extra key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', class: 'REQUEST' }) }, 400, 'invalid-request'],
['the business key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', business: 'other' }) }, 400, 'invalid-request'], ['the business key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', business: 'other' }) }, 400, 'invalid-request'],
@@ -164,16 +140,16 @@ test('each request check refuses before anything is sent, and logs the refusal w
['a string', { body: '"hi"' }, 400, 'invalid-request'], ['a string', { body: '"hi"' }, 400, 'invalid-request'],
]; ];
for (const [why, req, status, error] of cases) { for (const [why, req, status, error] of cases) {
const r = await post(port, req); const r = await post(req);
assert.equal(r.status, status, why); assert.equal(r.body.error, error, why); assert.equal(r.body.sent, false, why); assert.equal(r.status, status, why); assert.equal(r.body.error, error, why); assert.equal(r.body.sent, false, why);
} }
// An Origin that isn't this one is refused by the shared check, before the route. // An Origin that isn't this one is refused by the shared check, before the route.
for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, 'null']) { for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, 'null']) {
const r = await post(port, { headers: { ...ok, origin } }); const r = await post({ headers: { ...headers, origin } });
assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }, origin); assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }, origin);
} }
assert.equal((await post(port, { method: 'GET', body: undefined })).status, 405); assert.equal((await post({ method: 'GET', body: undefined })).status, 405);
assert.equal((await post(port, { method: 'PUT' })).status, 405); assert.equal((await post({ method: 'PUT' })).status, 405);
assert.deepEqual(sends, []); assert.deepEqual(sends, []);
assert.equal(lines.length, cases.length); assert.equal(lines.length, cases.length);
for (const l of lines) assert.match(l, /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to=("coder"|none) error=[a-z -]+$/, l); for (const l of lines) assert.match(l, /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to=("coder"|none) error=[a-z -]+$/, l);
@@ -181,19 +157,19 @@ test('each request check refuses before anything is sent, and logs the refusal w
// 16 KiB fits: the body limit is the broker's beyond that. // 16 KiB fits: the body limit is the broker's beyond that.
const fits = JSON.stringify({ to: 'coder', body: 'x'.repeat(16384 - JSON.stringify({ to: 'coder', body: '' }).length) }); const fits = JSON.stringify({ to: 'coder', body: 'x'.repeat(16384 - JSON.stringify({ to: 'coder', body: '' }).length) });
assert.equal(Buffer.byteLength(fits), 16384); assert.equal(Buffer.byteLength(fits), 16384);
assert.equal((await post(port, { body: fits })).status, 200); assert.equal((await post({ body: fits })).status, 200);
}); });
test('a send passes exactly to, body and in_reply_to, answers the id, and logs it', async t => { test('a send passes exactly to, body and in_reply_to, answers the id, and logs it', async t => {
const { port, sends, lines, boardHits, busCalls } = await setup(t); const { post, sends, lines, boardHits, busCalls } = await setup(t);
const r = await post(port, { body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) }); const r = await post({ body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) });
assert.equal(r.status, 200); assert.equal(r.status, 200);
assert.equal(r.body.id, 'msg-0001'); assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/); assert.deepEqual(Object.keys(r.body), ['id', 'at']); assert.equal(r.body.id, 'msg-0001'); assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/); assert.deepEqual(Object.keys(r.body), ['id', 'at']);
const reply = await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }) }); const reply = await post({ body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }) });
assert.equal(reply.status, 200); assert.equal(reply.status, 200);
// Values go to the broker as given, whatever their type: it is the one that refuses them. // Values go to the broker as given, whatever their type: it is the one that refuses them.
assert.equal((await post(port, { body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200); assert.equal((await post({ body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200);
assert.equal((await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: '' }) })).status, 200); assert.equal((await post({ body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: '' }) })).status, 200);
assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }, { to: 'pm', body: 'yes', in_reply_to: '' }]); assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }, { to: 'pm', body: 'yes', in_reply_to: '' }]);
assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body'], ['to', 'body', 'in_reply_to']]); assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body'], ['to', 'body', 'in_reply_to']]);
assert.equal(lines.length, 4); assert.equal(lines.length, 4);
@@ -204,21 +180,21 @@ test('a send passes exactly to, body and in_reply_to, answers the id, and logs i
}); });
test('a crafted role name cannot forge a log line', async t => { test('a crafted role name cannot forge a log line', async t => {
const { port, lines, state } = await setup(t); const { post, lines, state } = await setup(t);
state.answer = async () => { throw new BusReadError('unknown-role'); }; state.answer = async () => { throw new BusReadError('unknown-role'); };
const to = 'coder error=x\n2026-01-01T00:00:00.000Z send user="jason" to="pm" id=forged' + 'y'.repeat(100); const to = 'coder error=x\n2026-01-01T00:00:00.000Z send user="jason" to="pm" id=forged' + 'y'.repeat(100);
await post(port, { body: JSON.stringify({ to, body: 'hi' }) }); await post({ body: JSON.stringify({ to, body: 'hi' }) });
assert.equal(lines.length, 1); assert.equal(lines.length, 1);
assert.equal(lines[0].includes('\n'), false); assert.equal(lines[0].includes('\n'), false);
assert.match(lines[0], / error=unknown-role$/); assert.match(lines[0], / error=unknown-role$/);
assert.ok(lines[0].includes(` to=${JSON.stringify(to.slice(0, 64))} error=`)); assert.ok(lines[0].includes(` to=${JSON.stringify(to.slice(0, 64))} error=`));
// The cut is at 64 characters: a 65-character role loses its last one. // The cut is at 64 characters: a 65-character role loses its last one.
await post(port, { body: JSON.stringify({ to: 'r'.repeat(64) + 's', body: 'hi' }) }); await post({ body: JSON.stringify({ to: 'r'.repeat(64) + 's', body: 'hi' }) });
assert.match(lines[1], new RegExp(` to="${'r'.repeat(64)}" error=unknown-role$`)); assert.match(lines[1], new RegExp(` to="${'r'.repeat(64)}" error=unknown-role$`));
}); });
test('a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', async t => { test('a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', async t => {
const { port, sends, lines, state } = await setup(t); const { post, sends, lines, state } = await setup(t);
for (const [answer, status, error, sent] of [ for (const [answer, status, error, sent] of [
[new BusReadError('unknown-role'), 400, 'unknown-role', false], [new BusReadError('unknown-role'), 400, 'unknown-role', false],
[new BusReadError('invalid-request'), 400, 'invalid-request', false], [new BusReadError('invalid-request'), 400, 'invalid-request', false],
@@ -238,7 +214,7 @@ test('a bus refusal is not sent, with its status; an outcome the bus could not c
]) { ]) {
const before = sends.length; const before = sends.length;
state.answer = async () => { if (answer instanceof Error) throw answer; return answer; }; state.answer = async () => { if (answer instanceof Error) throw answer; return answer; };
const r = await post(port); const r = await post();
const label = answer instanceof Error ? answer.code ?? answer.message : JSON.stringify(answer); const label = answer instanceof Error ? answer.code ?? answer.message : JSON.stringify(answer);
assert.equal(sends.length, before + 1, `${label}: sent ${sends.length - before} times`); assert.equal(sends.length, before + 1, `${label}: sent ${sends.length - before} times`);
assert.equal(r.status, status, label); assert.equal(r.body.error, error, label); assert.equal(r.body.sent, sent, label); assert.equal(r.status, status, label); assert.equal(r.body.error, error, label); assert.equal(r.body.sent, sent, label);
@@ -262,12 +238,12 @@ test('through the human transport the request is exactly the business, message.s
const cli = join(root, 'fake-human-cli.mjs'), log = join(root, 'requests.jsonl'); const cli = join(root, 'fake-human-cli.mjs'), log = join(root, 'requests.jsonl');
writeFileSync(cli, FAKE); writeFileSync(cli, FAKE);
const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, env: { ...process.env, FAKE_LOG: log }, timeoutMs: 5000 }); const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, env: { ...process.env, FAKE_LOG: log }, timeoutMs: 5000 });
const { port, lines } = await setup(t, { send: args => call('message.send', args) }); const { post, lines } = await setup(t, { send: args => call('message.send', args) });
const r = await post(port); const r = await post();
assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-fake-1'); assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-fake-1');
const refused = await post(port, { body: JSON.stringify({ to: 'nobody', body: 'hi' }) }); const refused = await post({ body: JSON.stringify({ to: 'nobody', body: 'hi' }) });
assert.deepEqual([refused.status, refused.body.error, refused.body.sent], [400, 'unknown-role', false]); assert.deepEqual([refused.status, refused.body.error, refused.body.sent], [400, 'unknown-role', false]);
const crashed = await post(port, { body: JSON.stringify({ to: 'crash', body: 'hi' }) }); const crashed = await post({ body: JSON.stringify({ to: 'crash', body: 'hi' }) });
assert.deepEqual([crashed.status, crashed.body.error, crashed.body.sent], [503, 'outcome-unknown', 'unknown']); assert.deepEqual([crashed.status, crashed.body.error, crashed.body.sent], [503, 'outcome-unknown', 'unknown']);
const { readFileSync } = await import('node:fs'); const { readFileSync } = await import('node:fs');
const requests = readFileSync(log, 'utf8').trim().split('\n').map(l => JSON.parse(l)); const requests = readFileSync(log, 'utf8').trim().split('\n').map(l => JSON.parse(l));
@@ -284,8 +260,8 @@ test('against a real broker: the message is written once as a human send, and it
const call = transport('demo'); const call = transport('demo');
// The transport's refusal shape, as humanCall gives it. // The transport's refusal shape, as humanCall gives it.
const send = async args => { try { return await call('message.send', args); } catch (e) { throw new BusReadError(e.code); } }; const send = async args => { try { return await call('message.send', args); } catch (e) { throw new BusReadError(e.code); } };
const { port } = await setup(t, { send }); const { post } = await setup(t, { send });
const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) }); const r = await post({ body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) });
assert.equal(r.status, 200); assert.equal(r.status, 200);
const messages = () => b.request(human, { verb: 'trail', args: { subject: r.body.id } }).filter(row => row.table === 'messages'); const messages = () => b.request(human, { verb: 'trail', args: { subject: r.body.id } }).filter(row => row.table === 'messages');
assert.deepEqual(messages().map(m => [m.id, m.from_role, m.from_run, m.to_role, m.class, m.body]), [[r.body.id, 'human', 'human-cli', 'coder', 'INFO', 'Please look at row 76.']]); assert.deepEqual(messages().map(m => [m.id, m.from_role, m.from_run, m.to_role, m.class, m.body]), [[r.body.id, 'human', 'human-cli', 'coder', 'INFO', 'Please look at row 76.']]);
@@ -297,7 +273,7 @@ test('against a real broker: the message is written once as a human send, and it
[{ to: 'coder', body: 'hi', in_reply_to: 'msg-none' }, 400, 'message-not-found'], [{ to: 'coder', body: 'hi', in_reply_to: 'msg-none' }, 400, 'message-not-found'],
]) { ]) {
// The 32769-character body is past the Console's 16 KiB too; the Console refuses that one first. // The 32769-character body is past the Console's 16 KiB too; the Console refuses that one first.
const res = await post(port, { body: JSON.stringify(body) }); const res = await post({ body: JSON.stringify(body) });
assert.deepEqual([res.status, res.body.error, res.body.sent], [status, error, false], JSON.stringify(body).slice(0, 60)); assert.deepEqual([res.status, res.body.error, res.body.sent], [status, error, false], JSON.stringify(body).slice(0, 60));
} }
assert.equal(messages().length, 1); assert.equal(messages().length, 1);
+24 -23
View File
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url';
// scripts/webui-service.sh (row 75, #1569). Nothing here starts a unit or // scripts/webui-service.sh (row 75, #1569). Nothing here starts a unit or
// reaches the real systemd: install and uninstall run with --no-reload or a // reaches the real systemd: install and uninstall run with --no-reload or a
// stand-in systemctl on PATH, and XDG_CONFIG_HOME points into a temp dir. // stand-in systemctl on PATH, and XDG_CONFIG_HOME points into a temp dir.
// Row 83 (#1576) adds --send, taken only with the preview set. Row 77 // Row 83 (#1576) adds --send, taken only with verified OIDC through --sso. Row 77
// (#1571) adds --copy, the running-unit refusals and the uid rule; every run // (#1571) adds --copy, the running-unit refusals and the uid rule; every run
// gets a stand-in systemctl, since install now reads the unit's state, and // gets a stand-in systemctl, since install now reads the unit's state, and
// install runs with --allow-worktree, since the suite runs in worktrees. // install runs with --allow-worktree, since the suite runs in worktrees.
@@ -23,6 +23,7 @@ const NODE_DIR = dirname(process.execPath);
const UID = '6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e'; const UID = '6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e';
const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID]; const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID];
const PREVIEW_FLAGS = `--public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`; const PREVIEW_FLAGS = `--public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`;
const SSO_SEND = ['--sso', '--send'];
// A copy as `copy` leaves it, reduced to the file install looks for. // A copy as `copy` leaves it, reduced to the file install looks for.
const COPY_ID = '0123456789abcdef0123456789abcdef01234567'; const COPY_ID = '0123456789abcdef0123456789abcdef01234567';
const INSTALL = ['--copy', COPY_ID, '--allow-worktree']; const INSTALL = ['--copy', COPY_ID, '--allow-worktree'];
@@ -32,7 +33,7 @@ function fakeCopy(dir) {
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), ''); writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
return copyRoot(dir); return copyRoot(dir);
} }
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/m; const SEND_REFUSAL = /^webui-service: --send needs --sso; header preview is read-only$/m;
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8'); const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
// The unit as the base script renders it (row 75): the template with its four // The unit as the base script renders it (row 75): the template with its four
@@ -122,11 +123,11 @@ const execStart = (unit) => unit.split('\n').find((l) => l.startsWith('ExecStart
test('tester service render carries the tester port alongside existing flags', async (t) => { test('tester service render carries the tester port alongside existing flags', async (t) => {
const dir = tmp(t); const dir = tmp(t);
for (const port of ['1024', '7333', '65535']) { for (const port of ['1024', '7333', '65535']) {
const r = await run(dir, ['render', '--tester-port', port, ...PREVIEW, '--send']); const r = await run(dir, ['render', '--tester-port', port, ...SSO_SEND]);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
assert.match(execStart(r.stdout), new RegExp(` --tester-port ${port}( |$)`)); assert.match(execStart(r.stdout), new RegExp(` --tester-port ${port}( |$)`));
assert.match(execStart(r.stdout), / --port 7332 /); assert.match(execStart(r.stdout), / --port 7332 /);
assert.ok(execStart(r.stdout).endsWith(` ${PREVIEW_FLAGS} --send --tester-port ${port}`)); assert.ok(execStart(r.stdout).endsWith(` --sso --send --tester-port ${port}`));
} }
}); });
@@ -183,7 +184,7 @@ test('tester service install and status show the tester port without changing se
const unitDir = join(dir, 'units'); const unitDir = join(dir, 'units');
const bin = standIn(dir); const bin = standIn(dir);
const port = await closedPort(); const port = await closedPort();
const installed = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--tester-port', '1024', ...PREVIEW, '--send'], bin); const installed = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--tester-port', '1024', ...SSO_SEND], bin);
assert.equal(installed.status, 0, installed.stderr); assert.equal(installed.status, 0, installed.stderr);
assert.match(installed.stdout, /^serve: .* --tester-port 1024( |$)/m); assert.match(installed.stdout, /^serve: .* --tester-port 1024( |$)/m);
assert.match(execStart(readFileSync(join(unitDir, 'mosaic-webui.service'), 'utf8')), / --tester-port 1024( |$)/); assert.match(execStart(readFileSync(join(unitDir, 'mosaic-webui.service'), 'utf8')), / --tester-port 1024( |$)/);
@@ -263,7 +264,7 @@ test('the rendered unit names none of the human proof agent markers and sets no
const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8'); const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8');
const markers = [...source.match(/const MARKERS = \[([^\]]*)\]/)[1].matchAll(/'([A-Z_]+)'/g)].map((m) => m[1]); const markers = [...source.match(/const MARKERS = \[([^\]]*)\]/)[1].matchAll(/'([A-Z_]+)'/g)].map((m) => m[1]);
assert.ok(markers.length >= 7 && markers.includes('MOSAIC_BUS_CAP') && markers.includes('CLAUDECODE'), markers.join(' ')); assert.ok(markers.length >= 7 && markers.includes('MOSAIC_BUS_CAP') && markers.includes('CLAUDECODE'), markers.join(' '));
for (const args of [[], [...PREVIEW, '--business', 'mosaic'], [...PREVIEW, '--send']]) { for (const args of [[], [...PREVIEW, '--business', 'mosaic'], SSO_SEND]) {
const r = await run(dir, ['render', ...args]); const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
for (const marker of markers) assert.ok(!r.stdout.includes(marker), marker); for (const marker of markers) assert.ok(!r.stdout.includes(marker), marker);
@@ -394,16 +395,16 @@ test('without --send, render is the template with its placeholders filled, byte
} }
}); });
test('render --send with the preview set ends ExecStart with --send and changes nothing else', async (t) => { test('render --sso --send ends ExecStart with --send and changes nothing else', async (t) => {
const dir = tmp(t); const dir = tmp(t);
const extra = ['--port', '7340', '--business', 'mosaic']; const extra = ['--port', '7340', '--business', 'mosaic'];
for (const args of [[...PREVIEW, '--send'], ['--send', ...PREVIEW], [...PREVIEW.slice(0, 2), '--send', ...PREVIEW.slice(2)], ['--send', ...extra, ...PREVIEW]]) { for (const args of [['--sso', '--send'], ['--send', '--sso'], ['--send', ...extra, '--sso']]) {
const plain = args.filter((a) => a !== '--send'); const plain = args.filter((a) => a !== '--send');
const flags = `${plain.includes('--port') ? '--port 7340 --business mosaic' : '--port 7332'} ${PREVIEW_FLAGS}`; const flags = `${plain.includes('--port') ? '--port 7340 --business mosaic' : '--port 7332'} --sso`;
const r = await run(dir, ['render', ...args]); const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, filled(`${flags} --send`), args.join(' ')); assert.equal(r.stdout, filled(`${flags} --send`), args.join(' '));
assert.ok(execStart(r.stdout).endsWith(` --allow-user ${UID} --send`), execStart(r.stdout)); assert.ok(execStart(r.stdout).endsWith(' --sso --send'), execStart(r.stdout));
const without = await run(dir, ['render', ...plain]); const without = await run(dir, ['render', ...plain]);
assert.equal(without.status, 0, without.stderr); assert.equal(without.status, 0, without.stderr);
const a = r.stdout.split('\n'), b = without.stdout.split('\n'); const a = r.stdout.split('\n'), b = without.stdout.split('\n');
@@ -412,10 +413,10 @@ test('render --send with the preview set ends ExecStart with --send and changes
} }
}); });
test('--send without the whole preview set is refused and writes nothing', async (t) => { test('--send without --sso, including every header-preview shape, is refused and writes nothing', async (t) => {
const dir = tmp(t); const dir = tmp(t);
const pairs = [PREVIEW.slice(0, 2), PREVIEW.slice(2, 4), PREVIEW.slice(4, 6)]; const pairs = [PREVIEW.slice(0, 2), PREVIEW.slice(2, 4), PREVIEW.slice(4, 6)];
const partial = [[], ['--port', '7340'], ['--business', 'mosaic'], ...pairs, [...pairs[0], ...pairs[1]], [...pairs[0], ...pairs[2]], [...pairs[1], ...pairs[2]]]; const partial = [[], ['--port', '7340'], ['--business', 'mosaic'], ...pairs, [...pairs[0], ...pairs[1]], [...pairs[0], ...pairs[2]], [...pairs[1], ...pairs[2]], PREVIEW];
for (const args of partial) { for (const args of partial) {
const r = await run(dir, ['render', '--send', ...args]); const r = await run(dir, ['render', '--send', ...args]);
assert.equal(r.status, 1, args.join(' ')); assert.equal(r.status, 1, args.join(' '));
@@ -446,8 +447,8 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
const unitDir = join(dir, 'units'); const unitDir = join(dir, 'units');
const target = join(unitDir, 'mosaic-webui.service'); const target = join(unitDir, 'mosaic-webui.service');
const port = await closedPort(); const port = await closedPort();
const flags = `--port ${port} ${PREVIEW_FLAGS}`; const flags = `--port ${port} --sso`;
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send']); const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...SSO_SEND]);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm')); assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm'));
// Lead decision 102: Jason or Mos starts it, no other agent. // Lead decision 102: Jason or Mos starts it, no other agent.
@@ -462,9 +463,9 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
assert.equal(sending.status, 0, sending.stderr); assert.equal(sending.status, 0, sending.stderr);
assert.match(sending.stdout, /^send: on \(--send; human messages to roles\)$/m); assert.match(sending.stdout, /^send: on \(--send; human messages to roles\)$/m);
assert.match(sending.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m); assert.match(sending.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m);
const again = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--send', ...PREVIEW], bin); const again = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--send', '--sso'], bin);
assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm')); assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm'));
const readOnly = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW], bin); const readOnly = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--sso'], bin);
assert.match(readOnly.stdout, new RegExp(`^written: ${target}$`, 'm')); assert.match(readOnly.stdout, new RegExp(`^written: ${target}$`, 'm'));
assert.equal(readFileSync(target, 'utf8'), filled(`--checkout ${REPO} ${flags}`, copyRoot(dir))); assert.equal(readFileSync(target, 'utf8'), filled(`--checkout ${REPO} ${flags}`, copyRoot(dir)));
const off = await run(dir, ['status', '--dir', unitDir], bin); const off = await run(dir, ['status', '--dir', unitDir], bin);
@@ -477,7 +478,7 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
// serve would take --send as an allowed user; N5 refuses it, so no unit // serve would take --send as an allowed user; N5 refuses it, so no unit
// ends with `--allow-user --send` without sending. // ends with `--allow-user --send` without sending.
const before = readFileSync(target, 'utf8'); const before = readFileSync(target, 'utf8');
for (const args of [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--send'], ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--send', '--send']]) { for (const args of [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--send']]) {
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...args]); const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...args]);
assert.equal(r.status, 1, args.join(' ')); assert.equal(r.status, 1, args.join(' '));
assert.match(r.stderr, /^webui-service: allowed user must start with a letter or digit: --send$/m); assert.match(r.stderr, /^webui-service: allowed user must start with a letter or digit: --send$/m);
@@ -489,7 +490,7 @@ test('uninstall and status take no --send', async (t) => {
const dir = tmp(t); const dir = tmp(t);
const bin = standIn(dir); const bin = standIn(dir);
const unitDir = join(dir, 'units'); const unitDir = join(dir, 'units');
assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', ...PREVIEW, '--send'])).status, 0); assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', ...SSO_SEND])).status, 0);
for (const args of [['uninstall', '--dir', unitDir, '--send'], ['uninstall', '--dir', unitDir, '--no-reload', '--send'], ['status', '--dir', unitDir, '--send']]) { for (const args of [['uninstall', '--dir', unitDir, '--send'], ['uninstall', '--dir', unitDir, '--no-reload', '--send'], ['status', '--dir', unitDir, '--send']]) {
const r = await run(dir, args, bin); const r = await run(dir, args, bin);
assert.equal(r.status, 4, args.join(' ')); assert.equal(r.status, 4, args.join(' '));
@@ -662,9 +663,9 @@ test('install refuses unless the bus unit runs from the same copy, naming both p
test('render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged', async (t) => { test('render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged', async (t) => {
const dir = tmp(t); const dir = tmp(t);
const r = await run(dir, ['render', '--copy', COPY_ID, ...PREVIEW, '--send']); const r = await run(dir, ['render', '--copy', COPY_ID, ...SSO_SEND]);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, filled(`--checkout ${REPO} --port 7332 ${PREVIEW_FLAGS} --send`, copyRoot(dir))); assert.equal(r.stdout, filled(`--checkout ${REPO} --port 7332 --sso --send`, copyRoot(dir)));
assert.match(r.stdout, new RegExp(`^WorkingDirectory=${esc(copyRoot(dir))}$`, 'm')); assert.match(r.stdout, new RegExp(`^WorkingDirectory=${esc(copyRoot(dir))}$`, 'm'));
assert.match(r.stdout, new RegExp(`^ExecStart=\\S+ ${esc(copyRoot(dir))}/packages/webui/src/cli\\.mjs serve --checkout ${esc(REPO)} `, 'm')); assert.match(r.stdout, new RegExp(`^ExecStart=\\S+ ${esc(copyRoot(dir))}/packages/webui/src/cli\\.mjs serve --checkout ${esc(REPO)} `, 'm'));
const plain = await run(dir, ['render', ...PREVIEW]); const plain = await run(dir, ['render', ...PREVIEW]);
@@ -792,15 +793,15 @@ test('proxy proof render refuses incomplete non-preview missing unsafe-mode and
const bad = await run(dir, ['render', ...PREVIEW, ...flags]); assert.equal(bad.status, 1); assert.match(bad.stderr, /proof/); const bad = await run(dir, ['render', ...PREVIEW, ...flags]); assert.equal(bad.status, 1); assert.match(bad.stderr, /proof/);
}); });
test('proxy proof status retains send and tester reporting with both proof flags', async t => { test('proxy proof status retains read-only and tester reporting with both proof flags', async t => {
const dir = tmp(t), unitDir = join(dir, 'units'), file = join(dir, 'proof.fixture'); const dir = tmp(t), unitDir = join(dir, 'units'), file = join(dir, 'proof.fixture');
writeFileSync(file, 'fixture', { mode: 0o600 }); writeFileSync(file, 'fixture', { mode: 0o600 });
const port = await closedPort(), bin = standIn(dir); const port = await closedPort(), bin = standIn(dir);
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send', '--tester-port', '1024', '--proof-header', 'x-console-proof', '--proof-file', file], bin); const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--tester-port', '1024', '--proof-header', 'x-console-proof', '--proof-file', file], bin);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
const status = await run(dir, ['status', '--dir', unitDir], bin); const status = await run(dir, ['status', '--dir', unitDir], bin);
assert.equal(status.status, 0, status.stderr); assert.equal(status.status, 0, status.stderr);
assert.match(status.stdout, /send: on \(--send; human messages to roles\)/); assert.match(status.stdout, /send: off \(read-only\)/);
assert.match(status.stdout, /tester: http:\/\/127\.0\.0\.1:1024/); assert.match(status.stdout, /tester: http:\/\/127\.0\.0\.1:1024/);
}); });
+8 -9
View File
@@ -3,22 +3,21 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import {request} from 'node:http'; import {request} from 'node:http';
import {startServer} from '../src/serve.mjs'; import {startServer} from '../src/serve.mjs';
const preview={host:'console.example',header:'x-user',user:'jason'}; import {createSsoSession} from './helpers/sso-session.mjs';
const headers={host:'console.example','x-user':'jason',origin:'https://console.example','sec-fetch-site':'same-origin','content-type':'application/json'};
const payload={revision:'a'.repeat(64),limits:{total:0}}; const payload={revision:'a'.repeat(64),limits:{total:0}};
async function setup(t,enabled=true){const calls=[];const server=await startServer({port:0,preview,send:enabled?async()=>({}):null,settingsWrite:async args=>{calls.push(args);return {...args,business:'acme'};}});t.after(()=>new Promise(r=>server.close(r)));return {calls,port:server.address().port};} async function setup(t,enabled=true){const calls=[];const auth=await createSsoSession(t);const server=await startServer({port:0,sso:auth.sso,send:enabled?async()=>({}):null,settingsWrite:async args=>{calls.push(args);return {...args,business:'acme'};}});t.after(()=>new Promise(r=>server.close(r)));const session=await auth.signIn(server.address().port);return {calls,port:server.address().port,headers:auth.writeHeaders(session)};}
function post(s,hs=headers,data=payload){return new Promise((resolve,reject)=>{const req=request({hostname:'127.0.0.1',port:s.port,path:'/api/settings',method:'POST',headers:hs},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,text}));});req.on('error',reject);req.end(typeof data==='string'?data:JSON.stringify(data));});} function post(s,hs=s.headers,data=payload){return new Promise((resolve,reject)=>{const req=request({hostname:'127.0.0.1',port:s.port,path:'/api/settings',method:'POST',headers:hs},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,text}));});req.on('error',reject);req.end(typeof data==='string'?data:JSON.stringify(data));});}
test('REQ-WEB-1 signed-in same-origin Settings write reaches exactly one human operation',async t=>{const s=await setup(t);const r=await post(s);assert.equal(r.status,200,r.text);assert.deepEqual(s.calls,[payload]);}); test('REQ-WEB-1 signed-in same-origin Settings write reaches exactly one human operation',async t=>{const s=await setup(t);const r=await post(s);assert.equal(r.status,200,r.text);assert.deepEqual(s.calls,[payload]);});
test('REQ-WEB-1 Settings writes require enabled send, named Host, identity, exact Origin and same-origin Fetch Metadata',async t=>{ test('REQ-WEB-1 Settings writes require enabled send, named Host, identity, exact Origin and same-origin Fetch Metadata',async t=>{
const off=await setup(t,false);assert.equal((await post(off)).status,403);assert.equal(off.calls.length,0); const off=await setup(t,false);assert.equal((await post(off)).status,403);assert.equal(off.calls.length,0);
const s=await setup(t);for(const [key,value,status] of [['host','wrong.example',403],['x-user','other',401],['origin','https://else.example',403],['sec-fetch-site','cross-site',403],['origin',undefined,403],['sec-fetch-site',undefined,403],['x-user',['jason','jason'],401]]){const hs={...headers};if(value===undefined)delete hs[key];else hs[key]=value;assert.equal((await post(s,hs)).status,status,key);}assert.equal(s.calls.length,0); const s=await setup(t);for(const [key,value,status] of [['host','wrong.example',403],['cookie','__Host-mosaic_session=invalid',401],['origin','https://else.example',403],['sec-fetch-site','cross-site',403],['origin',undefined,403],['sec-fetch-site',undefined,403],['cookie',undefined,401]]){const hs={...s.headers};if(value===undefined)delete hs[key];else hs[key]=value;assert.equal((await post(s,hs)).status,status,key);}assert.equal(s.calls.length,0);
}); });
test('REQ-WEB-1 Settings validates JSON, size, revision and strict writable keys before broker call',async t=>{ test('REQ-WEB-1 Settings validates JSON, size, revision and strict writable keys before broker call',async t=>{
const s=await setup(t);for(const value of [{...payload,actor:'jason'},{limits:{}},[],null,'bad','x'.repeat(17000)])assert.equal((await post(s,headers,value)).status,400,String(value));assert.equal((await post(s,{...headers,'content-type':'text/plain'})).status,400);assert.equal(s.calls.length,0); const s=await setup(t);for(const value of [{...payload,actor:'jason'},{limits:{}},[],null,'bad','x'.repeat(17000)])assert.equal((await post(s,s.headers,value)).status,400,String(value));assert.equal((await post(s,{...s.headers,'content-type':'text/plain'})).status,400);assert.equal(s.calls.length,0);
}); });
test('Q169 a Console change of the assistant reaches the human operation unchanged, and the log names the signed-in user',async t=>{ test('Q169 a Console change of the assistant reaches the human operation unchanged, and the log names the signed-in user',async t=>{
const calls=[],lines=[];const server=await startServer({port:0,preview,send:async()=>({}),log:l=>lines.push(l),settingsWrite:async args=>{calls.push(args);return {...args,business:'acme'};}});t.after(()=>new Promise(r=>server.close(r)));const s={port:server.address().port}; const calls=[],lines=[],auth=await createSsoSession(t);const server=await startServer({port:0,sso:auth.sso,send:async()=>({}),log:l=>lines.push(l),settingsWrite:async args=>{calls.push(args);return {...args,business:'acme'};}});t.after(()=>new Promise(r=>server.close(r)));const session=await auth.signIn(server.address().port);const s={port:server.address().port,headers:auth.writeHeaders(session)};
const change={revision:'a'.repeat(64),assistants:{assistant:{definition:'assistant',vars:{model:'claude-opus-5-5',thinking:'high'},tracker:null}}}; const change={revision:'a'.repeat(64),assistants:{assistant:{definition:'assistant',vars:{model:'claude-opus-5-5',thinking:'high'},tracker:null}}};
const r=await post(s,headers,change);assert.equal(r.status,200,r.text);assert.deepEqual(calls,[change]);assert.equal(lines.length,1);assert.match(lines[0],/ settings user="jason" changed$/); const r=await post(s,s.headers,change);assert.equal(r.status,200,r.text);assert.deepEqual(calls,[change]);assert.equal(lines.length,1);assert.match(lines[0],/ settings user="jason" changed$/);
for(const value of [{assistants:change.assistants},{...change,holder:'x'}])assert.equal((await post(s,headers,value)).status,400);assert.equal(calls.length,1); for(const value of [{assistants:change.assistants},{...change,holder:'x'}])assert.equal((await post(s,s.headers,value)).status,400);assert.equal(calls.length,1);
}); });
+18 -3
View File
@@ -335,7 +335,7 @@ test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repos
} }
}); });
test('11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one', async t => { test('11: the CLI refuses --sso with the old flags and without console.sso, then --sso --send starts and reaches the human transport', async t => {
const root = mkdtempSync(join(tmpdir(), 'wsso-')); const root = mkdtempSync(join(tmpdir(), 'wsso-'));
t.after(() => rmSync(root, { recursive: true, force: true })); t.after(() => rmSync(root, { recursive: true, force: true }));
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') }; const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
@@ -366,7 +366,7 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, and
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m); assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600); chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it. // The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } }); const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso', '--send'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
t.after(() => child.kill('SIGKILL')); t.after(() => child.kill('SIGKILL'));
let out = '', err = ''; let out = '', err = '';
child.stderr.on('data', c => (err += c)); child.stderr.on('data', c => (err += c));
@@ -375,13 +375,28 @@ test('11: the CLI refuses --sso with the old flags and without console.sso, and
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } }); child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); }); child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
}); });
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), read-only$`, 'm')); assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), reads and human writes \\(--send\\), grant Approve and Deny$`, 'm'));
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm')); assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false); assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
assert.equal(out.includes(a.clientId), false); assert.equal(out.includes(a.clientId), false);
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]); const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` }); const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } }); assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
const login = await call(port, { path: '/auth/login' });
assert.equal(login.status, 303);
const loginCookie = setCookies(login)[0].split(';')[0];
const granted = op.grant(a, login.headers.location);
const state = new URL(login.headers.location).searchParams.get('state');
const signed = await call(port, { path: `/auth/callback?${new URLSearchParams({ code: granted.code, state })}`, headers: { cookie: loginCookie } });
const session = sessionOf(signed);
assert.ok(session);
const sent = await call(port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: JSON.stringify({ to: 'coder', body: 'secret-body-text' }) });
assert.equal(sent.status, 503);
assert.deepEqual(sent.body, { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false });
const sendLine = /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="person-a" to="coder" error=no-bus-host$/m;
for (let i = 0; i < 100 && !sendLine.test(out); i++) await new Promise(resolve => setTimeout(resolve, 20));
assert.match(out, sendLine);
assert.equal(out.includes('secret-body-text'), false);
child.kill('SIGTERM'); child.kill('SIGTERM');
}); });
@@ -7,6 +7,7 @@ import { tmp } from '../../cli/tests/helpers.mjs';
import { BusReadError } from '../src/bus.mjs'; import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { testerFixture, testerChat, TOKEN, VARIABLE, OUTSIDE } from './tester-fixture.mjs'; import { testerFixture, testerChat, TOKEN, VARIABLE, OUTSIDE } from './tester-fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
export function http(server, path, { method = 'GET', headers = {}, data } = {}) { export function http(server, path, { method = 'GET', headers = {}, data } = {}) {
const address = server.address(), unix = typeof address === 'string', port = address.port; const address = server.address(), unix = typeof address === 'string', port = address.port;
@@ -172,9 +173,11 @@ test('row 177 W3: the human thread through the tester chat is thread-not-owned,
test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and writes before the chat', async t => { test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and writes before the chat', async t => {
const calls = []; const calls = [];
const chat = Object.fromEntries(['projects', 'createProject', 'threads', 'createThread', 'messages', 'send', 'open'].map(k => [k, async () => { calls.push(k); return []; }])); const chat = Object.fromEntries(['projects', 'createProject', 'threads', 'createThread', 'messages', 'send', 'open'].map(k => [k, async () => { calls.push(k); return []; }]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', preview: { host: 'console.example', header: 'x-user', user: 'jason' }, send: async () => ({}), chat, log: () => {} }); const auth = await createSsoSession(t, { host: 'console.example' });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: auth.sso, send: async () => ({}), chat, log: () => {} });
t.after(() => close(web)); t.after(() => close(web));
const signed = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; const session = await auth.signIn(web.address().port);
const signed = auth.writeHeaders(session);
for (const [method, path, data] of [['GET', '/api/chat/threads'], ['POST', '/api/chat/threads/20000000-0000-4000-8000-000000000002/messages', { body: 'x' }]]) { for (const [method, path, data] of [['GET', '/api/chat/threads'], ['POST', '/api/chat/threads/20000000-0000-4000-8000-000000000002/messages', { body: 'x' }]]) {
const r = await http(web, path, { method, data, headers: { ...signed, 'x-mosaic-tester': '' } }); const r = await http(web, path, { method, data, headers: { ...signed, 'x-mosaic-tester': '' } });
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'tester listener required'); assert.deepEqual(calls, []); assert.equal(r.status, 403, path); assert.equal(r.body.error, 'tester listener required'); assert.deepEqual(calls, []);
+6 -6
View File
@@ -43,8 +43,8 @@
# Console), --tester-socket (private Unix tester), or legacy --tester-port N # Console), --tester-socket (private Unix tester), or legacy --tester-port N
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies), # (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies),
# --business ID, --board URL, and the preview set --public-host # --business ID, --board URL, and the preview set --public-host
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send # NAME --auth-header HEADER --allow-user ID, all three or none. --send
# (row 83), taken only with the preview set: the unit's Console then sends # (row 83) is taken only with --sso: the unit's Console then sends
# human messages to roles and needs the system config at start. systemd # human messages to roles and needs the system config at start. systemd
# expands % and $ and splits on spaces, so each value is held to a plain # expands % and $ and splits on spaces, so each value is held to a plain
# character set and refused otherwise. --proof-header NAME --proof-file PATH # character set and refused otherwise. --proof-header NAME --proof-file PATH
@@ -52,7 +52,7 @@
# render checks ownership, mode and location without opening the proof file. # render checks ownership, mode and location without opening the proof file.
# --sso (Q175 phase 1, #1690): the Console signs people in itself from # --sso (Q175 phase 1, #1690): the Console signs people in itself from
# console.sso in config.json. It refuses the preview set and the proof pair, # console.sso in config.json. It refuses the preview set and the proof pair,
# and --send takes it in place of the preview set. Nothing secret is carried. # and --send requires it. Header preview is read-only. Nothing secret is carried.
# #
# There is no start, stop, enable or restart verb. The Console reads the # There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback # main listener's bus through the human transport; the optional loopback
@@ -93,7 +93,7 @@ instance_select
UNIT_NAME="$CONSOLE_UNIT" UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}" BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH] FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
--tester-port: legacy copies only; 1024..65535, different from --port and never 7332; loopback tester listener. --tester-port: legacy copies only; 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout." --allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -223,12 +223,12 @@ serve_flags() {
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1)) [ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
[ -z "$ALLOW_USER" ] || preview=$((preview + 1)) [ -z "$ALLOW_USER" ] || preview=$((preview + 1))
# serve refuses --sso with the preview set or the proof pair, and --send # serve refuses --sso with the preview set or the proof pair, and --send
# without --sso or the preview set; refuse them here first, so no unit is # without --sso; refuse them here first, so no unit is
# written that would only exit 2 at start. # written that would only exit 2 at start.
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*" die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
fi fi
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso" [ "$SEND" = 0 ] || [ "$SSO" = 1 ] || die "--send needs --sso; header preview is read-only"
if [ "$preview" -ne 0 ]; then if [ "$preview" -ne 0 ]; then
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none" [ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac