From bd79daffdd04c028904b13fdcae7fd7e23c3b9cb Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 2 Nov 2026 22:09:01 -0600 Subject: [PATCH] fix(case2-unit): factory Console takes the paired --sso --send; .53 launches through the proxy (Vikunja #415, #416) The factory profile of scripts/case2-unit.sh now takes the bare pair --sso --send, ported from the lane's serve_flags rule (b00019548). Either flag alone refuses, and the proxy preview set and the proof pair still refuse. Before any unit write, a bounded query run as the stack user, through the existing config-query supervisor, checks that the stack user's system config has console.sso; the Console refuses --sso without it at start (packages/webui/src/cli.mjs:95). The server needs no change. The library profile is unchanged: still read-only, still refusing --send and --sso. Taking library --send with forward-auth and proof would need the #385 refusal reverted in cli.mjs:63, serve.mjs:681 and the service, release and upgrade scripts, so it is not done here. docs/guides/case1-business.mosaic-stack.json now launches claude-opus-5-5 with provider proxy, route anthropic, under account factory with credential proxy. --- docs/guides/case1-business.mosaic-stack.json | 9 ++- .../tests/case1-business-file.test.mjs | 15 +++- packages/cli/tests/case2-unit.test.mjs | 76 ++++++++++++++++++- scripts/case2-unit.sh | 33 +++++--- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/docs/guides/case1-business.mosaic-stack.json b/docs/guides/case1-business.mosaic-stack.json index ff155dc10..fa8dc7b71 100644 --- a/docs/guides/case1-business.mosaic-stack.json +++ b/docs/guides/case1-business.mosaic-stack.json @@ -38,10 +38,15 @@ "launch": { "by": "chief", "instances": ["assistant"], + "account": { + "name": "factory", + "credential": "proxy" + }, "models": { "claude-opus-5-5": { - "provider": "anthropic", - "model": "claude-opus-5-5" + "provider": "proxy", + "model": "claude-opus-5-5", + "route": "anthropic" } } } diff --git a/packages/business/tests/case1-business-file.test.mjs b/packages/business/tests/case1-business-file.test.mjs index 7226178a0..0d5853236 100644 --- a/packages/business/tests/case1-business-file.test.mjs +++ b/packages/business/tests/case1-business-file.test.mjs @@ -73,7 +73,9 @@ test("row 323: committed Case 1 document loads unchanged through the business lo assert.equal(b.launch.by, "chief"); assert.deepEqual(b.launch.instances, ["assistant"]); assert.deepEqual(b.arbiters, { delivery: "chief", technical: "chief" }); - assert.deepEqual(b.launch.models["claude-opus-5-5"], { provider: "anthropic", model: "claude-opus-5-5" }); + // #416: the .53 factory reaches Anthropic through the proxy, under the factory account. + assert.deepEqual(b.launch.models["claude-opus-5-5"], { provider: "proxy", model: "claude-opus-5-5", route: "anthropic" }); + assert.deepEqual(b.launch.account, { name: "factory", credential: "proxy" }); for (const role of Object.values(b.roles)) { assert.equal(role.vars.harness, "claude-code"); assert.equal(role.vars.model, "claude-opus-5-5"); @@ -83,6 +85,17 @@ test("row 323: committed Case 1 document loads unchanged through the business lo } }); +// #416 changed the shipped route; the direct-provider input it replaced still loads. +test("row 323: a direct Anthropic launch without an account still loads", (t) => { + const x = world(t); + x.doc.launch.models["claude-opus-5-5"] = { provider: "anthropic", model: "claude-opus-5-5" }; + delete x.doc.launch.account; + writeJson(x.business, x.doc); + const b = loadBusiness("mosaic-stack", { dir: x.configDir, rolesDir: REPO_ROLES }); + assert.deepEqual(b.launch.models["claude-opus-5-5"], { provider: "anthropic", model: "claude-opus-5-5" }); + assert.equal(b.launch.account, undefined); +}); + test("row 323: real business validate accepts the Case 1 fixture without tracker credentials", (t) => { const x = world(t); configure(x); diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index fb65a3af8..6849e347f 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -568,14 +568,86 @@ test("factory refuses duplicate socket, a missing --port or --business, and each refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), '--port', '443'] }), /given twice/); refuses(unit(h, { profile: "factory", flags: ["--business", "acme"] }), /serve flag --port is required/); refuses(unit(h, { profile: "factory", flags: ["--port", "443"] }), /serve flag --business is required/); - for (const [flag, value] of [["--public-host", "x.example"], ["--auth-header", "X-A"], ["--allow-user", "u"], ["--proof-header", "X-P"], ["--proof-file", h.proof], ["--send", null]]) { + for (const [flag, value] of [["--public-host", "x.example"], ["--auth-header", "X-A"], ["--allow-user", "u"], ["--proof-header", "X-P"], ["--proof-file", h.proof]]) { refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), new RegExp(`the factory profile refuses ${flag}`)); } - for (const [flag, value, pattern] of [["--checkout", "/srv/x", /refusing --checkout/], ["--board", "http://127.0.0.1:7331", /refusing --board/], ["--sso", null, /refusing --sso/], ["--nonsense", "1", /unknown serve flag/]]) { + for (const [flag, value, pattern] of [["--checkout", "/srv/x", /refusing --checkout/], ["--board", "http://127.0.0.1:7331", /refusing --board/], ["--nonsense", "1", /unknown serve flag/]]) { refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), pattern); } }); +// #415/#416: the factory Console signs its owner in itself (console.sso) and takes writes only as the +// paired --sso --send. The pair never unlocks the proxy preview set or the proof pair. +const ssoConfig = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { providers: [] } } }); +const noUnits = (h) => { + assert.equal(existsSync(busFile(h)), false); + assert.equal(existsSync(webuiFile(h)), false); + assert.equal(existsSync(h.log), false); +}; + +test("#415 factory with --sso --send renders and the Console unit carries both flags", (t) => { + const h = hub(t); + ssoConfig(h); + const r = unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--send"] }); + assert.equal(r.code, 0, r.out + r.err); + const webui = read(webuiFile(h)); + assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --tester-socket --sso --send`]); + assert.deepEqual(lines(webui, "EnvironmentFile"), []); + assert.deepEqual(webui.split("\n").filter((l) => /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l)), HARDENING(h)); + // The console.sso check ran as the stack user, through the same sudo as the tester query. + const sudo = read(h.sudoLog); + assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m")); + assert.match(sudo, /s\.console&&s\.console\.sso/); + // Without the tester marker the pair renders too, in the order given. + const g = hub(t); + ssoConfig(g); + assert.equal(unit(g, { profile: "factory", flags: ["--port", "443", "--send", "--business", "acme", "--sso"] }).code, 0); + assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --port 443 --send --business acme --sso`]); +}); + +test("#415 factory refuses --sso alone and --send alone before writing a unit", (t) => { + const h = hub(t); + ssoConfig(h); + refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/); + refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--send"] }), /the factory profile refuses --send without --sso/); + refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--sso", "--send"] }), /serve flag --sso given twice/); + refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--send", "--send"] }), /serve flag --send given twice/); + noUnits(h); +}); + +test("#415 factory with --sso --send still refuses each proxy preview flag and the proof flags", (t) => { + const h = hub(t); + ssoConfig(h); + for (const [flag, value] of [["--public-host", "x.example"], ["--auth-header", "X-A"], ["--allow-user", "u"], ["--proof-header", "X-P"], ["--proof-file", h.proof]]) { + refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--send", flag, value] }), new RegExp(`the factory profile refuses ${flag}: its host has no proxy`)); + } + noUnits(h); +}); + +test("#415 factory --sso refuses before any unit write when the stack user's config has no console.sso", (t) => { + const h = hub(t); + const pair = ["--port", "443", "--business", "acme", "--sso", "--send"]; + refuses(unit(h, { profile: "factory", flags: pair }), /--sso needs console\.sso in mosaic's system config/); + writeJson(h.query, { dataRoot: h.dataRoot, console: {} }); + refuses(unit(h, { profile: "factory", flags: pair }), /--sso needs console\.sso in mosaic's system config/); + noUnits(h); + // Only the exact one-line answer counts; anything else refuses, and so does a failed query. + for (const [output, status, pattern] of [['{"sso":true}\nforged', "0", /sso config query has invalid facts/], ['{"sso":"true"}', "0", /sso config query has invalid facts/], ['{"sso":true}', "1", /sso config query inner-command/]]) { + writeFileSync(join(h.root, "query-output"), output); + writeFileSync(join(h.root, "query-status"), status); + refuses(unit(h, { profile: "factory", flags: pair }), pattern); + } + noUnits(h); +}); + +test("#416 library still refuses --sso, alone and with --send, before writing a unit", (t) => { + const h = hub(t); + ssoConfig(h); + refuses(unit(h, { flags: [...libraryFlags(h), "--sso"] }), /refusing --sso: the Console signs people in through the proxy/); + refuses(unit(h, { flags: [...libraryFlags(h), "--sso", "--send"] }), /refusing --sso: the Console signs people in through the proxy/); + noUnits(h); +}); + test("row253 factory refuses retired TCP and a value after the socket marker", (t) => { const h = hub(t); refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/); diff --git a/scripts/case2-unit.sh b/scripts/case2-unit.sh index 477a6bd2c..85371cacc 100755 --- a/scripts/case2-unit.sh +++ b/scripts/case2-unit.sh @@ -20,7 +20,7 @@ # never imports copy code. This adds sudo and GNU timeout to the installer # dependencies. The inner query supervisor runs after the uid drop: 5 s inside, # 7 s on root's wait. Fixed system Bash, setsid, sleep, timeout and head are -# resolved and root-validated. Only the config query uses this supervisor. +# resolved and root-validated. Only the config queries use this supervisor. # It renders the default instance only. # # The library profile takes exactly these serve flags, each once: @@ -29,10 +29,13 @@ # --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. +# and --sso are 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. +# optional bare --tester-socket marker. It also takes the paired bare +# --sso --send (#415): the Console signs its owner in from console.sso, which +# the stack user's config must have; either one alone refuses, and the +# preview and proof flags still refuse. Retired tester TCP refuses. Only +# factory takes --pm and --claude. # # On a re-install the installed units must equal the render in everything # but the copy id, and all selected units must name one copy, so a re-pin or rollback @@ -298,6 +301,7 @@ tester_capture() ( cat -- "$file" ) +SSO_QUERY='import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({sso:Boolean(s.console&&s.console.sso)}));' TESTER_QUERY='import {readFileSync} from "node:fs";import {pathToFileURL} from "node:url";const m=await import(pathToFileURL(process.argv[1]).href);const s=m.loadSystem();console.log(JSON.stringify({dataRoot:s.dataRoot,socket:readFileSync(process.argv[2],"utf8").includes("MOSAIC_TESTER_SOCKET_V1")}));' tester_facts() { local LC_ALL=C pattern='^\{"dataRoot":"([A-Za-z0-9/._@+-]+)","socket":(true|false)\}$' @@ -342,7 +346,7 @@ tester_location() { serve_flags() { local -A seen=() local flags='' name value - local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 + local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_socket=0 sso=0 local i=0 n=${#SERVE[@]} while [ "$i" -lt "$n" ]; do name="${SERVE[$i]}" @@ -351,10 +355,8 @@ serve_flags() { --tester-socket) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-socket" ;; --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)" ;; + --sso) [ "$PROFILE" = factory ] || 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" = factory ] || 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) [ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;; @@ -363,6 +365,8 @@ serve_flags() { [ -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" = --sso ]; then sso=1; flags="$flags --sso"; 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 @@ -391,6 +395,17 @@ serve_flags() { [ -n "${seen[$name]:-}" ] || die "serve flag $name is required" done if [ "$PROFILE" = factory ]; then + # #415: only the paired owner sign-in and writes. No preview header or + # proof file is inferred. The Console refuses --sso without console.sso + # at start; this asks the stack user's config first, before any write. + [ "$sso" = 0 ] || [ -n "${seen[--send]:-}" ] || die "refusing --sso without --send in the factory profile" + [ -z "${seen[--send]:-}" ] || [ "$sso" = 1 ] || die "the factory profile refuses --send without --sso" + if [ "$sso" = 1 ]; then + local answer + answer="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$SSO_QUERY" "$COPY/packages/cli/src/config.mjs")" || die "sso config query $(tester_capture_reason "$?")" + [ "$answer" != '{"sso":false}' ] || die "--sso needs console.sso in $USER_NAME's system config" + [ "$answer" = '{"sso":true}' ] || die "sso config query has invalid facts" + fi if [ "$tester_socket" = 1 ]; then local query query="$(tester_capture 5 7 4096 "$USER_NAME" "$HOME_DIR" "$UNIT_PATH" "$NODE" --input-type=module -e "$TESTER_QUERY" "$COPY/packages/cli/src/config.mjs" "$COPY/packages/webui/src/cli.mjs")" || die "tester config query $(tester_capture_reason "$?")"