Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab2bc23c7f | ||
|
|
9936a1be1a |
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() ?? {};
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
}
|
||||
@@ -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() } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '');
|
||||
});
|
||||
@@ -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', {}]] : []);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'); } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user