Merge PR #1836: Require verified OIDC for Console human writes (#385)

This commit is contained in:
jason.woltje committed 2026-11-02 12:31:19 -06:00
commit 418f00bce2
36 files changed
+880 -378

No files matched your search

+4
View File
@@ -5836,3 +5836,7 @@ Before: agents can read the library; supporting-path and push-credential depende
Q284 / #470 source-only security fix 2026-10-29T03:33:00.640624+00:00: pin worker base Node24.22.2 multi-platform indexsha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c, official registry metadata retained under docs/security/q284-node-base. Hash/digest/platform references checked; no image pull/build/runtime/test/CI/deploy. Current deployed Node states remain UNKNOWN. No frozen packet change; dedicated CI and cross-family merge review pending.
2026-10-29 | Sage | #470 independent-review repair | M1 binds preparation/verification baseDigest to exactly one literal digest-pinned Containerfile FROM; stale cached tag metadata cannot be substituted. Focused fixture TDD: 0/3 RED then 3/3 GREEN, fake build/inspection only, no Docker command or image operation. L1 docs corrected. L2 additive registry config hash/version binding retained; prior metadata receipt preserved with correction. Earlier no-commit statement describes initial source session; Mos then published PR1837. #660 admitted runner and fresh independent review remain required; frozen H1/packets unchanged.
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.
2026-10-29T05:49:53Z | Codex Daybreak Blue xhigh implementation sub-agent | CORRECTION and Mos2255 #385 review repair R2 | The 03:27:31Z dependency/no-commit statements are historical: exact pinned `[email protected]` is now materialized through the ignored `node_modules` fixture with no install, and original preparation is committed at `9936a1be1a37fdb30ba53cf2f14b7897225c3e4a`. On that unchanged HEAD, uncommitted R2 source/tests/docs make Case 2 library preview read-only until #412, drop inherited non-SSO `--send` during release planning, and refuse duplicate service `--send`. Focused RED was 0/2, 0/1, 0/1; focused GREEN and selected T53/T55/T57/T58 fixture blocks pass. No root/native/live/full-suite/CI action, commit, push, PR/API, merge or frozen/live-pin change.
2026-11-02T18:03:53Z | Claude Opus 5.5 implementation sub-agent (lead T3 c5c1e8ba) | #385 PR 1836 repair R3 on ab2bc23c7 | R2-F1: `scripts/stack-upgrade.sh` `read_args` now scans the serve argv after `--` and refuses `args-unreadable` before anything stops when the profile is library (no `--profile`, or the last one says library, as `case2-unit.sh` reads it) and `--send` is present; the message names the remediation, and the args file is never rewritten. `do_upgrade` and `do_rollback` both call `read_args` first, so rollback is covered; factory and tester paths are unchanged. RED on ab2bc23c7: new stack-upgrade case 0/1 (exit 2 rollback-failed, A4 and render-old failed, both units stopped), controls 1/1; GREEN 2/2. R2-I1: one component case runs the real `sso_for_target` then `send_for_target` on a legacy preview `--send` pointer, carried after `--sso` and before `--tester-socket`. R2-I2: TOOLS.md says a same-copy plain activate on a legacy pointer refuses already-active and needs `--no-send`. R2-I3: no code change; `uninstall`/`status` with `--send --send` exit 1, not 4, fail-closed either way. host-run-sheet section 7 gains the migration step. Whole files, one at a time: stack-upgrade 23/23 (69 s), stack-release 225/225 (608 s; a first run was cut by my 600 s background limit at 221 passing, its orphan fixture holder killed), case2-unit 48/48 (73 s). No root, systemd, container, network, push, PR or live action.
2026-11-02T18:19:04Z | Claude Opus 5.5 implementation sub-agent (lead T3 c5c1e8ba) | #385 PR 1836 repair R4 on d0ddfa8de | R3-F1 fixed fail-closed, no migration: `scripts/stack-upgrade.sh` adds `legacy_console`, called right after `read_args` in `do_upgrade` and `do_rollback`. For the library profile (same default and last-wins rule as R3), an installed Console unit under the driver's UNIT_DIR whose ExecStart has a `--send` token after `serve` refuses new code `console-unit-legacy` (exit 3, nothing stopped). A missing unit is left to `read_old` (`units-unreadable`). The R3 args refusal stays. `case2-unit.sh`, factory, tester, stack-release.sh and OIDC/#412 untouched. Tests in stack-upgrade.test.mjs run the real base renderer (`git show 09a847eda` of case2-unit.sh and both templates) in the module fixture with fake getent/systemctl to make the legacy unit, prove the current case2-unit.sh refuses it, then drive upgrade and rollback with the real current renderer as A4's RENDER. Controls: read-only library unit, factory host, missing Console unit. RED on d0ddfa8de code 24/25 (the main case exits 2 rollback-failed, both units stopped), GREEN 25/25 (70.1 s). host-run-sheet section 7 now gives a read-only grep of the unit and says stop and report when it carries `--send`. TOOLS.md lists the code. No hash pins on changed files. stack-release.test.mjs and case2-unit.test.mjs read no changed file and were not rerun. One local commit, no push.
+4
View File
@@ -1889,3 +1889,7 @@ are never rewritten or removed; corrections are new entries.
2026-10-29T03:33:00.640624+00:00 | Sage | Q284 #470 patched Node base source | Digest pin and official metadata prepared; no build, CI, commit, PR, merge or live action.
2026-10-29 | Sage | #470 independent-review repair | M1 binds preparation/verification baseDigest to exactly one literal digest-pinned Containerfile FROM; stale cached tag metadata cannot be substituted. Focused fixture TDD: 0/3 RED then 3/3 GREEN, fake build/inspection only, no Docker command or image operation. L1 docs corrected. L2 additive registry config hash/version binding retained; prior metadata receipt preserved with correction. Earlier no-commit statement describes initial source session; Mos then published PR1837. #660 admitted runner and fresh independent review remain required; frozen H1/packets unchanged.
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.
2026-10-29T05:49:53Z | Codex Daybreak Blue xhigh implementation sub-agent | Mos2255 #385 review repair R2 on work/vikunja-19-385-q284-complete at unchanged HEAD 9936a1be1a37fdb30ba53cf2f14b7897225c3e4a/base 09a847edaa25b609bac7728ff83f224708a29399 | Corrects the 03:27:31Z historical blocker: pinned oauth4webapi3.8.8 fixture is now present without install and original prep is committed. F1/F2/F3 repaired in uncommitted source/tests/docs; F4/F7 disclosed only, F6 append conflicts preserved. Three focused test files only: intentional RED captured, GREEN controls plus T53 12/12, T55 8/8, T57 13/13 and T58 11/11 pass. No root/native/live/full-suite/CI, commit/push/PR/API/merge, frozen packet or live-pin action.
2026-11-02T18:03:53Z | Claude Opus 5.5 implementation sub-agent (lead T3 c5c1e8ba) | #385 PR 1836 repair R3 on work/vikunja-19-385-q284-complete at ab2bc23c7 | R2-F1 fixed: stack-upgrade refuses a library args file holding `--send` after `--` before any unit stops, for upgrade and rollback; R2-I1 component case and R2-I2 TOOLS.md precision added, R2-I3 noted. RED 0/1 then GREEN 2/2; stack-upgrade 23/23, stack-release 225/225, case2-unit 48/48. One local commit; no push, PR, Vikunja, Gitea or live action.
2026-11-02T18:19:04Z | Claude Opus 5.5 implementation sub-agent (lead T3 c5c1e8ba) | #385 PR 1836 repair R4 on work/vikunja-19-385-q284-complete at d0ddfa8de | R3-F1 fixed: stack-upgrade refuses `console-unit-legacy` before any unit stops when a library host's installed Console unit runs serve with `--send`, for upgrade and rollback; host-run-sheet section 7 and TOOLS.md updated. RED 24/25 then GREEN 25/25 in stack-upgrade.test.mjs; other suites not rerun (no changed file read). One local commit; no push, PR, Vikunja, Gitea or live action.
+21 -7
View File
@@ -528,6 +528,10 @@ every table in COUNTED.
--send needs --sso in the run's Console flags, given or carried. --no-sso needs --sso in the
pointer's, and takes the Console flags from the rollback set of the activation that turned it on.
--no-sso drops the pointer's --send, and so does a rollback to a Console without --sso.
An inherited legacy --send beside header-preview flags is dropped during planning because preview is
read-only; it never reaches the service installer. The drop alone is not a change: an activate of the
pointer's own copy refuses already-active unless another flag changes the Console (check_target's
list), so on a legacy pointer pass --no-send there.
```
This is an operator plan, not an action agents run. Both reviews and the
@@ -637,8 +641,11 @@ and only the Console flags change.
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
Console flags and the unit, so the Console serves reads and no human sends. It
may name the live copy while the pointer holds `--send`; once `--send` is gone a
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
same-copy `--no-send` refuses `already-active`. Independently, an activate or
rollback drops an inherited legacy `--send` when the run has no `--sso`, before
the plan reaches `webui-service.sh`; verified `--sso --send` remains carried.
Later activates, `pm-on` and rollbacks keep send off. Only an explicit `--send`
beside given or carried `--sso` puts it back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
@@ -885,7 +892,14 @@ writes: one `case2-unit.sh` argument per line, the serve flags after `--`,
owned by root with no group or other write, exactly one `--copy`, and that
copy the one both installed units run. Otherwise the run refuses
`args-unreadable` or `args-differ`. The driver writes the new copy id into
that file only after `confirm` passes.
that file only after `confirm` passes. For the `library` profile, an installed
Console unit whose `ExecStart` has a `--send` token after `serve` was rendered
before #385, and the current `case2-unit.sh` refuses it. `upgrade` and
`rollback` refuse `console-unit-legacy` right after reading the args file,
before any step runs, because no migration exists yet
(`docs/guides/host-run-sheet.md` section 7). A missing Console unit is not
this refusal; the later unit read refuses it `units-unreadable`, also before
anything stops.
`upgrade`, in order. Every stack user step runs as
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from the
@@ -1017,16 +1031,16 @@ scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-relo
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
scripts/webui-service.sh status [--dir DIR]
# FLAGS: [--port N] [--business ID] [--board URL]
# [--public-host NAME --auth-header HEADER --allow-user ID [--send]]
# [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID]
```
The systemd user unit `mosaic-webui.service` runs the Console,
`node packages/webui/src/cli.mjs serve`, with serve's flags as given:
`--port` defaults to 7332 (7330 is the operator's own Console), and the
preview set goes all three or none. `--send` (row 83) goes only with the
preview set and is refused otherwise (exit 1, nothing written); ExecStart
preview set goes all three or none and stays read-only. `--send` (row 83)
requires `--sso` and is refused otherwise (exit 1, nothing written); ExecStart
then ends with `--send`, and the Console needs the system config at start
(`--send needs the bus configured`, exit 2). Without `--send` the unit is
including `console.sso` (exit 2 if missing). Without `--send` the unit is
read-only. The template is
`packages/webui/systemd/mosaic-webui.service.in`.
+36 -1
View File
@@ -380,7 +380,42 @@ or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `-
`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the
`--copy` line, and only after confirm passes.
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` on any of these rules.
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
profile and its private tester socket are unchanged.
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
`ExecStart`. `stack-upgrade.sh upgrade` and `rollback` check both before anything stops, when the
profile is `library` (no `--profile` line, or the last one says `library`):
- `--send` after `--` in the args file refuses `args-unreadable`.
- A `--send` token after `serve` in `/etc/systemd/system/mosaic-webui.service`'s `ExecStart` refuses
`console-unit-legacy`. The current `case2-unit.sh` refuses that installed unit, so an upgrade or
rollback would stop both units and then fail both renders.
Both refusals leave the units running, and the script rewrites neither file. Check the installed unit
read-only, as root:
```sh
grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --send'
```
- `0`, and no installed unit carries `--send`: removing the args line is enough. Open
`/etc/mosaic-stack/dev.args` and delete the one line that reads `--send`, changing nothing else. Check
that `stat -c '%U %a' /etc/mosaic-stack/dev.args` still gives `root` and a mode with no group or other
write, and that `grep -cx -- --send /etc/mosaic-stack/dev.args` prints `0`. Then rerun the same
`stack-upgrade.sh` command.
- `1` or more: the host needs a reviewed migration of the unit, the args file and the release pointer
together, and none exists yet. Stop. Leave the units running, change neither file and report to the
lead with the command's output. Removing only the args line does not help: the run then refuses
`console-unit-legacy`.
The Console stays read-only after the upgrade, until #412 (#385).
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
rules, before it stops a unit.
## 8. Enrollment, once per host
+13 -3
View File
@@ -103,7 +103,7 @@ case_sudo_run '${root}' "$@"`);
}
const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-host", "books.example", "--auth-header", "X-Auth-User",
"--allow-user", "melanie", "--send", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {} } = {}) {
@@ -300,7 +300,7 @@ function template(h, from, name, edit) {
return file;
}
test("a clean library render writes both units 0644 with the fixed lines, and enables both then starts the bus then the Console", (t) => {
test("a clean read-only library render writes both units 0644 with the fixed lines, and enables both then starts the bus then the Console", (t) => {
const h = hub(t);
const r = unit(h);
assert.equal(r.code, 0, r.err);
@@ -311,6 +311,7 @@ test("a clean library render writes both units 0644 with the fixed lines, and en
assert.deepEqual(lines(bus, "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
assert.deepEqual(lines(bus, "EnvironmentFile"), [`-${h.home}/.config/mosaic-dev/secrets/claude-session.env`]);
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${libraryFlags(h).join(" ")}`]);
assert.doesNotMatch(lines(webui, "ExecStart")[0], / --(?:send|sso)(?: |$)/);
assert.deepEqual(lines(webui, "EnvironmentFile"), []);
for (const text of [bus, webui]) {
assert.deepEqual(lines(text, "WantedBy"), ["multi-user.target"]);
@@ -336,6 +337,15 @@ test("a clean library render writes both units 0644 with the fixed lines, and en
]);
});
test("the read-only library profile explicitly refuses --send before writing a unit", (t) => {
const h = hub(t);
const r = unit(h, { flags: [...libraryFlags(h), "--send"] });
refuses(r, /the library profile is read-only; refusing --send until Case 2 supports --sso \(#412\)/);
assert.equal(existsSync(busFile(h)), false);
assert.equal(existsSync(webuiFile(h)), false);
assert.equal(existsSync(h.log), false);
});
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
const h = hub(t);
const node = outsideNode();
@@ -504,7 +514,7 @@ test("library refuses a privileged-floor miss, a bad proof pair and every flag o
const without = (...names) => {
const out = [];
for (let i = 0; i < base.length; i++) {
if (names.includes(base[i])) { if (base[i] !== "--send") i++; continue; }
if (names.includes(base[i])) { i++; continue; }
out.push(base[i]);
}
return out;
+82 -29
View File
@@ -1128,19 +1128,22 @@ const CARRY_MSG=(to)=>`${to}'s Console does not support --proof-header, which th
const HEADER_MSG=(h)=>`--proof-header ${h} must be a header name of a-z, 0-9 and - (64 at most, lowercased) other than host, origin, x-mosaic-tester and the --auth-header value`;
test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{
await t.test('tests 1 and 9: the pair goes after the preview flags, before --send and the kept --tester-port, as the unit renders it; a new pair replaces it',async(t)=>{
await t.test('tests 1 and 9: initial activation drops legacy preview send; the pair goes before the kept --tester-port and a new pair replaces it',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),g=await proofFile(e,'proof2.187',0o400);
const pre=base.slice(0,-1);
let r=await prun(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,'--tester-port','7333']);
assert.match(stepText(e),/Z drop --send; header preview is read-only \(#385\)\n/);
const out=await planOnly(e,0,'activate',C1,'acme',...pair(f));
assert.deepEqual(proofLines(out),[`proof: on, header x-console-proof, file ${f}`]);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(` ${PREVIEW.join(' ')} ${pair(f).join(' ')} --send --tester-port 7333`));
assert.deepEqual(sendLines(out),['send: off']);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(` ${PREVIEW.join(' ')} ${pair(f).join(' ')} --tester-port 7333`));
r=await prun(e,'activate',C1,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${[...pre,...pair(f),'--send','--tester-port','7333'].join(' ')}`),conExec(e));
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--tester-port','7333']);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${[...pre,...pair(f),'--tester-port','7333'].join(' ')}`),conExec(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
r=await prun(e,'activate',C1,'acme',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two'),'--send','--tester-port','7333']);
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two'),'--tester-port','7333']);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
assert.equal(conExec(e).match(/--proof-header/g).length,1);assert.ok(!conExec(e).includes(f));
await noLeak(e);
@@ -1348,6 +1351,57 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
const noSend=(c)=>c.filter((x)=>x!=='--send');
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('Mos2255 source component: legacy preview send drops while explicit and SSO paths keep their guards',()=>{
const source=readFileSync(SR,'utf8');
const exact=(start,end)=>{
const a=source.indexOf(start),b=source.indexOf(end,a);
assert.ok(a>=0&&b>a,`missing source component ${start}`);
return source.slice(a,b);
};
const component=[
exact('check_refuse() {','\n\n# Item 1 and item 4'),
exact('has_flag() {','\n\n# Row 200'),
exact('send_for_target() {','\n\n# Row 187:'),
exact('sso_for_target() {','\n\n# Row 200 item 2'),
].join('\n');
const runCase=(setup)=>{
const r=spawnSync('/bin/bash',['-c',`${component}
PLAN=1; PLAN_REFUSE=(); SEND=0; NO_SEND=0; NO_SSO=0
${setup}
send_for_target
printf 'console=%s\\nplan=%s\\nstep=%s\\nrefuses=%s\\n' "\${RUN_CONSOLE[*]}" "$SEND_PLAN" "$SEND_STEP" "\${PLAN_REFUSE[*]}"
`],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);
return Object.fromEntries(r.stdout.trimEnd().split('\n').map((line)=>line.split(/=(.*)/s).slice(0,2)));
};
const preview='--port 7332 --business acme --public-host console.example --auth-header x-user --allow-user jason';
assert.deepEqual(runCase(`P_CONSOLE=(${preview} --send); RUN_CONSOLE=("\${P_CONSOLE[@]}")`),{
console:preview,
plan:'send: dropped; header preview is read-only (#385)',
step:'drop --send; header preview is read-only (#385)',
refuses:'',
});
assert.match(runCase(`SEND=1; P_CONSOLE=(${preview} --send); RUN_CONSOLE=("\${P_CONSOLE[@]}")`).refuses,/^send-needs-sso: --send needs --sso/);
assert.deepEqual(runCase('P_CONSOLE=(--port 7332 --business acme --sso --send --tester-port 7333); RUN_CONSOLE=("${P_CONSOLE[@]}")'),{
console:'--port 7332 --business acme --sso --send --tester-port 7333',plan:'send: carried',step:'',refuses:'',
});
assert.deepEqual(runCase(`NO_SEND=1; P_CONSOLE=(${preview} --send); RUN_CONSOLE=("\${P_CONSOLE[@]}")`),{
console:preview,plan:'send: dropped; --no-send was given',step:'drop --send; --no-send was given',refuses:'',
});
assert.deepEqual(runCase(`NO_SSO=1; P_CONSOLE=(${preview} --sso --send); RUN_CONSOLE=(${preview} --send)`),{
console:preview,plan:'send: dropped; --no-sso drops --send (F39)',step:'drop --send; --no-sso was given',refuses:'',
});
assert.deepEqual(runCase(`P_CONSOLE=(${preview} --sso --send); RUN_CONSOLE=(${preview} --send)`),{
console:preview,plan:'send: dropped; the run drops --sso, and --send needs it (F39)',step:'drop --send; the run drops --sso',refuses:'',
});
// R2-I1 (#385): activate --sso on a legacy preview --send pointer. The real sso_for_target builds the
// run's flags from the pointer's, and send_for_target carries --send after --sso, before the tester flag.
assert.deepEqual(runCase(`SSO=1; P_CONSOLE=(${preview} --send --tester-socket); RUN_CONSOLE=("\${P_CONSOLE[@]}"); sso_for_target`),{
console:'--port 7332 --business acme --sso --send --tester-socket',plan:'send: carried',step:'',refuses:'',
});
});
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
@@ -1381,8 +1435,8 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
out=await planOnly(e,0,'activate',C1,'acme');
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
assert.deepEqual(sendLines(out),['send: dropped; header preview is read-only (#385)']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(` ${noSend(base).join(' ')}`));
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
@@ -1423,29 +1477,24 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
await t.test('test 6: a legacy pointer with both a pair and --send drops send implicitly or explicitly while preserving proof choices',async(t)=>{
const e=await mk(t),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400),base=await seedPreview(e,undefined,[...pair(f),'--send']),pre=noSend(base),plain=base.slice(0,-5);
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; header preview is read-only (#385)']);
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,pre);
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
assert.deepEqual(pointer(e).console,[...plain,...pair(g,'X-Proof-Two')]);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
const h=await mk(t),k=await proofFile(h),hbase=await seedPreview(h,undefined,[...pair(k),'--send']),hpre=hbase.slice(0,-5);
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
const steps=stepText(h);
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
assert.match(steps,new RegExp(`Z drop --proof-header x-console-proof --proof-file ${k}; --no-proof was given\\n`));assert.match(steps,/Z drop --send; --no-send was given\n/);
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
for(const x of [e,h])await noLeak(x);
});
@@ -1477,15 +1526,15 @@ function setEdit(set,name,from,to,sums=true){
}
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
await t.test('item 1: --sso drops the preview set and the pair, puts --sso before --send and the tester port, and the plan names what it drops and carries',async(t)=>{
await t.test('item 1: a pre-SSO activation drops legacy send; --sso then drops preview and proof before the tester port',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--tester-port','7333']);
await ssoConfig(e);
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
const want=['--port',e.port,'--business','acme','--sso','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: carried',
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: off',
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --tester-port 7333`]);
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
@@ -1500,13 +1549,15 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...ssoLines(out)],['proof: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send`]);
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme`]);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
assert.doesNotMatch(stepText(e),/proof/);
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
@@ -1600,6 +1651,7 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const set=arrivalSet(e,C1);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
// The latest set holds the --sso Console; the arrival set holds B's.
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
@@ -1801,9 +1853,10 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const pre=conExec(e),want=noSend(base);
assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
assert.ok(pre.endsWith(` ${want.join(' ')}`),pre);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
@@ -1812,7 +1865,7 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.equal(conExec(e),pre.replace(' --send --tester-port 7333',' --tester-port 7333'));
assert.equal(conExec(e),pre);
const steps=stepText(e);
assert.ok(steps.includes('Z drop --send; --no-sso was given\n'),steps);
assert.ok(steps.includes(`Z turn off --sso; the Console flags come from ${set}/${SHOW}\n`),steps);
+133 -2
View File
@@ -5,14 +5,15 @@
// stack-release-fixture.mjs's. Root's sudo, case2-unit.sh render and row 240 hostcheck are stand-ins
// here; the stack's user's steps are the real stack-release.sh from the user's clone, except the
// rehearsal, which the fixture's stub copies can't pass (its own tests are in stack-release.test.mjs).
// The #385 R3-F1 cases run the real base and current case2-unit.sh instead of the render stand-in.
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawn} from 'node:child_process';
import {execFileSync,spawn,spawnSync} from 'node:child_process';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync} from 'node:fs';
import {DatabaseSync} from 'node:sqlite';
import {join,dirname} from 'node:path';
import {once} from 'node:events';
import {B,C,C1,U,COPIES,NODE,SBIN,base,fault,git,lines,logLines,mk,pointer,repo,sha,storeVersion,sysctl,units} from './stack-release-fixture.mjs';
import {B,C,C1,U,COPIES,NODE,ROOT,SBIN,base,fault,git,lines,logLines,mk,pointer,repo,sha,storeVersion,sysctl,units} from './stack-release-fixture.mjs';
// --- Root's stand-ins. Each is serialized into its own file with K, its baked paths. ---
@@ -433,6 +434,136 @@ test('row 233: the args file must be the running user\'s, with no group or other
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
// R2-F1 (#385, PR 1836): base case2-unit.sh required --send for the library profile, and the current one
// refuses it, so a legacy args file would stop both units at A2 and fail the A4 render and render-old.
// read_args refuses it first, for an upgrade and a rollback alike, and never rewrites the file.
const legacySend=(f)=>`${f} has --send after --, which case2-unit.sh refuses for the library profile: remove the --send line, then rerun; the library Console is read-only until #412 (#385) (nothing was stopped)`;
test('#385 R2-F1: a library args file holding --send after -- refuses args-unreadable before anything stops, for upgrade and rollback',async(t)=>{
const e=await useed(t);
const legacy=e.argsText+'--send\n';
const cases=[
['the default profile',legacy,()=>upgrade(e,C1)],
['an explicit --profile library',legacy.replace('--\n','--profile\nlibrary\n--\n'),()=>upgrade(e,C1)],
['the last --profile is library',legacy.replace('--\n','--profile\nfactory\n--profile\nlibrary\n--\n'),()=>upgrade(e,C1)],
['a rollback',legacy,()=>drive(e,B,'rollback','--instance','dev','--to',B,...TO)],
];
for(const [why,text,run] of cases){
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await run();assert.equal(r.code,3,`${why}: ${r.all}`);
assert.equal(r.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${legacySend(e.args)}`,why);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','result'],why);
assert.equal(slog(e).at(-1),`result exit=3 args-unreadable ${legacySend(e.args)}`,why);
// The file is left as the operator wrote it.
assert.equal(readFileSync(e.args,'utf8'),text,why);assert.deepEqual(readdirSync(e.etc),['dev.args'],why);
}
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'render.log')));
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('#385 R2-F1 controls: library args without --send and a factory args file pass read_args as before',async(t)=>{
const e=await useed(t);
// A bundle that does not exist stops the run at its first step after read_args, with nothing stopped.
const gone=join(e.root,'no.bundle');
for(const [why,text] of [
['library, no --send',e.argsText.replace('--\n','--profile\nlibrary\n--\n')],
['default profile, no --send',e.argsText],
['factory with --send, which case2-unit.sh refuses at the render as before',e.argsText.replace('--\n','--profile\nfactory\n--\n')+'--send\n'],
['factory, no --send',e.argsText.replace('--\n','--profile\nfactory\n--\n')],
]){
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',gone,'--sha',C1,...TO);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','fetch','result'],why);
assert.match(slog(e).at(-1),/^result exit=3 fetch-failed /,why);
}
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
// R3-F1 (#385, PR 1836): a library host rendered by base case2-unit.sh has --send in its installed Console
// unit's ExecStart, not only in its args file. With the args line removed, A2 would stop both units and A4
// and render-old would both refuse the installed unit (check_unit's ExecStart and the full-text match), so
// the driver refuses console-unit-legacy right after read_args. These cases run real renderers: base
// 09a847eda's case2-unit.sh, from git show into the module fixture, renders the legacy pair, and the
// checkout's own case2-unit.sh is A4's RENDER, both into an owned unit directory with fake getent and
// systemctl.
const BASE_REV='09a847eda';
let baseRenderer;
const baseCase2=()=>{
if(baseRenderer)return baseRenderer;
const d=join(base,'case2-base');
for(const p of ['scripts/case2-unit.sh','packages/cli/systemd/mosaic-bus.service.in','packages/webui/systemd/mosaic-webui.service.in']){
mkdirSync(dirname(join(d,p)),{recursive:true});writeFileSync(join(d,p),execFileSync('git',['-C',ROOT,'show',`${BASE_REV}:${p}`]),{mode:p.endsWith('.sh')?0o755:0o644});
}
return baseRenderer=join(d,'scripts','case2-unit.sh');
};
// The stack's home gets a proof file; dir is a Case 2 unit directory that A4's real renderer and the driver
// read. args(copy, extra) is a library args file's lines without --send; legacy() renders base's pair into dir.
function case2Host(e,dir){
const home=e.env.HOME,secrets=join(home,'.config','mosaic-dev','secrets');mkdirSync(secrets,{recursive:true,mode:0o700});
const proof=join(secrets,'proxy-proof');writeFileSync(proof,'made-up-proof\n');chmodSync(proof,0o600);
const c2=join(e.root,'case2'),bin=join(c2,'bin'),log=join(c2,'systemctl.log');mkdirSync(bin,{recursive:true});mkdirSync(dir,{recursive:true});
const uid=process.getuid(),gid=process.getgid();
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture") echo "fixture:x:${uid}:${gid}::${home}:/bin/bash" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
writeFileSync(join(bin,'systemctl'),`#!/bin/sh\necho "$*" >> '${log}'\n[ "$1" != is-active ] || exit 3\n`,{mode:0o755});
const env={CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
const serve=['--port','443','--business','acme','--public-host','books.example','--auth-header','X-Auth-User','--allow-user','melanie','--proof-header','X-Proxy-Proof','--proof-file',proof];
const args=(copy=B,extra=[])=>['--user','fixture','--home',home,'--business','acme','--copy',copy,'--node',NODE,...extra,'--',...serve];
const render=(script,a)=>{const r=spawnSync('bash',[script,...a],{env:{PATH:e.env.PATH,...env},encoding:'utf8'});return {code:r.status,out:r.stdout,err:r.stderr}};
const legacy=()=>{const a=args();a.splice(a.indexOf('melanie')+1,0,'--send');const r=render(baseCase2(),a);assert.equal(r.code,0,r.err);return r};
const bytes=()=>Object.fromEntries(readdirSync(dir).sort().map((n)=>[n,readFileSync(join(dir,n),'utf8')]));
const syslog=()=>lines(log);
// The driver reads dir, and A4's RENDER is the checkout's real case2-unit.sh with the same seams.
const use=(text)=>{writeFileSync(e.args,text);chmodSync(e.args,0o640);e.argsText=text;e.denv.STACK_UPGRADE_UNIT_DIR=dir;delete e.denv.STACK_UPGRADE_RENDER;Object.assign(e.denv,env)};
return {home,dir,args,render,legacy,bytes,syslog,use,con:join(dir,'mosaic-webui.service')};
}
const legacyUnit=(f)=>`${f} runs serve with --send, so it was rendered before #385, and stack-upgrade.sh cannot migrate it to the read-only library Console: the units are left running; stop and report (docs/guides/host-run-sheet.md section 7) (nothing was stopped)`;
test('#385 R3-F1: an installed pre-#385 library Console unit with --send refuses console-unit-legacy before anything stops, for upgrade and rollback',async(t)=>{
const e=await useed(t),h=case2Host(e,join(e.root,'case2','units'));
h.legacy();
const c=readFileSync(h.con,'utf8');
assert.ok(c.includes(`\nExecStart=${NODE} ${e.hc}/${B}/packages/webui/src/cli.mjs serve --port 443 --business acme --public-host books.example --auth-header X-Auth-User --allow-user melanie --send --proof-header X-Proxy-Proof --proof-file `),c);
// The operator removed the args file's --send line, as R3's section 7 said, so read_args passes.
h.use(h.args().join('\n')+'\n');
assert.ok(!e.argsText.includes('--send'));
const before=h.bytes(),sys=h.syslog().length;
// The premise: the current renderer, as A4 runs it, refuses the installed legacy unit and writes nothing.
const p=h.render(join(checkout(C1),'scripts','case2-unit.sh'),h.args(C1));
assert.equal(p.code,1,p.out+p.err);
assert.ok(p.err.startsWith(`case2-unit: installed ${h.con}: ExecStart= must be exactly once in [Service], as ExecStart=${NODE} ${e.hc}/${B}/packages/webui/src/cli.mjs serve --port 443 `),p.err);
assert.deepEqual(h.bytes(),before);assert.equal(h.syslog().length,sys);
for(const [why,run] of [['upgrade',()=>upgrade(e,C1)],['rollback',()=>drive(e,B,'rollback','--instance','dev','--to',B,...TO)]]){
const n=existsSync(e.slog)?slog(e).length:0;
const r=await run();assert.equal(r.code,3,`${why}: ${r.all}`);
assert.equal(r.err.split('\n').at(-2),`stack-upgrade: console-unit-legacy: ${legacyUnit(h.con)}`,why);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','result'],why);
assert.equal(slog(e).at(-1),`result exit=3 console-unit-legacy ${legacyUnit(h.con)}`,why);
argsKept(e);assert.deepEqual(h.bytes(),before,why);
}
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'render.log')));
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.equal(h.syslog().length,sys);
assert.deepEqual(live(e),['active','active',B,B]);
});
test('#385 R3-F1 controls: a read-only library Console unit, a factory host\'s unit and a missing Console unit pass the check',async(t)=>{
const e=await useed(t);
const ro=case2Host(e,join(e.root,'case2','ro')),old=case2Host(e,join(e.root,'case2','legacy')),none=case2Host(e,join(e.root,'case2','none'));
const r0=ro.render(join(checkout(C1),'scripts','case2-unit.sh'),ro.args());assert.equal(r0.code,0,r0.err);
assert.ok(!readFileSync(ro.con,'utf8').includes('--send'));
old.legacy();
none.legacy();rmSync(none.con);
// A bundle that does not exist stops the run at its first step after the check, with nothing stopped.
const gone=join(e.root,'no.bundle');
for(const [why,h,extra] of [['a read-only library unit',ro,[]],['a factory args file over a unit holding --send',old,['--profile','factory']],['no installed Console unit',none,[]]]){
h.use(h.args(B,extra).join('\n')+'\n');
const n=existsSync(e.slog)?slog(e).length:0;
const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',gone,'--sha',C1,...TO);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','fetch','result'],why);
assert.match(slog(e).at(-1),/^result exit=3 fetch-failed /,why);
}
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('row 233: hostcheck (row 240): a nonzero --check or a missing file refuses profile-drift before the plan',async(t)=>{
const e=await useed(t);
fault(e,'hostcheck-rc','1');
+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
ID, or just a loopback Host, and read what a reader capability reads. The
user id is not a secret; the sign-in at the edge is what keeps others out.
Under `--send` the first of those can also send (next section).
Header preview is read-only. A configured proxy proof does not permit writes;
`--send` requires the Console's verified OpenID Connect `--sso` mode.
To run the Console as a systemd user unit, `scripts/webui-service.sh`
renders and installs one with these flags (row 75; `docs/TOOLS.md`
@@ -104,7 +105,7 @@ renders and installs one with these flags (row 75; `docs/TOOLS.md`
decision 102, which accepts that sends from a Console Mos started enter the
trail as Jason. Any other agent starting it works around the human proof
(lead decisions 62 and 94). Without `--send` the unit is read-only. The script takes `--send` (row 83)
only with the three preview flags; otherwise it exits 1 and writes nothing.
only with `--sso`; otherwise it exits 1 and writes nothing.
With it, the unit's ExecStart ends with `--send`, and the Console needs the
system config at start: without it serve refuses with `--send needs the bus
configured` and exits 2, which the unit doesn't restart. `status` says
@@ -147,7 +148,7 @@ and one or more providers, each with its issuer, client id, a client secret
slot, the signing algorithms it accepts and the subjects allowed in). The
design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
takes `--sso` in place of the preview set.
requires `--sso`. Header-preview mode cannot enable the human writer.
- The client secret is read once at start from
`<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)
```sh
node packages/webui/src/cli.mjs serve --port 7332 \
--public-host NAME --auth-header HEADER --allow-user ID --send
node packages/webui/src/cli.mjs serve --port 7332 --sso --send
```
`--send` is off by default. It goes only with the three preview flags and
refuses at start without them. It also needs the system config: without it
the start refuses with `--send needs the bus configured`, where a read-only
`--send` is off by default. It requires verified OIDC through `--sso` and
refuses header-preview configurations, including those with proxy proof.
It also needs the system config, including `console.sso`, where a read-only
Console would still serve the board. A unit that sends comes from
`scripts/webui-service.sh install` with `--send` (above). With `--send` the
Inbox has a "Message a role" box: a role picker filled from the agents read,
@@ -199,23 +199,16 @@ decision 95). Decisions are still answered in a
terminal. There are no other message classes, no corrections and no seat
conversations here.
What `--send` grants: two kinds of client can send a human instruction to
any role while it runs.
- Anyone the proxy lets through as ID.
- Any local process that reaches the port and sets HEADER to ID with
`Host: NAME`. The user id is not a secret. This is the same class of
exposure as the human CLI (lead decision 62), accepted for the PoC by
lead decision 95.
With `--send`, the Console authenticates each user through its OIDC session
and uses that user's authorized transport. Caller-supplied identity headers
and a loopback connection do not authenticate a writer.
The request is POST `/api/bus/message` with a JSON object `{to, body,
in_reply_to?}`. Nothing is sent unless all of these hold, each checked on
every request:
- Host is NAME and HEADER is ID, once: otherwise 401 `sign-in required`.
There is no loopback path. The operator's browser on `127.0.0.1` reads
but can't send, and a loopback Host with HEADER is 403 `proxy Host
refused`, as above.
- Host is the configured OIDC public Host and the request has a valid
signed-in session. There is no loopback operator path under `--sso`.
- Origin is exactly `https://NAME`, else 403 `cross-origin request refused`.
- `Sec-Fetch-Site` is sent once and is exactly `same-origin`, else 403
`cross-site request refused`. Browsers set it, and page script can't.
@@ -996,8 +989,8 @@ threads and read/send messages. `POST /api/chat/threads/UUID/open` is the explic
**Resume assistant** action. Reading a thread never opens it or acknowledges a
message. Sending uses `thread.send` with class REQUEST, as the human CLI does.
All four POST operations need `--send`, the named preview Host, the exact
single authenticated user header, the Console's HTTPS Origin, same-origin Fetch
All four POST operations need `--sso --send`, the configured public Host, a
valid OIDC session, the Console's HTTPS Origin, same-origin Fetch
Metadata and a strict JSON object within 16 KiB. The broker binds the business
human and enforces ownership. A same-business thread owned by someone else
returns 403 `thread-not-owned`; a missing/foreign thread returns 404. No browser
+3 -3
View File
@@ -18,7 +18,7 @@ import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
@@ -60,8 +60,8 @@ try {
}
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
options.preview = previewOptions(preview);
if (sendOn && !ssoOn) throw new Error('--send needs --sso; header preview is read-only');
const proof = proofOptions(proofFlags, options.preview);
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
// The Queue, Business and Settings reads (row 54) need no bus; without
@@ -127,7 +127,7 @@ try {
});
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
}
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : '';
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
const health = sso?.health() ?? {};
+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 });
}
}
// Row 76 (#1570, lead decision 95): with --send, the one POST preview mode allows, a human
// message to a role. Checked here again whatever passed above: the header on this request, its own
// Row 76 (#1570, lead decision 95): with OIDC --sso --send, a human message to a role.
// Checked here again whatever passed above: the session on this request, its own
// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB, and only these keys. `to` and the body are the
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
// so it carries `sso: true` and the public origin. The header-preview signer is read-only.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
async function busSend(req, res, { send, preview, log }) {
@@ -648,7 +648,7 @@ async function libraryStatusRead(res, read, search) {
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with `sso`.
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
@@ -678,7 +678,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
if (send !== null && (sso === null || typeof send !== 'function')) throw new Error('--send needs --sso; header preview is read-only');
const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, upstream, files } = owner, granted = false, chatGrants = [];
+21 -20
View File
@@ -13,9 +13,8 @@ import { startServer } from '../src/serve.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
import { BusReadError } from '../src/bus.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const REV = 'a'.repeat(64);
const ID = '0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
const read = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false, kept: false } }, definitions: {}, noHire: [], retired: [], ...extra });
@@ -42,9 +41,11 @@ async function setup(t, { on = true, agent = async (verb, args) => ANSWERS[verb]
t.after(() => new Promise(r => server.close(r)));
return { calls, lines, dataRoot };
}
const server = await startServer({ port: 0, preview, send: on ? async () => ({}) : null, ...options });
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ port: 0, sso: auth.sso, send: on ? async () => ({}) : null, ...options });
t.after(() => new Promise(r => server.close(r)));
return { calls, lines, port: server.address().port, headers };
const session = await auth.signIn(server.address().port);
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
}
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
@@ -84,20 +85,20 @@ test('row 194 T16: each agent action makes one call with only its keys, logs the
test('row 194 T16: the boundary refuses before any bus call', async t => {
const s = await setup(t);
const cases = [
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'x-user': 'mallory' }, 401],
['/api/agent/launch', { instance: 'coder' }, { ...headers, origin: 'https://evil.example' }, 403],
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'sec-fetch-site': 'cross-site' }, 403],
['/api/agent/launch', { instance: 'coder', extra: 'x' }, headers, 400],
['/api/agent/launch', {}, headers, 400],
['/api/agent/launch', { instance: 7 }, headers, 400],
['/api/agent/launch', ['coder'], headers, 400],
['/api/agent/launch', 'not json', headers, 400],
['/api/agent/launch', { instance: 'x'.repeat(17000) }, headers, 400],
['/api/agent/terminate', { instance: 'coder', reason: 3 }, headers, 400],
['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, headers, 400],
['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, headers, 400],
['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, headers, 400],
['/api/veto', { op: 'keep', name: 'coder' }, headers, 400],
['/api/agent/launch', { instance: 'coder' }, { ...s.headers, cookie: '__Host-mosaic_session=invalid' }, 401],
['/api/agent/launch', { instance: 'coder' }, { ...s.headers, origin: 'https://evil.example' }, 403],
['/api/agent/launch', { instance: 'coder' }, { ...s.headers, 'sec-fetch-site': 'cross-site' }, 403],
['/api/agent/launch', { instance: 'coder', extra: 'x' }, s.headers, 400],
['/api/agent/launch', {}, s.headers, 400],
['/api/agent/launch', { instance: 7 }, s.headers, 400],
['/api/agent/launch', ['coder'], s.headers, 400],
['/api/agent/launch', 'not json', s.headers, 400],
['/api/agent/launch', { instance: 'x'.repeat(17000) }, s.headers, 400],
['/api/agent/terminate', { instance: 'coder', reason: 3 }, s.headers, 400],
['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, s.headers, 400],
['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, s.headers, 400],
['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, s.headers, 400],
['/api/veto', { op: 'keep', name: 'coder' }, s.headers, 400],
];
for (const [path, body, hs, status] of cases) {
const r = await send(s, path, body, hs);
@@ -213,11 +214,11 @@ test('row 194 T16 D4.1: the tester listener launches, terminates and reads reque
test('row 194 N5: agents.js is served as JavaScript and index.html loads it once', async t => {
const s = await setup(t);
const js = await get(s, '/agents.js', { host: 'console.example', 'x-user': 'jason' });
const js = await get(s, '/agents.js');
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic business instance from the instance CLI\./);
const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' });
const html = await get(s, '/');
assert.equal(html.text.match(/<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 { startServer } from '../src/serve.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 } = {}) {
const calls = [], lines = [];
const auth = await createSsoSession(t, { host: 'console.example' });
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,
assistantStop: on ? async args => { calls.push(['assistant.stop', args]); return stop(args); } : null,
});
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 => {
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);
for (const path of ['/api/name', '/api/assistant-stop']) {
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]]) {
const hs = { ...headers };
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 = { ...s.headers };
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, undefined, headers, 'GET')).status, 405);
assert.equal((await send(s, path, undefined, s.headers, 'GET')).status, 405);
}
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));
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/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);
});
@@ -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 => {
const rows = [{ instance: 'assistant', name: 'piper', at: '2026-10-18T00:00:00.000Z' }];
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.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 { BusReadError } from "../src/bus.mjs";
import { close } from "./fixture.mjs";
import { createSsoSession } from "./helpers/sso-session.mjs";
const P = "10000000-0000-4000-8000-000000000001";
const T = "20000000-0000-4000-8000-000000000002";
const preview = { host: "chat.example.com", header: "remote-user", user: "jason" };
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) {
function http(port, headers, path, body) {
const raw = body === undefined ? undefined : JSON.stringify(body);
return new Promise((resolve, reject) => {
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) {
const calls = [], logs = [];
const auth = await createSsoSession(t, { host: 'chat.example.com' });
const chat = createChat({ call: async (verb, args) => {
calls.push([verb, args]);
if (verb === "plugins") return { business: "demo", revision: "a".repeat(64), plugins: selected === undefined ? {} : { channel: selected } };
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));
const session = await auth.signIn(server.address().port);
const headers = auth.writeHeaders(session);
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 => {
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));
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;
assert.equal(res.status, 503);
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 { BusReadError } from '../src/bus.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
// legacy thread, with the thread's id.
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}/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));
return new Promise((resolve, reject) => {
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 => {
let text = '';
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);
});
}
async function setup(t, options = {}) {
const calls = [], logs = [];
const auth = await createSsoSession(t, { host: preview.host });
const state = { answer: [] };
const managementReads = [];
const chat = createChat({ call: async (verb, args) => {
@@ -49,9 +52,10 @@ async function setup(t, options = {}) {
if (state.answer instanceof Error) throw 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));
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 => {
@@ -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 => {
const c = await setup(t);
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'] },
{ 'sec-fetch-site': '' }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': ['same-origin', 'same-origin'] },
{ 'content-type': 'text/plain' },
@@ -109,7 +113,7 @@ for (const [path, value, verb, args, answer] of posts) {
assert.deepEqual(c.calls, []);
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 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, []);
});
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 })); }
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));
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 project = (await write('/api/chat/projects', { name: 'Project one', repo: 'stack' })).data.id;
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 { 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))]);
req.method = 'POST'; req.headers = { ...headers };
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 === 'site') req.headersDistinct['sec-fetch-site'] = [];
let status, result, calls = 0;
const chat = createChat({ call: async () => { calls++; return { id: M }; } });
const res = { writeHead(code) { status = code; }, end(bytes) { result = JSON.parse(bytes); } };
await chatHTTP(req, res, { path, search: '', chat, named: fence !== 'named',
send: fence === 'send' ? null : async () => ({}), preview, log: () => {} });
assert.equal(status, fence === 'identity' ? 401 : 403, path + ' ' + fence);
send: fence === 'send' ? null : async () => ({}), preview: signer, log: () => {} });
assert.equal(status, 403, path + ' ' + fence);
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 => {
const { spawn } = await import('node:child_process');
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 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 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'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
'--sso', '--send'],
{ env: { ...env, NODE_EXTRA_CA_CERTS: ca }, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
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.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
// 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'} }]]) {
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');
}
});
@@ -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 => {
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.deepEqual(c.calls, []);
@@ -424,7 +453,7 @@ test('264e cancel is an authenticated, confirmed human write and retains retirem
}
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 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);
for(const code of ['session-clear-complete','session-name-too-long','session-op-conflict']) {
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 { fixture, tmp } from '../../cli/tests/helpers.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 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 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 } = {}) {
const calls = [], lines = [];
const auth = await createSsoSession(t, { host: 'console.example' });
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,
});
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 => {
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 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 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);
const [current] = read.body.rows;
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(unsent.calls.length, 0);
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]]) {
const hs = { ...headers };
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 = { ...s.headers };
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', undefined, headers, 'GET')).status, 405);
assert.equal((await send(s, '/api/instances', undefined, s.headers, 'GET')).status, 405);
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, 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', 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.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 => {
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);
assert.equal((await get(none, '/api/bus/instances')).status, 503);
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 { startServer, userRoute } from '../src/serve.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 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 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: [] };
async function setup(t, { enabled = true, revert, status, lines = [] } = {}) {
const calls = { revert: [], status: [] };
const auth = await createSsoSession(t, { host: 'console.example' });
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,
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,
});
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) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
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));
});
}
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); };
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 });
assert.deepEqual((await call(off)).body, { error: 'read-only', sent: false });
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 };
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;
const r = await call(s, { hs });
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.
test('a granted-user HTTP session cannot reach owner library callbacks', async t => {
let ownerCalls = 0, userCalls = 0;
const sso = { publicHost: 'console.example', publicOrigin: 'https://console.example',
formTargets: () => [], session: () => ({ name: 'Alice', user: 'alice' }) };
const server = await startServer({ port: 0, sso, send: async () => ({}),
const access = { accessVersion: 1, revision: 1,
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
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; },
libraryRevert: async () => { ownerCalls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; },
userOptions: () => { userCalls++; return {}; }, log: () => {} });
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 }, {}]) {
const r = await call(s, options);
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,
libraryRevert: async () => { calls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; } });
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.body.error, 'read-only');
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 {close} from './fixture.mjs';
import {views} from '../../bus/src/views.mjs';
import {createSsoSession} from './helpers/sso-session.mjs';
const SINCE = '2026-10-18T07:00:00.000Z';
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' };
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) {
function post(port, headers, data) {
return new Promise((resolve, reject) => {
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 }));
@@ -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 => {
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)));
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 r = await post(port, reset);
const r = await post(port, headers, reset);
assert.equal(r.status, 200, r.text);
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);
});
@@ -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 { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const GEN = '12345678-1234-4234-8234-123456789abc';
function http(server, path, { method = 'GET', headers = {}, body = '' } = {}) {
return new Promise((resolve, reject) => {
const address = server.address();
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) }));
}); 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 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,
preview: { host: 'console.example', header: 'x-user', user: 'jason' },
send: send ? async () => {} : null, ...options });
sso: auth.sso, 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 }); });
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'],
['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}`,
origin: `http://127.0.0.1:${f.server.address().port}`, 'x-user': undefined } }, 403, 'read-only'],
['duplicate identity', { headers: { ...POST.headers, 'x-user': ['jason', 'jason'] } }, 401, 'sign-in required'],
origin: `http://127.0.0.1:${f.server.address().port}` } }, 403, 'public Host 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'],
['extra key', { body: '{"human":"jason"}' }, 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.
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]) {
const calls = [];
const sso = { formTargets: () => [], publicHost: 'console.example', publicOrigin: 'https://console.example',
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 };
const f = await fixture(t, { send: enabled }), calls = f.calls, server = f.server;
const headers = { ...POST.headers };
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.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 { startServer } from '../src/serve.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 C1 = 'a'.repeat(40), C2 = 'b'.repeat(40);
const payload = { project: 'stack', path: 'spec/north-star.md', base: C1, content: '# Edited\n' };
async function setup(t, { enabled = true, save, lines = [] } = {}) {
const calls = { files: [], file: [], save: [] };
const auth = await createSsoSession(t, { host: 'console.example' });
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,
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' }; },
@@ -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,
});
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) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
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));
});
}
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 => {
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);
assert.equal(refused.status, 403); assert.equal(refused.body.sent, false); assert.equal(off.calls.save.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 };
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;
const r = await call(s, { hs });
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);
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, { 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(s.calls.save.length, 0);
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'])
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 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 => {
@@ -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);
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)));
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-files?project=nope')).status, 400);
const none = await startServer({ port: 0, preview, log() {} });
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 => {
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(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":[]}'); });
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}`,
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));
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 };
writeFileSync(f.file, 'changed_fixture_value_012345678901234567890');
assert.equal((await ask(f.port, { headers })).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' }) });
assert.equal(sent.status, 200); assert.equal(sent.body.id, 'receipt-1');
assert.deepEqual(f.calls, ['tasks', 'board', 'send']);
assert.equal(sent.status, 403); assert.equal(sent.body.error, 'read-only');
assert.deepEqual(f.calls, ['tasks', 'board']);
const config = await ask(f.port, { headers, path: '/api/config' });
assert.equal(config.status, 200); assert.ok(!config.text.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 { Broker } from '../../bus/src/broker.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))]);
test('API-key canary reaches fake once and zero responses/logs/receipts/store files', async t => {
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' } } } });
const cap = b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true });
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) });
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 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); });
}); 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 { startServer } from '../src/serve.mjs';
import { consoleBus } from '../src/console-bus.mjs';
const preview = { host: 'console.example', header: 'x-user', user: 'owner' };
const headers = { host: preview.host, 'x-user': 'owner', origin: 'https://console.example',
'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
import { consoleAccess } from '../src/access.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const routes = [
['accounts', 'GET', 'proxy.accounts', {}], ['keys', 'GET', 'proxy.keys', {}],
['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' }],
['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 => {
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 = {}) {
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 }; },
log: l => logs.push(l), ...options });
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 };
}
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);
for (const value of ['p4-canary-key', 'fixture', 'redirect_url']) assert.equal(logs.join('\n').includes(value), false);
});
for (const [name, hs, code] of [
['signed-out', { ...headers, 'x-user': 'other' }, 401],
['loopback', { ...headers, host: '127.0.0.1' }, 403],
['cross-origin', { ...headers, origin: 'https://other.example' }, 403],
['missing Fetch Metadata', Object.fromEntries(Object.entries(headers).filter(([k]) => k !== 'sec-fetch-site')), 403],
for (const [name, change, code] of [
['signed-out', hs => { delete hs.cookie; }, 401],
['loopback', hs => { hs.host = '127.0.0.1'; }, 403],
['cross-origin', hs => { hs.origin = 'https://other.example'; }, 403],
['missing Fetch Metadata', hs => { delete hs['sec-fetch-site']; }, 403],
]) test(`proxy write refuses ${name}`, async 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.deepEqual(calls, []);
});
@@ -57,9 +60,16 @@ test('all proxy writes are read-only without --send', async t => {
assert.deepEqual(calls, []);
});
test('granted user never reaches owner page or any proxy route', async t => {
const sso = { publicHost: preview.host, publicOrigin: headers.origin, formTargets: () => [],
session: () => ({ name: 'Alice', user: 'alice' }) };
const { s, calls } = await setup(t, { preview: null, sso, userOptions: () => { throw Error('must not bind'); } });
const access = { accessVersion: 1, revision: 1,
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
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);
for (const [path, method, , args] of routes)
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 canary = 'p4-canary-key';
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 }));
}); 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 { createChat } from '../src/chat.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 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 = {
'unknown-addressee': 'Unknown addressee. Choose a room member.',
'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 } = {}) {
const calls = [], state = { value: null };
const auth = await createSsoSession(t, { host: 'console.example' });
const chat = createChat({ call: async (verb, args) => {
if (verb === 'plugins') return { plugins: { channel: ['webui'] } };
calls.push([verb, args]);
if (state.value instanceof Error) throw 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(); }));
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 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) }));
});
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 => {
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);
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);
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.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 {systemFor} from '../../business/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 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)}));});
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 web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
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();
try{
// 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.
await b.evaluate('document.querySelector("#routes-receipt").textContent="";document.querySelector("#routes-form").requestSubmit();true');
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 wait('!document.querySelector("#routes-form button[type=submit]")?.textContent.startsWith("Saving")&&document.querySelector("#routes-receipt")?.textContent!==""');
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 { systemFor } from '../../business/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 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 });
async function setup(t, { on = true, writer = on, write = async () => view(), bus = null } = {}) {
const calls = [], lines = [];
const auth = await createSsoSession(t, { host: 'console.example' });
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,
});
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 => {
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.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 => {
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 });
assert.equal((await send(unwritten, '/api/routes', data)).status, 403);
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]]) {
const hs = { ...headers };
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 = { ...s.headers };
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', undefined, headers, 'GET')).status, 405);
assert.equal((await send(s, '/api/routes', undefined, s.headers, 'GET')).status, 405);
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 },
[], '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', 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);
// 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'); } });
+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.
// Only with the preview flags; POST /api/bus/message needs the header on this request, its own
// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker
// Row 76 (#1570, lead decision 95): `serve --sso --send`, a human message to a role from the Console.
// POST /api/bus/message needs a verified OIDC session on this request, its own Origin,
// 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.
// Every other write is still 403 read-only. Nothing here starts the real human transport.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process';
import { spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { BusReadError, humanCall } from '../src/bus.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 preview = { host: NAME, header: HEADER, user: ID };
const NAME = SSO_HOST;
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 = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
const without = (headers, name) => Object.fromEntries(Object.entries(headers).filter(([k]) => k !== name));
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.
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) => {
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 => {
@@ -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 state = { answer: async () => ({ id: 'msg-0001' }) };
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));
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 () => {
for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: null }, { send: 'yes', preview }, { send: {}, preview }]) {
test('startServer takes send only as a function and only with verified OIDC', async t => {
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);
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 env = { ...process.env, MOSAIC_CONFIG: join(root, 'no-such-config.json') };
for (const [flags, why] of [
[['--send'], /^refused: --send needs the preview mode/],
[['--send', '--public-host', NAME, '--auth-header', HEADER], /^refused: preview mode needs/],
[['--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], /^refused: --send needs the bus configured \(/],
[['--send', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID], /^refused: --send needs the bus configured \(/],
[['--send'], /^refused: --send needs --sso/],
[['--send', '--public-host', NAME, '--auth-header', 'x-user'], /^refused: preview mode needs/],
[['--public-host', NAME, '--auth-header', 'x-user', '--allow-user', 'jason', '--send'], /^refused: --send needs --sso/],
[['--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 });
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 => {
const { port, sends, lines } = await setup(t, { send: null });
const r = await post(port);
const { post, sends, lines } = await setup(t, { send: null });
const r = await post();
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.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 => {
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']]) {
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.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]);
});
test('config says send only on the public name with --send on', async t => {
const { port } = 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(port, { method: 'GET', path: '/api/config', headers: { host: `127.0.0.1:${port}` }, body: undefined })).body.send, false);
test('config says send for the signed-in public name and refuses the loopback operator path', async t => {
const { port, post, headers } = await setup(t);
assert.equal((await post({ method: 'GET', path: '/api/config', body: undefined })).body.send, true);
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 => {
const { port, sends, lines } = await setup(t);
for (const [why, headers] of [
['no header', without(HEADER)],
['another user', { ...ok, [HEADER]: 'mallory' }],
['the id with a suffix', { ...ok, [HEADER]: ID + 'x' }],
['two values', { ...ok, [HEADER]: [ID, ID] }],
['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}` }],
test('a send without a verified OIDC session is 401 and is neither sent nor logged', async t => {
const { post, headers, sends, lines } = await setup(t);
for (const [why, candidateHeaders] of [
['no cookie', without(headers, 'cookie')],
['a forged legacy identity header', { ...without(headers, 'cookie'), 'x-authentik-username': 'jason' }],
['an invalid session', { ...headers, cookie: '__Host-mosaic_session=invalid' }],
['two session cookies', { ...headers, cookie: `${headers.cookie}; ${headers.cookie}` }],
]) {
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);
}
// 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, []);
});
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 = [
['no Origin', { headers: without('origin') }, 403, 'cross-origin request refused'],
['no Sec-Fetch-Site', { headers: without('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']),
['two Sec-Fetch-Site', { headers: { ...ok, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'],
['no Content-Type', { headers: without('content-type') }, 400, 'invalid-request'],
['text/plain', { headers: { ...ok, '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'],
['no Origin', { headers: without(headers, 'origin') }, 403, 'cross-origin 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: { ...headers, 'sec-fetch-site': v } }, 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(headers, 'content-type') }, 400, 'invalid-request'],
['text/plain', { headers: { ...headers, 'content-type': 'text/plain' } }, 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'],
['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'],
@@ -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'],
];
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);
}
// 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']) {
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((await post(port, { method: 'GET', body: undefined })).status, 405);
assert.equal((await post(port, { method: 'PUT' })).status, 405);
assert.equal((await post({ method: 'GET', body: undefined })).status, 405);
assert.equal((await post({ method: 'PUT' })).status, 405);
assert.deepEqual(sends, []);
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);
@@ -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.
const fits = JSON.stringify({ to: 'coder', body: 'x'.repeat(16384 - JSON.stringify({ to: 'coder', body: '' }).length) });
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 => {
const { port, sends, lines, boardHits, busCalls } = await setup(t);
const r = await post(port, { body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) });
const { post, sends, lines, boardHits, busCalls } = await setup(t);
const r = await post({ body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) });
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']);
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);
// 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(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: '' }) })).status, 200);
assert.equal((await post({ body: JSON.stringify({ to: 7, body: ['x'] }) })).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.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body'], ['to', 'body', 'in_reply_to']]);
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 => {
const { port, lines, state } = await setup(t);
const { post, lines, state } = await setup(t);
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);
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[0].includes('\n'), false);
assert.match(lines[0], / error=unknown-role$/);
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.
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$`));
});
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 [
[new BusReadError('unknown-role'), 400, 'unknown-role', 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;
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);
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);
@@ -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');
writeFileSync(cli, FAKE);
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 r = await post(port);
const { post, lines } = await setup(t, { send: args => call('message.send', args) });
const r = await post();
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]);
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']);
const { readFileSync } = await import('node:fs');
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');
// 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 { port } = await setup(t, { send });
const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) });
const { post } = await setup(t, { send });
const r = await post({ body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) });
assert.equal(r.status, 200);
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.']]);
@@ -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'],
]) {
// 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.equal(messages().length, 1);
+34 -25
View File
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url';
// 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
// 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
// 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.
@@ -23,6 +23,7 @@ const NODE_DIR = dirname(process.execPath);
const UID = '6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e';
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 SSO_SEND = ['--sso', '--send'];
// A copy as `copy` leaves it, reduced to the file install looks for.
const COPY_ID = '0123456789abcdef0123456789abcdef01234567';
const INSTALL = ['--copy', COPY_ID, '--allow-worktree'];
@@ -32,7 +33,7 @@ function fakeCopy(dir) {
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
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');
// 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) => {
const dir = tmp(t);
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.match(execStart(r.stdout), new RegExp(` --tester-port ${port}( |$)`));
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 bin = standIn(dir);
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.match(installed.stdout, /^serve: .* --tester-port 1024( |$)/m);
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 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(' '));
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]);
assert.equal(r.status, 0, r.stderr);
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 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 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]);
assert.equal(r.status, 0, r.stderr);
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]);
assert.equal(without.status, 0, without.stderr);
const a = r.stdout.split('\n'), b = without.stdout.split('\n');
@@ -412,10 +413,18 @@ 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('render refuses duplicate --send under --sso', async (t) => {
const dir = tmp(t);
const r = await run(dir, ['render', '--sso', '--send', '--send']);
assert.equal(r.status, 1, r.stderr);
assert.equal(r.stdout, '');
assert.match(r.stderr, /^webui-service: --send given twice$/m);
});
test('--send without --sso, including every header-preview shape, is refused and writes nothing', async (t) => {
const dir = tmp(t);
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) {
const r = await run(dir, ['render', '--send', ...args]);
assert.equal(r.status, 1, args.join(' '));
@@ -446,8 +455,8 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
const unitDir = join(dir, 'units');
const target = join(unitDir, 'mosaic-webui.service');
const port = await closedPort();
const flags = `--port ${port} ${PREVIEW_FLAGS}`;
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send']);
const flags = `--port ${port} --sso`;
const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...SSO_SEND]);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm'));
// Lead decision 102: Jason or Mos starts it, no other agent.
@@ -462,9 +471,9 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
assert.equal(sending.status, 0, sending.stderr);
assert.match(sending.stdout, /^send: on \(--send; human messages to roles\)$/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'));
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.equal(readFileSync(target, 'utf8'), filled(`--checkout ${REPO} ${flags}`, copyRoot(dir)));
const off = await run(dir, ['status', '--dir', unitDir], bin);
@@ -474,10 +483,10 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
assert.deepEqual(otherCalls(join(dir, 'calls.txt')).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service/.test(c)), []);
const none = await run(dir, ['status', '--dir', join(dir, 'empty')], bin);
assert.doesNotMatch(none.stdout, /^send:/m, 'no unit, no send line');
// serve would take --send as an allowed user; N5 refuses it, so no unit
// ends with `--allow-user --send` without sending.
// This malformed identity makes --send the --allow-user value and is not a
// duplicate-flag guard; the dedicated --sso --send --send case above is.
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]);
assert.equal(r.status, 1, args.join(' '));
assert.match(r.stderr, /^webui-service: allowed user must start with a letter or digit: --send$/m);
@@ -489,7 +498,7 @@ test('uninstall and status take no --send', async (t) => {
const dir = tmp(t);
const bin = standIn(dir);
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']]) {
const r = await run(dir, args, bin);
assert.equal(r.status, 4, args.join(' '));
@@ -662,9 +671,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) => {
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.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(`^ExecStart=\\S+ ${esc(copyRoot(dir))}/packages/webui/src/cli\\.mjs serve --checkout ${esc(REPO)} `, 'm'));
const plain = await run(dir, ['render', ...PREVIEW]);
@@ -792,15 +801,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/);
});
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');
writeFileSync(file, 'fixture', { mode: 0o600 });
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);
const status = await run(dir, ['status', '--dir', unitDir], bin);
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/);
});
+8 -9
View File
@@ -3,22 +3,21 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {request} from 'node:http';
import {startServer} from '../src/serve.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'};
import {createSsoSession} from './helpers/sso-session.mjs';
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};}
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));});}
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=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 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 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=>{
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=>{
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 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$/);
for(const value of [{assistants:change.assistants},{...change,holder:'x'}])assert.equal((await post(s,headers,value)).status,400);assert.equal(calls.length,1);
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,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-'));
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') };
@@ -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);
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.
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'));
let out = '', err = '';
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.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.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
assert.equal(out.includes(a.clientId), false);
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}` });
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');
});
@@ -7,6 +7,7 @@ import { tmp } from '../../cli/tests/helpers.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './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 } = {}) {
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 => {
const calls = [];
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));
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' }]]) {
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, []);
+8 -4
View File
@@ -24,8 +24,10 @@
# The library profile takes exactly these serve flags, each once:
# --port N (1..1023) --business ID (the unit's) --public-host NAME
# A member that changes uid and cannot be killed refuses inner-cleanup, never success.
# --auth-header HEADER --allow-user ID --send
# --auth-header HEADER --allow-user ID
# --proof-header NAME --proof-file PATH
# It is proxy-preview read-only until Case 2 supports --sso (#412); --send
# is explicitly refused. The proof still protects proxy preview reads.
# The factory profile takes --port N (1..1023) and --business ID, and an
# optional bare --tester-socket marker. Retired tester TCP refuses. Only factory takes
# --pm and --claude.
@@ -343,15 +345,17 @@ serve_flags() {
--checkout) die "refusing --checkout: Case 2 has no queue panel" ;;
--board) die "refusing --board: Case 2 runs no control board" ;;
--sso) die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
--send)
[ "$PROFILE" = library ] || die "the factory profile refuses --send: its host has no proxy"
die "the library profile is read-only; refusing --send until Case 2 supports --sso (#412)" ;;
--port|--business) ;;
--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send)
--public-host|--auth-header|--allow-user|--proof-header|--proof-file)
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
*) die "unknown serve flag: $name" ;;
esac
[ -z "${seen[$name]:-}" ] || die "serve flag $name given twice"
seen[$name]=1
if [ "$name" = --tester-socket ]; then tester_socket=1; flags="$flags --tester-socket"; i=$((i + 1)); continue; fi
if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi
[ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value"
value="${SERVE[$((i + 1))]}"
case "$name" in
@@ -375,7 +379,7 @@ serve_flags() {
i=$((i + 2))
done
local required=(--port --business)
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --send --proof-header --proof-file)
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --proof-header --proof-file)
for name in "${required[@]}"; do
[ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
done
+10 -3
View File
@@ -1107,8 +1107,9 @@ const k=Object.hasOwn(d,"console")?"console":Object.hasOwn(d,"federation")?"fede
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
# it. A legacy pointer's implicit preview --send also drops before the plan reaches the installer,
# because header preview is read-only (#385). Verified --sso send is still carried. Sets SEND_PLAN
# (the plan's send: line) and SEND_STEP.
# Row 273 (mos-1246 F39): under forward auth any same-uid process can send as the signed-in user
# through the Console's port, so --send turns send on only when the run's Console flags hold --sso,
# given or carried. --no-sso drops the pointer's --send, and so does a rollback to a Console without
@@ -1165,7 +1166,13 @@ send_for_target() {
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
fi
elif [ "$had" = 1 ]; then
SEND_PLAN="send: carried"
if has_flag --sso "${RUN_CONSOLE[@]}"; then
SEND_PLAN="send: carried"
else
RUN_CONSOLE=("${without_send[@]}")
SEND_PLAN="send: dropped; header preview is read-only (#385)"
SEND_STEP="drop --send; header preview is read-only (#385)"
fi
else
SEND_PLAN="send: off"
fi
+41 -2
View File
@@ -120,9 +120,14 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")"
# --- The args file and the stack's user ---
# I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE and ARGS_COPY, and the paths derived from them.
# ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY and ARGS_PROFILE, and the paths derived
# from them.
# #385 R2-F1: base case2-unit.sh required --send for the library profile (its default, and the last
# --profile wins, as there), and the current one refuses it. A2 would stop both units, and A4 and
# render-old would both fail on the same args, so a library file holding --send after -- refuses
# here, before anything stops. The file is never rewritten; the operator removes the line.
read_args() {
local f="$ETC/$INSTANCE.args" st i copies=0 v
local f="$ETC/$INSTANCE.args" st i copies=0 v profile=library
[ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f"
st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
@@ -137,12 +142,20 @@ read_args() {
case "${ARGS[i]}" in
--user) S_USER="$v" ;; --home) S_HOME="$v" ;; --business) BUSINESS="$v" ;; --node) NODE="$v" ;;
--copy) ARGS_COPY="$v"; copies=$((copies + 1)) ;;
--profile) profile="$v" ;;
esac
i=$((i + 1)) ;;
*) refuse args-unreadable "$f has ${ARGS[i]} before --, which case2-unit.sh does not take" ;;
esac
done
[ "$copies" = 1 ] || refuse args-unreadable "$f needs exactly one --copy before --, not $copies"
ARGS_PROFILE="$profile"
if [ "$profile" = library ]; then
for ((i = i + 1; i < ${#ARGS[@]}; i++)); do
[ "${ARGS[i]}" != --send ] ||
refuse args-unreadable "$f has --send after --, which case2-unit.sh refuses for the library profile: remove the --send line, then rerun; the library Console is read-only until #412 (#385)"
done
fi
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$ARGS_COPY" =~ ^[0-9a-f]{40}$ ]] ||
refuse args-unreadable "$f lacks a plain --user, --home, --business, --node or --copy"
@@ -154,6 +167,30 @@ read_args() {
AS_USER=("$SUDO" -u "$S_USER" -H env "PATH=$(dirname "$NODE"):/usr/local/bin:/usr/bin:/bin")
}
# #385 R3-F1: base case2-unit.sh also put --send in the library Console unit's ExecStart. The current
# one checks an installed unit's ExecStart against its own read-only command, so A4 and render-old
# would both refuse that unit after A2 stopped both. No reviewed migration exists, so a library host
# whose installed Console unit has a --send token after serve refuses here, before anything runs. A
# missing unit is read_old's to refuse.
legacy_console() {
local f="$UNIT_DIR/$CON_UNIT" line t past
local -a lines words
[ "$ARGS_PROFILE" = library ] && [ -f "$f" ] && [ ! -L "$f" ] || return 0
mapfile -t lines < "$f" || refuse units-unreadable "cannot read $f"
for line in "${lines[@]}"; do
[[ "$line" =~ ^[[:space:]]*ExecStart[[:space:]]*=(.*)$ ]] || continue
read -r -a words <<< "${BASH_REMATCH[1]}"
past=""
for t in "${words[@]}"; do
if [ -n "$past" ]; then
[ "$t" != --send ] ||
refuse console-unit-legacy "$f runs serve with --send, so it was rendered before #385, and stack-upgrade.sh cannot migrate it to the read-only library Console: the units are left running; stop and report (docs/guides/host-run-sheet.md section 7)"
elif [ "$t" = serve ]; then past=1
fi
done
done
}
# RENDER_ARGS: the args file's lines with copy $1 in place of --copy's value.
args_with() {
local i past=""
@@ -285,6 +322,7 @@ run_steps() {
do_upgrade() {
read_args
legacy_console
# Before A1: the bundle into the stack's user's clone, then the copy from it (I1).
step fetch "${AS_USER[@]}" git -C "$CLONE" fetch -q "$BUNDLE" refs/heads/refactor:refs/remotes/origin/refactor ||
refuse fetch-failed "the stack's user could not fetch $BUNDLE into $CLONE"
@@ -331,6 +369,7 @@ rehearse() {
# check: the target is an older copy than this checkout, and no rehearsal.
do_rollback() {
read_args
legacy_console
[ -x "$COPIES_DIR/$TO/scripts/mosaic" ] || refuse copy-missing "no $COPIES_DIR/$TO/scripts/mosaic"
hostcheck
read_old
+7 -7
View File
@@ -43,8 +43,8 @@
# Console), --tester-socket (private Unix tester), or legacy --tester-port N
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies),
# --business ID, --board URL, and the preview set --public-host
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
# (row 83), taken only with the preview set: the unit's Console then sends
# NAME --auth-header HEADER --allow-user ID, all three or none. --send
# (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
# 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
@@ -52,7 +52,7 @@
# render checks ownership, mode and location without opening the proof file.
# --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,
# 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
# main listener's bus through the human transport; the optional loopback
@@ -93,7 +93,7 @@ instance_select
UNIT_NAME="$CONSOLE_UNIT"
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]
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.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -142,7 +142,7 @@ parse_flags() {
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
--send) [ "$SEND" = 0 ] || die "--send given twice"; SEND=1; SERVE_SET=1; shift ;;
--sso) SSO=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
@@ -223,12 +223,12 @@ serve_flags() {
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
# 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.
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
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
[ "$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