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.
This commit is contained in:
1 parent
1ff45c4ad6
commit
bd79daffdd
4 files changed
+119
-14
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
+24
-9
@@ -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 "$?")"
|
||||
|
||||
Reference in new issue
Block a user