diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 58ca35875..e1b4b02be 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5818,3 +5818,4 @@ Before: agents can read the library; supporting-path and push-credential depende 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 `oauth4webapi@3.8.8` 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 `oauth4webapi@3.8.8` 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. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 8984dbd08..574a8794b 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1880,3 +1880,4 @@ are never rewritten or removed; corrections are new entries. 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. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index d98c6e398..90fa89d72 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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`. diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md index 2020f7a17..bc2afd190 100644 --- a/docs/guides/host-run-sheet.md +++ b/docs/guides/host-run-sheet.md @@ -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 diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index ea234e3ad..58fa8d061 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -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; diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 4f9bc0f3f..067bca7a2 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -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); diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index eef7de0ac..299675809 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -413,6 +413,14 @@ test('render --sso --send ends ExecStart with --send and changes nothing else', } }); +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)]; @@ -475,8 +483,8 @@ 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']]) { const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...args]); diff --git a/scripts/case2-unit.sh b/scripts/case2-unit.sh index 2361e0bd0..d669e8cbd 100755 --- a/scripts/case2-unit.sh +++ b/scripts/case2-unit.sh @@ -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 diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 2656f95b0..212a206bc 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -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 diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index 4dd0c7e99..75d602318 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -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