Compare commits

...
2 Commits
34 changed files with 656 additions and 372 deletions

No files matched your search

+3
View File
@@ -5816,3 +5816,6 @@ Before: agents can read the library; supporting-path and push-credential depende
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-10-29T03:27:31Z | Codex implementation sub-agent | #385 OIDC-only writer preparation on exact base 09a847edaa25b609bac7728ff83f224708a29399 | Added fail-closed CLI, programmatic and service-render refusals for `--send` without `--sso`; retained read-only header preview/proof; migrated inventoried writer tests to real in-process OIDC login/callback sessions and updated docs. Focused regression 5/5 and selected service cases 4/4 pass; syntax and diff checks pass. The broader writer suite is blocked before execution because the clean checkout has no `node_modules` and exact pinned `[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.
+2
View File
@@ -1879,3 +1879,5 @@ are never rewritten or removed; corrections are new entries.
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-10-29T03:27:31Z | Codex Daybreak Blue xhigh implementation sub-agent | #385 OIDC-only Console writes, branch work/vikunja-19-385-q284-complete at exact base 09a847edaa25b609bac7728ff83f224708a29399 | Source/test/docs preparation complete for review; focused refusal 5/5 and selected service 4/4 pass, broader real-OIDC writer tests blocked by absent pinned oauth4webapi@3.8.8; no install/commit/push/PR/queue move or overall-green claim.
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.
+11 -6
View File
@@ -493,6 +493,8 @@ 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.
```
This is an operator plan, not an action agents run. Both reviews and the
@@ -602,8 +604,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`.
@@ -982,16 +987,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`.
+5
View File
@@ -380,6 +380,11 @@ 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.
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.
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` on any of these rules.
## 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;
+76 -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,51 @@ 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:'),
].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:'',
});
});
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 +1429,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 +1471,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 +1520,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 +1543,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 +1645,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 +1847,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 +1859,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);
+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
+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