// Case 2 row C2-13a (#1732): scripts/case2-unit.sh renders and installs the hub's two system units, // and scripts/case2-precheck.sh checks the host before they start. Brief: docs/plans/2026-10-20_case2-rows.md // "C2-13a". Every case runs against a scratch unit directory, a scratch home and fake systemctl, // getent, sudo, id, node, bwrap and claude. Nothing here reads or writes /etc/systemd, // ~/.mosaic-dev or ~/.config/mosaic-dev, and no fixture value is a real credential. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { createServer } from "node:net"; import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs"; import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs"; import { tmp } from "./helpers.mjs"; import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs"; import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs"; const REPO = fileURLToPath(new URL("../../../", import.meta.url)); const UNIT = join(REPO, "scripts", "case2-unit.sh"); const PRECHECK = join(REPO, "scripts", "case2-precheck.sh"); const BUS_TEMPLATE = join(REPO, "packages", "cli", "systemd", "mosaic-bus.service.in"); const WEBUI_TEMPLATE = join(REPO, "packages", "webui", "systemd", "mosaic-webui.service.in"); const COPY_A = "a".repeat(40); const COPY_B = "b".repeat(40); const UID = process.getuid(); const GID = process.getgid(); // A made-up key value; no output may hold it. const FIXTURE_KEY = "fixture-zai-key-3c9e51"; // A root-owned executable outside /usr/local/bin, /usr/bin and /bin, for the PATH case. function outsideNode() { const dir = "/usr/lib/systemd"; for (const name of readdirSync(dir).sort()) { const p = join(dir, name); const s = statSync(p, { throwIfNoEntry: false }); if (s?.isFile() && s.uid === 0 && (s.mode & 0o111) && (s.mode & 0o022) === 0 && realpathSync(p) === p) return p; } throw new Error(`no root-owned executable in ${dir}`); } const USR_NODE = realpathSync("/usr/bin/node"); const TRUE_BIN = realpathSync("/usr/bin/true"); function script(file, text) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`); chmodSync(file, 0o755); return file; } // A scratch hub: a home with two copies and a proof file, a unit directory, and fakes. function hub(t, { uid = UID } = {}) { const root = tmp(t, "case2-unit-"); const home = join(root, "home", "mosaic"); for (const id of [COPY_A, COPY_B]) { mkdirSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts"), { recursive: true }); writeFileSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts", "mosaic"), ""); } const secrets = join(home, ".config", "mosaic-dev", "secrets"); mkdirSync(secrets, { recursive: true, mode: 0o700 }); const proof = join(secrets, "proxy-proof"); writeFileSync(proof, "made-up-proof\n"); chmodSync(proof, 0o600); const units = join(root, "units"); mkdirSync(units); const bin = join(root, "bin"); const log = join(root, "systemctl.log"); script(join(bin, "getent"), `case "$1" in passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;; group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;; *) exit 2 ;; esac`); // is-active answers yes for each unit named in CASE2_TEST_ACTIVE. script(join(bin, "systemctl"), `echo "$*" >>'${log}' if [ "$1" = is-active ]; then case " \${CASE2_TEST_ACTIVE:-} " in *" $3 "*) exit 0 ;; *) exit 3 ;; esac; fi [ "$*" != "\${CASE2_TEST_FAIL:-}" ]`); const dataRoot = join(home, '.mosaic-dev'), query = join(root, 'query.json'), sudoLog = join(root, 'sudo.log'); mkdirSync(dataRoot, { recursive: true }); writeJson(query, { dataRoot }); for (const copy of [COPY_A, COPY_B]) { const src = join(home, '.local/share/mosaic-stack/copies', copy, 'packages'); mkdirSync(join(src, 'cli/src'), { recursive: true }); mkdirSync(join(src, 'webui/src'), { recursive: true }); writeFileSync(join(src, 'cli/src/config.mjs'), `import fs from 'node:fs';export const loadSystem=()=>JSON.parse(fs.readFileSync(${JSON.stringify(query)},'utf8'));`); writeFileSync(join(src, 'webui/src/cli.mjs'), '// MOSAIC_TESTER_SOCKET_V1\n'); } script(join(bin, 'sudo'), `${CASE_SUDO_SESSION} echo "$*" >>'${sudoLog}' [ "$1" = -u ] && [ "$2" = mosaic ] && [ "$3" = -H ] || exit 97 shift 3 [ "$1" = env ] && [ "$2" = -i ] && [ "$3" = 'HOME=${home}' ] || exit 98 if [ -f '${root}/query-hang' ]; then trap '' TERM (trap '' TERM; echo "$BASHPID" >>'${root}/query-pids'; exec sleep 60) & echo "$BASHPID" >>'${root}/query-pids'; wait; exit fi if [ -f '${root}/query-orphan' ]; then (trap '' TERM; here="$BASHPID"; read -r sid < <(/bin/ps -o sid= -p "$here"); printf '%s %s\\n' "$here" "$sid" >'${root}/query-owned'; exec sleep 60) /dev/null 2>&1 & echo "$!" >'${root}/query-pids' cat '${root}/query-output'; exit 0 fi if [ -f '${root}/query-output' ]; then cat '${root}/query-output'; exit "$(cat '${root}/query-status')"; fi case_sudo_run '${root}' "$@"`); return { root, home, secrets, proof, units, bin, log, dataRoot, query, sudoLog }; } const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-host", "books.example", "--auth-header", "X-Auth-User", "--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 = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) { const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)); const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve]; const r = spawnSync(unitScript, args, { encoding: "utf8", timeout: 30000, env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env }, }); return { code: r.status, out: r.stdout, err: r.stderr }; } const busFile = (h) => join(h.units, "mosaic-bus@.service"); const webuiFile = (h) => join(h.units, "mosaic-webui.service"); const read = (f) => readFileSync(f, "utf8"); const lines = (text, key) => text.split("\n").filter((l) => l.startsWith(`${key}=`)).map((l) => l.slice(key.length + 1)); const copyDir = (h, id = COPY_A) => join(h.home, ".local", "share", "mosaic-stack", "copies", id); const HARDENING = (h) => ["ProtectSystem=strict", `ReadWritePaths=${h.home}`, "ProtectHome=yes", "PrivateTmp=yes"]; test('row253 factory socket uses actual config and target-uid query without widening hardening', t=>{ const h=hub(t),custom=join(h.home,'custom');mkdirSync(custom);writeJson(h.query,{dataRoot:custom}); const r=unit(h,{profile:'factory'});assert.equal(r.code,0,r.out+r.err); assert.deepEqual(lines(read(webuiFile(h)),'ExecStart'),[`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${factoryFlags().join(' ')}`]); assert.deepEqual(read(webuiFile(h)).split('\n').filter(l=>/^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l)),HARDENING(h)); assert.match(read(h.sudoLog),new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, 'm')); assert.equal(existsSync(join(custom,'console')),false,'installer never creates the socket parent'); }); test('row253 installer refuses unsafe location and query facts before unit writes',t=>{ const cases=[ ['sibling prefix',h=>writeJson(h.query,{dataRoot:h.home+'2/data'}),/outside.*home/], ['dot',h=>writeJson(h.query,{dataRoot:h.home+'/./data'}),/normalized/], ['dotdot',h=>writeJson(h.query,{dataRoot:h.home+'/x/../data'}),/normalized/], ['double slash',h=>writeJson(h.query,{dataRoot:h.home+'//data'}),/normalized/], ['trailing slash',h=>writeJson(h.query,{dataRoot:h.home+'/data/'}),/normalized/], ['newline',h=>writeJson(h.query,{dataRoot:h.home+'/x\nP10 forged closed'}),/query/], ['non ASCII',h=>writeJson(h.query,{dataRoot:h.home+'/é'}),/query/], ['middle symlink',h=>{symlinkSync(h.dataRoot,join(h.home,'link'));writeJson(h.query,{dataRoot:join(h.home,'link','nested')});},/symlink/], ['long path',h=>writeJson(h.query,{dataRoot:join(h.home,'a'.repeat(108))}),/too.long/], ['parent file',h=>writeFileSync(join(h.dataRoot,'console'),'x',{mode:0o600}),/parent-type/], ['parent symlink',h=>symlinkSync(h.home,join(h.dataRoot,'console')),/symlink/], ['parent mode',h=>{mkdirSync(join(h.dataRoot,'console'));chmodSync(join(h.dataRoot,'console'),0o755);},/parent/], ['foreign parent',h=>{mkdirSync(join(h.dataRoot,'console'),{mode:0o700});},/parent/], ['unsupported',h=>writeFileSync(join(copyDir(h),'packages/webui/src/cli.mjs'),'// old copy'),/support/], ['schema',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true,extra:true}));writeFileSync(join(h.root,'query-status'),'0');},/query/], ['wrong type',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:'true'}));writeFileSync(join(h.root,'query-status'),'0');},/query/], ['noisy query',h=>{writeFileSync(join(h.root,'query-output'),'noise\n'+JSON.stringify({dataRoot:h.dataRoot,socket:true}));writeFileSync(join(h.root,'query-status'),'0');},/query/], ['failed valid query',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true}));writeFileSync(join(h.root,'query-status'),'1');},/query/], ]; for(const [name,change,why] of cases){ const h=hub(t,{uid:name==='foreign parent'?UID+4242:UID});change(h); const r=unit(h,{profile:'factory'});refuses(r,why); assert.equal(existsSync(busFile(h)),false,name);assert.equal(existsSync(webuiFile(h)),false,name);assert.equal(existsSync(h.log),false,name); assert.equal((r.out+r.err).includes('P10 forged closed'),false,name); } }); test('row253 query timeout kills its process group and leaves no running child',async t=>{ const h=hub(t);writeFileSync(join(h.root,'query-hang'),''); const r=unit(h,{profile:'factory'});refuses(r,/query/); const pids=read(join(h.root,'query-pids')).trim().split('\n').map(Number);assert.ok(pids.length>=2); for(const pid of pids){ let state='gone'; for(let n=0;n<50;n++){ try{state=read(`/proc/${pid}/stat`).split(') ')[1].split(' ')[0];}catch{state='gone';} if(state==='gone'||state==='Z')break; await new Promise(resolve=>setTimeout(resolve,10)); } assert.ok(state==='gone'||state==='Z',`query descendant ${pid} still ${state}`); } assert.equal(existsSync(h.log),false);assert.equal(existsSync(busFile(h)),false); }); test('row253 protected homes refuse before any filesystem access',()=>{ const source=read(UNIT), body=source.slice(source.indexOf('tester_location() {'),source.indexOf('# tester-location-end')); for(const home of ['/home/mosaic','/root/mosaic','/run/user/997','/tmp/mosaic','/var/tmp/mosaic']){ const r=spawnSync('/bin/bash',['-c',body+'\nif tester_location "$1/data" "$1" 997 ""; then exit 99; fi; printf "%s" "$TS_REASON"','--',home],{encoding:'utf8'}); assert.equal(r.status,0);assert.equal(r.stdout,'protected-private-home'); } }); // C4 cases run through both sudo process models. Exec shares the runner's // session, so its session-wide check would be vacuous; exact recorded PIDs // are checked in both modes. Hop additionally isolates the fake session. for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query has exact inner reason and cleans all members`,async t=>{ for(const mode of ['exec','hop']){ const h=hub(t),sessions=join(h.root,'session-pids'),queries=join(h.root,'query-pids'),files=[sessions,queries]; writeFileSync(join(h.root,'session-mode'),mode); const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof'); if(kind==='member')symlinkSync('/usr/bin/sleep',member); writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof)); const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh'); mkdirSync(dirname(observed),{recursive:true}); symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh')); writeFileSync(clock,'',{mode:0o600}); // The 7s outer watchdog spans startup and cleanup too. Give the private copy // cleanup room while retaining a separate band on the unchanged 5s inner timer. const source=read(UNIT),call='query="$(tester_capture 5 7 4096'; assert.equal(source.split(call).length,2,'tester query budget anchor is unique'); script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock)); const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock); const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{ CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE, CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'), }}),wall=(performance.now()-start)/1000;capture(); refuses(r,/^case2-unit: tester config query inner-timeout\n$/); const seconds=readProbeSeconds(read(clock)); const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number); assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`); assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`); assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`); t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`); assert.deepEqual(await pollFixtureGone(files),[],mode); if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed'); assert.equal(existsSync(h.log),false,mode);await stopGuard(); } }); test('row253 C4 overflow is output-limit in both sudo modes',async t=>{ for(const mode of ['exec','hop']){ const h=hub(t),files=[join(h.root,'session-pids')];writeFileSync(join(h.root,'session-mode'),mode);const capture=cleanupFixture(t,files); writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),`process.stdout.write('X'.repeat(8192));export const loadSystem=()=>({dataRoot:${JSON.stringify(h.dataRoot)}});`); const r=unit(h,{profile:'factory'});capture();refuses(r,/^case2-unit: tester config query output-limit\n$/); assert.equal(r.out,'');assert.equal(existsSync(busFile(h)),false);assert.equal(existsSync(h.log),false); assert.deepEqual(await pollFixtureGone(files),[]); } }); test('row253 C4 stalled supervisor uses the outer reason and explicit residual cleanup',async t=>{ for(const mode of ['exec','hop']){ const h=hub(t),file=join(h.root,'session-pids'),files=[file];writeFileSync(join(h.root,'session-mode'),mode); writeFileSync(join(h.root,'stall-config'),'');writeFileSync(join(h.root,'stall-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true})); const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files); const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture(); refuses(r,/^case2-unit: tester config query outer-timeout\n$/); assert.ok(seconds>=7&&seconds<9,`${mode}: ${seconds}s; 2s slack includes root preflight`); assert.equal(r.out,'');assert.equal(existsSync(busFile(h)),false);assert.equal(existsSync(h.log),false); if(mode==='hop')assert.ok(recordedLivePids(file).length>0,'R3 residual exists before fixture cleanup'); killRecordedPids(file);assert.deepEqual(await pollFixtureGone(files),[], 'explicit fixture cleanup of R3 residual');await stopGuard(); } }); test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{ const extract=s=>s.split('# row253-supervisor-begin\n')[1]?.split('# row253-supervisor-end')[0]; const a=extract(read(UNIT)),b=extract(read(join(REPO,'scripts/case1-accept.sh'))); assert.ok(a?.length>100);assert.equal(a,b); }); test('row253 C4 cleanup budget starts after inventory pass one',()=>{ // Run the shipped cleanup with a deterministic slow inventory/clock. Every // kernel operation is replaced before execution; no real PID is signalled. const source=read(UNIT); const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1]; assert.ok(program,'fixed cleanup program exists'); const anchor='\ntry:\n'; assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique'); const standins=` from types import SimpleNamespace alive = True kills = 0 clock = time.monotonic() def inventory(path): global clock clock += 1000 return ["999"] def fake_fields(pid): return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None) def send(fd, sig): global alive, kills if sig == 9: alive = False kills += 1 fields = fake_fields os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None) signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send) time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None) `; const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'}); assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1'); }); test('row253 query reaps its group even when a child closes stdout before the query returns',async t=>{ const h=hub(t);writeFileSync(join(h.root,'query-orphan'),''); writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true})); let pid; const capture=cleanupFixture(t,[join(h.root,'query-owned')]); const r=unit(h,{profile:'factory'});capture(); pid=Number(read(join(h.root,'query-pids')).trim()); assert.equal(r.code,0,r.out+r.err); let state='gone'; for(let n=0;n<50;n++){ try{state=read(`/proc/${pid}/stat`).split(') ')[1].split(' ')[0];}catch{state='gone';} if(state==='gone'||state==='Z')break; await new Promise(resolve=>setTimeout(resolve,10)); } assert.ok(state==='gone'||state==='Z',`query descendant ${pid} still ${state}`); }); test('row253 config parser is strict',()=>{ const source=read(UNIT), body=source.slice(source.indexOf('tester_facts() {'),source.indexOf('tester_location() {')); const valid=spawnSync('/bin/bash',['-c',body+'\ntester_facts "$1" && printf "%s %s" "$TS_DATA" "$TS_MARKED"','--','{"dataRoot":"/srv/mosaic/data","socket":true}'],{encoding:'utf8'}); assert.equal(valid.status,0);assert.equal(valid.stdout,'/srv/mosaic/data true'); const facts=['{"dataRoot":"/srv/mosaic/data","socket":true,"extra":1}','{"dataRoot":4,"socket":true}','{"dataRoot":"/srv/mosaic/data","socket":"true"}','{"dataRoot":"/srv/mosaic/data","socket":true}\nP10 forged closed']; for(const fact of facts){ const r=spawnSync('/bin/bash',['-c',body+'\ntester_facts "$1"','--',fact]); assert.notEqual(r.status,0,fact); } }); function refuses(r, pattern) { assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`); assert.match(r.err, pattern); } // A template copy with one edit, for the template cases. function template(h, from, name, edit) { const file = join(h.root, name); writeFileSync(file, edit(read(from))); return file; } 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); for (const f of [busFile(h), webuiFile(h)]) assert.equal(statSync(f).mode & 0o777, 0o644); const bus = read(busFile(h)); const webui = read(webuiFile(h)); assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin"]); 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"]); assert.deepEqual(lines(text, "User"), ["mosaic"]); assert.deepEqual(lines(text, "Group"), ["mosaic"]); assert.deepEqual(lines(text, "NoNewPrivileges"), ["yes"]); const hardening = text.split("\n").filter((l) => /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l)); assert.deepEqual(hardening, HARDENING(h)); assert.doesNotMatch(text, /@[A-Z_]+@|%h|MOSAIC_CONFIG=|MOSAIC_CLAUDE_BIN|After=|Wants=/); } assert.deepEqual(lines(bus, "AmbientCapabilities"), []); assert.deepEqual(lines(bus, "CapabilityBoundingSet"), []); assert.deepEqual(lines(webui, "AmbientCapabilities"), ["CAP_NET_BIND_SERVICE"]); assert.deepEqual(lines(webui, "CapabilityBoundingSet"), ["CAP_NET_BIND_SERVICE"]); assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/); assert.deepEqual(read(h.log).trim().split("\n"), [ "is-active --quiet mosaic-bus@acme.service", "is-active --quiet mosaic-webui.service", "daemon-reload", "enable mosaic-bus@acme.service mosaic-webui.service", "start mosaic-bus@acme.service", "start mosaic-webui.service", ]); }); // The selector (rename design r2): --org and --business are one option in each of two scopes, the // script's own flags before -- and the serve flags after it. Nothing is counted across --; the serve // selector is required, must be the unit's business, and renders as --business. const SELECTORS = [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]]; test("the unit takes --org or --business on either side of --, renders --business once, and re-installs as the same units", (t) => { const h = hub(t); assert.equal(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme"] }).code, 0); const before = [read(busFile(h)), read(webuiFile(h))]; assert.deepEqual(lines(before[1], "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme`]); for (const outer of SELECTORS) { for (const child of SELECTORS) { const r = unit(h, { profile: "factory", outer, flags: ["--port", "443", ...child] }); assert.equal(r.code, 0, `${outer.join(" ")} -- ${child.join(" ")}: ${r.err}`); assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before, `${outer.join(" ")} -- ${child.join(" ")}`); } } // The selector keeps its place among the serve flags. const g = hub(t); assert.equal(unit(g, { profile: "factory", outer: ["--org", "acme"], flags: ["--org", "acme", "--port", "443", "--business", "acme"] }).code, 0); assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --business acme --port 443`]); }); test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => { const h = hub(t); const serve = ["--port", "443", "--business", "acme"]; const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n"; const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"]; for (const [label, argv, code, err] of [ ["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"], ["outer unequal, new first", [...head, "--org", "acme", "--business", "beta", "--", ...serve], 4, "case2-unit: --org acme and --business beta conflict\n"], ["outer old repeated", [...head, "--business", "acme", "--business", "acme", "--", ...serve], 4, "case2-unit: --business given twice\n"], ["outer new repeated", [...head, "--org", "acme", "--org", "acme", "--", ...serve], 4, "case2-unit: --org given twice\n"], ["outer missing", [...head, "--org"], 4, usage], ["outer empty", [...head, "--org", "", "--", ...serve], 4, usage], ["outer none", [...head, "--", ...serve], 4, usage], ["outer invalid", [...head, "--org", "Acme", "--", ...serve], 1, "case2-unit: --business must be a business id: Acme\n"], ["outer flag-shaped", [...head, "--business", "-x", "--", ...serve], 1, "case2-unit: --business must be a business id: -x\n"], ["outer takes no separator", [...head, "--org", "--", ...serve], 1, "case2-unit: --business must be a business id: --\n"], ["serve unequal, old first", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--org", "beta"], 1, "case2-unit: serve --business acme and --org beta conflict\n"], ["serve unequal, new first", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--business", "beta"], 1, "case2-unit: serve --org acme and --business beta conflict\n"], ["serve old repeated", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--business", "acme"], 1, "case2-unit: serve flag --business given twice\n"], ["serve new repeated", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--org", "acme"], 1, "case2-unit: serve flag --org given twice\n"], ["serve missing", [...head, "--business", "acme", "--", "--port", "443", "--org"], 1, "case2-unit: serve flag --org needs a value\n"], ["serve empty", [...head, "--business", "acme", "--", "--port", "443", "--org", ""], 1, "case2-unit: serve flag --org needs a value\n"], ["serve none", [...head, "--org", "acme", "--", "--port", "443"], 1, "case2-unit: serve flag --business is required\n"], ["serve invalid", [...head, "--business", "acme", "--", "--port", "443", "--org", "Acme"], 1, "case2-unit: serve --org Acme is not the unit's business acme\n"], ["serve flag-shaped", [...head, "--business", "acme", "--", "--port", "443", "--org", "--tester-socket"], 1, "case2-unit: serve --org --tester-socket is not the unit's business acme\n"], ["outer and serve differ", [...head, "--org", "acme", "--", "--port", "443", "--org", "beta"], 1, "case2-unit: serve --org beta is not the unit's business acme\n"], ["serve and outer differ", [...head, "--org", "beta", "--", "--port", "443", "--business", "acme"], 1, "case2-unit: serve --business acme is not the unit's business beta\n"], ["a selector as --copy's value", ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", "--org", "--node", USR_NODE, "--profile", "factory", "--", ...serve], 1, "case2-unit: --copy must be a full 40-character lower-case commit id: --org\n"], ]) { const r = unit(h, { argv }); assert.equal(r.code, code, `${label}: ${r.err}`); assert.equal(r.err, err, label); assert.equal(r.out, "", label); } // A selector spelling taken as a library serve flag's value is that flag's text. const library = libraryFlags(h).map((f) => (f === "books.example" ? "--org" : f)); refuses(unit(h, { flags: library }), /^case2-unit: public host must be a hostname, no port or scheme: --org\n$/); assert.equal(existsSync(busFile(h)), false); assert.equal(existsSync(webuiFile(h)), false); assert.equal(existsSync(h.log), false); }); 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(); // This metadata-only test uses a root-owned executable as a path stand-in. writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0'); assert.equal(unit(h, { node }).code, 0); for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]); assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `)); }); test("a failed bus start refuses with the Console not started", (t) => { const h = hub(t); const r = unit(h, { env: { CASE2_TEST_FAIL: "start mosaic-bus@acme.service" } }); refuses(r, /start mosaic-bus@acme\.service failed; the Console is not started/); assert.deepEqual(read(h.log).trim().split("\n").slice(2), ["daemon-reload", "enable mosaic-bus@acme.service mosaic-webui.service", "start mosaic-bus@acme.service"]); }); test("the script never links a unit file", () => { assert.doesNotMatch(read(UNIT), /systemctl link/); }); test("factory renders --pm once and one MOSAIC_CLAUDE_BIN line, and without them both tokens render empty", (t) => { const h = hub(t); assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0); const bus = read(busFile(h)); const exec = lines(bus, "ExecStart"); assert.equal(exec.length, 1); assert.match(exec[0], / bus start %i --pm$/); assert.equal(exec[0].split("--pm").length, 2); assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin", `MOSAIC_CLAUDE_BIN=${TRUE_BIN}`]); assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${factoryFlags().join(" ")}`]); const plain = hub(t); assert.equal(unit(plain, { profile: "factory" }).code, 0); const text = read(busFile(plain)); assert.deepEqual(lines(text, "ExecStart"), [`${copyDir(plain)}/scripts/mosaic bus start %i`]); assert.doesNotMatch(text, /MOSAIC_CLAUDE_BIN|@PM_FLAGS@|@CLAUDE_ENV@/); }); test("a re-install over running units says they are still on the old copy and must restart together, never that it started them", (t) => { const h = hub(t); assert.equal(unit(h).code, 0); const both = unit(h, { copy: COPY_B, env: { CASE2_TEST_ACTIVE: "mosaic-bus@acme.service mosaic-webui.service" } }); assert.equal(both.code, 0, both.err); assert.doesNotMatch(both.out, /started/); assert.match(both.out, new RegExp(`mosaic-bus@acme\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`)); assert.match(both.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`)); assert.match(both.out, new RegExp(`restart mosaic-bus@acme\\.service and mosaic-webui\\.service together to put both on copy ${COPY_B}`)); assert.deepEqual(read(h.log).trim().split("\n").slice(-2), ["start mosaic-bus@acme.service", "start mosaic-webui.service"]); // Only the Console ran (the bus had stopped): the bus starts on the new copy, the Console stays. const one = unit(h, { copy: COPY_A, env: { CASE2_TEST_ACTIVE: "mosaic-webui.service" } }); assert.equal(one.code, 0, one.err); assert.match(one.out, new RegExp(`started mosaic-bus@acme\\.service on copy ${COPY_A}`)); assert.match(one.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_B}, not copy ${COPY_A}`)); assert.doesNotMatch(one.out, /started mosaic-webui/); assert.match(one.out, /restart mosaic-bus@acme\.service and mosaic-webui\.service together/); }); test("a re-install to another copy re-renders both units, and an edited installed unit refuses", (t) => { const h = hub(t); assert.equal(unit(h).code, 0); assert.equal(unit(h, { copy: COPY_B }).code, 0); assert.match(read(busFile(h)), new RegExp(`copies/${COPY_B}/scripts/mosaic`)); assert.doesNotMatch(read(busFile(h)) + read(webuiFile(h)), new RegExp(COPY_A)); const edits = [ [busFile, (s) => s.replace("User=mosaic", "User=root"), /User= must be exactly once in \[Service\], as User=mosaic/], [webuiFile, (s) => s.replace(/^ExecStart=(.*)$/m, "ExecStart=$1 --board http://127.0.0.1:1"), /ExecStart= must be exactly once/], [busFile, (s) => s.replace("ProtectHome=yes\n", ""), /ProtectHome= must be exactly once/], [webuiFile, (s) => s.replace("PrivateTmp=yes\n", ""), /PrivateTmp= must be exactly once/], [busFile, (s) => s.replace("Type=simple", "Type=simple\nX-Note=%h"), /%h is left/], ]; for (const [file, edit, pattern] of edits) { const before = read(file(h)); writeFileSync(file(h), edit(before)); refuses(unit(h, { copy: COPY_B }), pattern); writeFileSync(file(h), before); } assert.equal(unit(h, { copy: COPY_B }).code, 0); }); test("a template with a namespace, user or system call restriction refuses", (t) => { const h = hub(t); for (const line of ["RestrictNamespaces=yes", "PrivateUsers=yes", "SystemCallFilter=@system-service"]) { const key = line.split("=")[0]; for (const [from, env] of [[BUS_TEMPLATE, "CASE2_BUS_TEMPLATE"], [WEBUI_TEMPLATE, "CASE2_WEBUI_TEMPLATE"]]) { const file = template(h, from, `t-${key}.in`, (s) => s.replace("NoNewPrivileges=yes", `NoNewPrivileges=yes\n${line}`)); refuses(unit(h, { env: { [env]: file } }), new RegExp(`refusing ${key}=`)); } } assert.equal(existsSync(busFile(h)), false); }); test("the bus template's EnvironmentFile line must be there and point at the session env, and the Console template must have none", (t) => { const h = hub(t); const gone = template(h, BUS_TEMPLATE, "no-env.in", (s) => s.replace(/^EnvironmentFile=.*\n/m, "")); refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: gone } }), /EnvironmentFile= must be exactly once/); const elsewhere = template(h, BUS_TEMPLATE, "env-elsewhere.in", (s) => s.replace("EnvironmentFile=-@SESSION_ENV@", "EnvironmentFile=-/etc/other.env")); refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: elsewhere } }), /EnvironmentFile= must be exactly once/); const added = template(h, WEBUI_TEMPLATE, "webui-env.in", (s) => s.replace("NoNewPrivileges=yes", "NoNewPrivileges=yes\nEnvironmentFile=-/etc/other.env")); refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: added } }), /refusing EnvironmentFile=/); }); test("a template holding an unknown token, PermissionsStartOnly or a privileged Exec prefix refuses", (t) => { const h = hub(t); const token = template(h, BUS_TEMPLATE, "token.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@")); refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: token } }), /a token is left after the render: @SECRET@/); const webuiToken = template(h, WEBUI_TEMPLATE, "wtoken.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@")); refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: webuiToken } }), /a token other than one @FLAGS@/); const pso = template(h, BUS_TEMPLATE, "pso.in", (s) => s.replace("Type=simple", "Type=simple\nPermissionsStartOnly=yes")); refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: pso } }), /refusing PermissionsStartOnly=/); for (const pre of ["+/srv/mosaic/x", "-+/srv/mosaic/x"]) { const file = template(h, BUS_TEMPLATE, "pre.in", (s) => s.replace("Type=simple", `Type=simple\nExecStartPre=${pre}`)); refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: file } }), /an Exec line that runs with full privileges: ExecStartPre=/); } }); test("a symlink at either unit path refuses", (t) => { for (const name of ["mosaic-bus@.service", "mosaic-webui.service"]) { const h = hub(t); const target = join(h.root, "elsewhere"); writeFileSync(target, ""); symlinkSync(target, join(h.units, name)); refuses(unit(h), new RegExp(`${name.replace(".", "\\.")} is a symlink`)); } }); test("a --node that is relative, not root-owned or under refuses", (t) => { const h = hub(t); refuses(unit(h, { node: "bin/node" }), /--node must be an absolute path/); const mine = script(join(h.root, "opt", "node"), "exit 0"); refuses(unit(h, { node: mine }), /--node: .* is not owned by root/); const inside = script(join(h.home, "bin", "node"), "exit 0"); refuses(unit(h, { node: inside }), /--node must be outside/); }); test("a re-install with a different --node or different serve flags refuses", (t) => { const h = hub(t); assert.equal(unit(h).code, 0); writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0'); // A new node moves PATH too, so the installed unit fails its own Environment= check first. refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/); const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f)); refuses(unit(h, { flags }), /installed .*: ExecStart= must be exactly once/); // A line check_unit doesn't fix still has to equal the render. writeFileSync(busFile(h), read(busFile(h)).replace(/^Description=.*$/m, "Description=edited")); refuses(unit(h), /differs from the render in more than the copy id/); }); test("an installed pair whose units name different copies refuses", (t) => { const h = hub(t); assert.equal(unit(h).code, 0); writeFileSync(webuiFile(h), read(webuiFile(h)).replaceAll(COPY_A, COPY_B)); refuses(unit(h), new RegExp(`the installed units name different copies, ${COPY_A} and ${COPY_B}`)); }); test("a copy id that is short, uppercase, holds / or .., or names no directory refuses", (t) => { const h = hub(t); for (const copy of ["a".repeat(39), "A".repeat(40), `${"a".repeat(19)}/${"a".repeat(20)}`, `..${"a".repeat(38)}`]) { refuses(unit(h, { copy }), /--copy must be a full 40-character lower-case commit id/); } refuses(unit(h, { copy: "c".repeat(40) }), /no copy directory/); }); test("library refuses a privileged-floor miss, a bad proof pair and every flag outside its set", (t) => { const h = hub(t); const base = libraryFlags(h); const without = (...names) => { const out = []; for (let i = 0; i < base.length; i++) { if (names.includes(base[i])) { i++; continue; } out.push(base[i]); } return out; }; refuses(unit(h, { flags: base.map((f) => (f === "443" ? "7332" : f)) }), /--port must be 1\.\.1023/); refuses(unit(h, { flags: without("--proof-header", "--proof-file") }), /serve flag --proof-header is required/); refuses(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f)) }), /must be an Authentik uid/); assert.equal(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f === "melanie" ? "0".repeat(64) : f)) }).code, 0); rmSync(busFile(h)); rmSync(webuiFile(h)); for (const [flag, value, pattern] of [ ["--tester-port", "7333", /case2-unit.sh retires tester TCP; use --tester-socket/], ["--tester-socket", null, /the library profile refuses --tester-socket/], ["--checkout", "/srv/x", /refusing --checkout/], ["--board", "http://127.0.0.1:7331", /refusing --board/], ["--sso", null, /refusing --sso/], ["--nonsense", "1", /unknown serve flag: --nonsense/], ]) { refuses(unit(h, { flags: [...base, flag, ...(value === null ? [] : [value])] }), pattern); } refuses(unit(h, { flags: base.map((f) => (f === "acme" ? "other" : f)) }), /serve --business other is not the unit's business acme/); refuses(unit(h, { extra: ["--pm"] }), /the library profile refuses --pm/); refuses(unit(h, { extra: ["--claude", TRUE_BIN] }), /the library profile refuses --claude/); chmodSync(h.proof, 0o644); refuses(unit(h), /mode 0600 or 0400, not 644/); chmodSync(h.proof, 0o600); const link = join(h.secrets, "proof-link"); symlinkSync(h.proof, link); refuses(unit(h, { flags: base.map((f) => (f === h.proof ? link : f)) }), /must not be a symlink/); const dataRoot = join(h.home, ".mosaic-dev"); mkdirSync(dataRoot, { recursive: true }); const outside = join(dataRoot, "proof"); writeFileSync(outside, "made-up-proof\n"); chmodSync(outside, 0o600); refuses(unit(h, { flags: base.map((f) => (f === h.proof ? outside : f)) }), /must be under .*secrets, outside the data root/); assert.equal(existsSync(busFile(h)), false); }); // A non-root test can't make a root-owned file under the scratch home, so the // owner case names another uid in the passwd database instead. test("library refuses a proof file not owned by 's uid", (t) => { const h = hub(t, { uid: UID + 4242 }); refuses(unit(h), new RegExp(`proxy proof file must be owned by mosaic \\(uid ${UID + 4242}\\)`)); }); test("factory refuses duplicate socket, a missing --port or --business, and each proxy flag", (t) => { const h = hub(t); refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), '--tester-socket'] }), /given twice/); 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]]) { 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/], ["--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); }); // M3 (#1873; Jason's ruling 2026-11-10): only an unstamped library copy keeps proxy preview. The // stamped copies below sign in through SSO accounts. test("#416 an unstamped library copy 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); }); // ---- M3 (#1873): stamped copies ---- // A stamped copy (stack-copy.sh's read-only .stack-build.json) serves only with --sso, console.sso, // an accounts.json that loads and a .deployed naming the default instance. The render asks the // stack user's copy through the bounded sudo query, with the copy's real deployment.mjs and // organization accounts.mjs, and refuses before any write, naming every missing step in order. // No real host, config or credential: the issuer is on .invalid and nothing connects to it. const ISSUER = "https://idp.invalid/a"; const STAMP_ACCOUNTS = { accountsVersion: 1, revision: 1, users: [{ id: "u-000000000001", subjects: [{ provider: "a", sub: "owner" }], name: "Owner", instanceRole: "operator", state: "active", authGen: 1, created: "2026-11-10T00:00:00Z" }], memberships: [], invites: [] }; const accountsFile = (h) => join(h.home, ".config", "mosaic-dev", "accounts.json"); const deployedFile = (h) => join(h.dataRoot, ".deployed"); function stamp(h, id = COPY_A) { const dir = copyDir(h, id); cpSync(join(REPO, "packages/cli/src/deployment.mjs"), join(dir, "packages/cli/src/deployment.mjs")); for (const pkg of ["organization", "seat"]) symlinkSync(join(REPO, "packages", pkg), join(dir, "packages", pkg)); writeFileSync(join(dir, ".stack-build.json"), JSON.stringify({ commit: id }) + "\n"); chmodSync(join(dir, ".stack-build.json"), 0o444); } const stampSso = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { publicOrigin: "https://console.invalid", providers: [{ id: "a", issuer: ISSUER }] } } }); function stampAccounts(h, doc = STAMP_ACCOUNTS) { writeFileSync(accountsFile(h), JSON.stringify(doc)); chmodSync(accountsFile(h), 0o600); } function stampDeployed(h, instance = "dev") { writeFileSync(deployedFile(h), JSON.stringify({ instance }) + "\n"); chmodSync(deployedFile(h), 0o444); } function stamped(t, { sso = true, accounts = true, deployed = true } = {}) { const h = hub(t); stamp(h); if (sso) stampSso(h); if (accounts) stampAccounts(h); if (deployed) stampDeployed(h); return h; } const STAMP_LIBRARY = ["--port", "443", "--business", "acme", "--sso"]; const STAMP_FACTORY = ["--port", "443", "--business", "acme", "--tester-socket", "--sso", "--send"]; const STEP = { deployed: (h) => `check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes ${h.dataRoot}/.deployed`, deployedBad: (h) => `check .deployed: ${h.dataRoot}/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it`, sso: (h) => `prepare SSO: console.sso in mosaic's system config ${h.home}/.config/mosaic-dev/config.json, with its client secret slots`, accounts: (h) => `run \`mosaic accounts init\` as mosaic, a human verb: no ${accountsFile(h)}`, accountsBad: (h) => `fix ${accountsFile(h)}: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs`, library: () => "add --sso to the serve flags, then re-pin", factory: () => "add --sso --send to the serve flags, then re-pin", }; // The exact refusal: one header line, then the steps numbered in the run sheet's order. function refusesSteps(h, r, steps) { assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`); const want = [`case2-unit: refusing the units for ${COPY_A}: a stamped copy's Console starts only with SSO accounts; missing steps, in order:`, ...steps.map((s, i) => ` ${i + 1}. ${s(h)}`)].join("\n") + "\n"; assert.equal(r.err, want); } // Nothing the render must not write: no unit, no systemctl, and none of the prerequisites. function wroteNothing(h, { accounts = false, deployed = false } = {}) { noUnits(h); assert.equal(existsSync(accountsFile(h)), accounts, "the render never writes accounts.json"); assert.equal(existsSync(deployedFile(h)), deployed, "the render never writes .deployed"); } test("M3 a stamped library unit renders only with --sso and the prerequisites, and runs the Console without proxy preview", (t) => { const h = stamped(t); const r = unit(h, { flags: STAMP_LIBRARY }); 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 --sso`]); assert.doesNotMatch(webui.split("\n").filter((l) => !l.startsWith("#")).join("\n"), /--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send/); assert.deepEqual(lines(read(busFile(h)), "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]); // The prerequisites were asked as the stack user, through the copy's own modules. const sudo = read(h.sudoLog); assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m")); assert.match(sudo, /a\.loadAccounts\(.*d\.assertDeployed\(s,dir\)/); assert.ok(sudo.includes(`${copyDir(h)}/packages/organization/src/accounts.mjs`)); assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/); // The render read them and left each as it was. assert.deepEqual(JSON.parse(read(accountsFile(h))), STAMP_ACCOUNTS); assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444); }); test("M3 a stamped library unit refuses proxy-preview admission, its proof and --send, before any write", (t) => { const h = stamped(t); for (const [flag, value] of [["--public-host", "books.example"], ["--auth-header", "X-Auth-User"], ["--allow-user", "melanie"], ["--proof-header", "X-Proxy-Proof"], ["--proof-file", h.proof]]) { const r = unit(h, { flags: [...STAMP_LIBRARY, flag, value] }); assert.equal(r.code, 1, flag); assert.equal(r.err, `case2-unit: refusing ${flag}: ${COPY_A} is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso\n`); } // The whole old preview set refuses at its first flag, with or without --sso. refuses(unit(h, { flags: libraryFlags(h) }), /refusing --public-host: .* is a stamped copy, and proxy-preview admission and its proof do not apply/); refuses(unit(h, { flags: [...STAMP_LIBRARY, "--send"] }), /the library profile is read-only; refusing --send \(a stamped library Console signs in with --sso and stays read-only\)/); wroteNothing(h, { accounts: true, deployed: true }); assert.equal(existsSync(h.sudoLog), false, "a flag refusal comes before any query"); }); test("M3 a stamped library unit without its prerequisites refuses before any write and names every missing step in order", (t) => { const none = stamped(t, { sso: false, accounts: false, deployed: false }); refusesSteps(none, unit(none, { flags: ["--port", "443", "--business", "acme"] }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.library]); wroteNothing(none); refusesSteps(none, unit(none, { flags: STAMP_LIBRARY }), [STEP.deployed, STEP.sso, STEP.accounts]); wroteNothing(none); // Each prerequisite alone. for (const [name, opts, steps] of [ ["no .deployed", { deployed: false }, [STEP.deployed]], ["no console.sso", { sso: false }, [STEP.sso]], ["no accounts.json", { accounts: false }, [STEP.accounts]], ]) { const h = stamped(t, opts); refusesSteps(h, unit(h, { flags: STAMP_LIBRARY }), steps); wroteNothing(h, { accounts: opts.accounts !== false, deployed: opts.deployed !== false }); } const flagless = stamped(t); refusesSteps(flagless, unit(flagless, { flags: ["--port", "443", "--business", "acme"] }), [STEP.library]); wroteNothing(flagless, { accounts: true, deployed: true }); }); test("M3 a stamped copy refuses an invalid .deployed or accounts.json with the step that names it", (t) => { const cases = [ [".deployed names another instance", (h) => { rmSync(deployedFile(h)); stampDeployed(h, "other"); }, [STEP.deployedBad]], [".deployed writable", (h) => chmodSync(deployedFile(h), 0o644), [STEP.deployedBad]], [".deployed broken link", (h) => { rmSync(deployedFile(h)); symlinkSync(join(h.root, "missing"), deployedFile(h)); }, [STEP.deployedBad]], ["accounts without an active operator", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], state: "disabled" }] }), [STEP.accountsBad]], ["accounts subject on an unconfigured provider", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], subjects: [{ provider: "b", sub: "owner" }] }] }), [STEP.accountsBad]], ["accounts writable by group", (h) => chmodSync(accountsFile(h), 0o620), [STEP.accountsBad]], ["accounts broken link", (h) => { rmSync(accountsFile(h)); symlinkSync(join(h.root, "missing"), accountsFile(h)); }, [STEP.accountsBad]], ]; for (const [name, change, steps] of cases) { for (const [profile, flags] of [["library", STAMP_LIBRARY], ["factory", STAMP_FACTORY]]) { const h = stamped(t); change(h); refusesSteps(h, unit(h, { profile, flags }), steps); noUnits(h); } } }); test("M3 a stamped factory or Case 1 unit renders only with --sso --send and the prerequisites, and names each missing step", (t) => { const h = stamped(t); const r = unit(h, { profile: "factory", flags: STAMP_FACTORY }); assert.equal(r.code, 0, r.out + r.err); assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${STAMP_FACTORY.join(" ")}`]); const none = stamped(t, { sso: false, accounts: false, deployed: false }); refusesSteps(none, unit(none, { profile: "factory" }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.factory]); wroteNothing(none); refusesSteps(none, unit(none, { profile: "factory", flags: STAMP_FACTORY }), [STEP.deployed, STEP.sso, STEP.accounts]); wroteNothing(none); // A stamped copy without the flag pair names it as the last step, with the prerequisites present. const flagless = stamped(t); refusesSteps(flagless, unit(flagless, { profile: "factory" }), [STEP.factory]); refusesSteps(flagless, unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--send"] }), [STEP.factory]); wroteNothing(flagless, { accounts: true, deployed: true }); // The pair stays a pair: --sso alone still refuses, as for an unstamped copy. refuses(unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/); noUnits(flagless); }); test("M3 a stamped copy's query refuses on noise or a failed query, before any write", (t) => { // The copy's accounts module stands in for the stack user's code: noise on stdout, a failed exit. for (const [body, pattern] of [ ['console.log("forged");export const loadAccounts=()=>{};', /stamped copy query has invalid facts/], ["process.exit(1);export const loadAccounts=()=>{};", /stamped copy query inner-command/], ]) { const h = stamped(t); const org = join(copyDir(h), "packages/organization"); rmSync(org); mkdirSync(join(org, "src"), { recursive: true }); writeFileSync(join(org, "src/accounts.mjs"), body); refuses(unit(h, { flags: STAMP_LIBRARY }), pattern); noUnits(h); } }); // #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's // checks, the installed units' included, and writes nothing; --check-new skips only the installed // units' checks, for --next-args, which moves them aside before its render. const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) => [mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--", ...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))]; const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null)); const logNow = (h) => (existsSync(h.log) ? read(h.log) : ""); test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => { const h = hub(t); const empty = unit(h, { argv: checkArgv(h) }); assert.equal(empty.code, 0, empty.out + empty.err); assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`); noUnits(h); assert.equal(unit(h).code, 0); const units = unitsNow(h), log = logNow(h); for (const copy of [COPY_A, COPY_B]) { const r = unit(h, { argv: checkArgv(h, { copy }) }); assert.equal(r.code, 0, r.out + r.err); assert.deepEqual(unitsNow(h), units, copy); assert.equal(logNow(h), log, `${copy}: no systemctl call`); } // The installed-unit match stays: an edit, or other serve flags, refuses as the render would. writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited")); refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/); writeFileSync(busFile(h), units[0]); const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f)); refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/); assert.deepEqual(unitsNow(h), units); assert.equal(logNow(h), log); }); for (const profile of ["library", "factory"]) { test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => { const h = hub(t); assert.equal(unit(h, { profile }).code, 0); stamp(h, COPY_B); stampSso(h); stampAccounts(h); stampDeployed(h); const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY; const units = unitsNow(h), log = logNow(h); refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/); refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/); const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }); assert.equal(r.code, 0, r.out + r.err); assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`); // --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse. refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/); rmSync(accountsFile(h)); refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/); assert.deepEqual(unitsNow(h), units); assert.equal(logNow(h), log, "no systemctl call"); assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json"); assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444); }); } 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/); refuses(unit(h, { profile: 'factory', flags: [...factoryFlags(), '7333'] }), /unknown serve flag: 7333/); }); test("factory refuses a --claude that is relative, not root-owned or under , and a re-install with another --pm or --claude", (t) => { const h = hub(t); refuses(unit(h, { profile: "factory", extra: ["--claude", "bin/claude"] }), /--claude must be an absolute path/); const mine = script(join(h.root, "opt", "claude"), "exit 0"); refuses(unit(h, { profile: "factory", extra: ["--claude", mine] }), /--claude: .* is not owned by root/); const inside = script(join(h.home, "bin", "claude"), "exit 0"); refuses(unit(h, { profile: "factory", extra: ["--claude", inside] }), /--claude must be outside/); assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0); refuses(unit(h, { profile: "factory", extra: ["--claude", TRUE_BIN] }), /--pm, --claude and --proxy must equal the installed ones|ExecStart= must be exactly once/); refuses(unit(h, { profile: "factory", extra: ["--pm"] }), /Environment= lines must be exactly|differs from the render/); refuses(unit(h, { profile: "factory", extra: ["--pm", "--claude", USR_NODE] }), /Environment= lines must be exactly|differs from the render/); }); // ---- case2-precheck.sh ---- // A scratch user for the precheck: a config, a business file, an installed Console unit, // fakes on PATH and a fake /proc/sys. function host(t, { roles = {}, vars = {}, keyFile = `ZAI_API_KEY=${FIXTURE_KEY}\n`, port = "443", busClaude = null } = {}) { const root = tmp(t, "case2-pre-"); const home = join(root, "home"); const configDir = join(home, ".config", "mosaic-dev"); const config = writeJson(join(configDir, "config.json"), { configVersion: 1, environment: "production", dataRoot: join(home, ".mosaic-dev"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "pi" }, }); const doc = businessDoc(root); doc.roles.cto.vars = { harness: "pi" }; for (const [name, v] of Object.entries(roles)) doc.roles[name].vars = v; Object.assign(doc.vars, vars); writeJson(join(configDir, "businesses", "acme.json"), doc); const secrets = join(configDir, "secrets"); mkdirSync(secrets, { recursive: true, mode: 0o700 }); if (keyFile !== null) { writeFileSync(join(secrets, "claude-session.env"), keyFile); chmodSync(join(secrets, "claude-session.env"), 0o600); } const units = join(root, "units"); mkdirSync(units); writeFileSync(join(units, "mosaic-webui.service"), `[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port ${port} --business acme\n`); writeFileSync(join(units, "mosaic-bus@.service"), `[Service]\nEnvironment=PATH=/usr/bin\n${busClaude ? `Environment=MOSAIC_CLAUDE_BIN=${busClaude}\n` : ""}`); const sys = join(root, "sys"); mkdirSync(join(sys, "net", "ipv4"), { recursive: true }); mkdirSync(join(sys, "kernel"), { recursive: true }); writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n"); writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n"); const bin = join(root, "bin"); const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim(); script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`); script(join(bin, "sudo"), "exit 1"); script(join(bin, "id"), `if [ "$1" = -G ]; then echo "\${CASE2_TEST_GROUPS:-${GID}}"; else exec /usr/bin/id "$@"; fi`); script(join(bin, "node"), `if [ "$1" = --version ]; then echo "\${CASE2_TEST_NODE:-v24.3.0}"; exit 0; fi\nexec '${process.execPath}' "$@"`); script(join(bin, "bwrap"), `exit "\${CASE2_TEST_BWRAP:-0}"`); const notify = join(home, ".mosaic-dev", "notify", "acme"); mkdirSync(notify, { recursive: true, mode: 0o700 }); writeFileSync(join(notify, "notify.json"), '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 }); return { root, home, configDir, config, secrets, units, sys, bin, dataRoot: join(home, ".mosaic-dev") }; } // A fake claude: replace answers with the marker and a small prompt; append answers with a large one; // nomarker answers with a small prompt and no marker; text answers with plain text, not JSON. function fakeClaude(dir, kind) { return script(join(dir, "claude"), `[ "$1" = --version ] && { echo "9.9.9 (Claude Code, ${kind} fake)"; exit 0; } file='' while [ $# -gt 0 ]; do [ "$1" = --system-prompt-file ] && file="$2"; shift; done marker="$(awk '{ print $NF }' "$file")" ${kind === "nomarker" ? "marker=something-else" : ""} ${kind === "text" ? 'echo "$marker"; exit 0' : ""} tokens=${kind === "append" ? 9000 : 40} printf '{"result":"%s","usage":{"input_tokens":%s,"cache_read_input_tokens":0}}\\n' "$marker" "$tokens"`); } // This host may have a claude in /usr/bin, so this PATH links every /usr/bin entry but that one. function pathWithoutClaude(x) { const usr = join(x.root, "usr-bin"); mkdirSync(usr); for (const name of readdirSync("/usr/bin")) if (name !== "claude") symlinkSync(join("/usr/bin", name), join(usr, name)); return `${x.bin}:${usr}`; } function precheck(x, { profile = "library", env = {}, path, args = ["--business", "acme", "--profile", profile] } = {}) { const r = spawnSync(PRECHECK, args, { encoding: "utf8", timeout: 60000, env: { PATH: path ?? `${x.bin}:/usr/bin:/bin`, HOME: x.home, MOSAIC_CONFIG: x.config, CASE2_UNIT_DIR: x.units, CASE2_PROC_SYS: x.sys, CASE2_GETENT: join(x.bin, "getent"), ...env, }, }); const out = r.stdout + r.stderr; assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the precheck printed the fixture key"); return { code: r.status, out }; } function blocked(r, pattern) { assert.equal(r.code, 1, r.out); assert.match(r.out, pattern); } test("the precheck passes on a good host and prints free memory, without the key", (t) => { const x = host(t); const r = precheck(x); assert.equal(r.code, 0, r.out); assert.match(r.out, /ok {6}config validates/); assert.match(r.out, /ok {6}bus\.tester is off/); assert.match(r.out, /ok {6}net\.ipv4\.ip_unprivileged_port_start 1024 is above the Console's port 443/); assert.match(r.out, /claude-session\.env sets ZAI_API_KEY/); assert.match(r.out, /free memory: \d+ MiB/); assert.doesNotMatch(r.out, /claude .*fake|marker/); }); // The selector (rename design r2): --org and --business are one option in the precheck's scope, and a // bad one refuses before any check line, with the script's usage exit 4. test("the precheck takes --org or --business, alone or matching in either order, and refuses a bad selector before any check", (t) => { const x = host(t); for (const sel of [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]]) { const r = precheck(x, { args: [...sel, "--profile", "library"] }); assert.equal(r.code, 0, `${sel.join(" ")}: ${r.out}`); assert.match(r.out, /ok {6}config validates/, sel.join(" ")); } const usage = "usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status\n"; const notId = (v) => `case2-precheck: --business must be a business id: ${v}\n`; for (const [args, out] of [ [["--business", "acme", "--org", "beta"], "case2-precheck: --business acme and --org beta conflict\n"], [["--org", "acme", "--business", "beta"], "case2-precheck: --org acme and --business beta conflict\n"], [["--business", "acme", "--business", "acme"], "case2-precheck: --business given twice\n"], [["--org", "acme", "--org", "acme"], "case2-precheck: --org given twice\n"], [["--org"], usage], [["--business"], usage], [["--org", ""], usage], [["--org", "--profile", "library"], notId("--profile")], [["--business", "-x"], notId("-x")], [["--org", "Acme"], notId("Acme")], [["--org", "acme/../x"], notId("acme/../x")], ]) { const r = precheck(x, { args }); assert.equal(r.code, 4, args.join(" ")); assert.equal(r.out, out, args.join(" ")); } // A selector spelling taken as --profile's value is that option's text. const r = precheck(x, { args: ["--profile", "--org", "--business", "acme"] }); assert.equal(r.code, 4, r.out); assert.equal(r.out, "case2-precheck: --profile must be factory or library: --org\n"); }); test("the precheck refuses when its selector.sh is missing, before any check", (t) => { const x = host(t), lone = join(x.root, "lone", "scripts", "case2-precheck.sh"); mkdirSync(dirname(lone), { recursive: true }); cpSync(PRECHECK, lone); const r = spawnSync(lone, ["--org", "acme"], { encoding: "utf8", env: { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home } }); assert.equal(r.status, 4, r.stdout + r.stderr); assert.equal(r.stdout, ""); assert.ok(r.stderr.endsWith(`case2-precheck: cannot read ${join(x.root, "lone", "scripts", "selector.sh")}\n`), r.stderr); }); test("the precheck refuses each failed host check", (t) => { blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/); blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/); const sudo = host(t); script(join(sudo.bin, "sudo"), "exit 0"); blocked(precheck(sudo), /REFUSED sudo -n true succeeds/); blocked(precheck(host(t), { env: { XDG_DATA_HOME: "/elsewhere" } }), /REFUSED XDG_DATA_HOME is \/elsewhere/); const home = host(t); assert.equal(precheck(home, { env: { XDG_DATA_HOME: `${home.home}/.local/share` } }).code, 0); blocked(precheck(host(t), { env: { HOME: "/tmp" } }), /REFUSED HOME is \/tmp/); const runs = host(t); mkdirSync(join(runs.dataRoot, "launches", "acme"), { recursive: true, mode: 0o755 }); chmodSync(join(runs.dataRoot, "launches", "acme"), 0o755); blocked(precheck(runs), /REFUSED run root .* is not a 0700 directory \(mode 755\)/); chmodSync(join(runs.dataRoot, "launches", "acme"), 0o700); assert.match(precheck(runs).out, /ok {6}run root .* is 0700/); const bad = host(t); writeFileSync(bad.config, "{ not json"); blocked(precheck(bad), /REFUSED config:/); }); test("the precheck refuses a user missing from passwd, a business file that does not load, and a symlinked run root", (t) => { const nouser = host(t); script(join(nouser.bin, "getent"), "exit 2"); blocked(precheck(nouser), /REFUSED user: no .* in the passwd database/); const unparsed = host(t); writeFileSync(join(unparsed.configDir, "businesses", "acme.json"), "{ not json"); blocked(precheck(unparsed), /REFUSED business file: cannot read .*acme\.json/); const gone = host(t); rmSync(join(gone.configDir, "businesses", "acme.json")); blocked(precheck(gone), /REFUSED business file: cannot read .*acme\.json: ENOENT/); const invalid = host(t); const file = join(invalid.configDir, "businesses", "acme.json"); const doc = JSON.parse(read(file)); doc.notAField = true; writeJson(file, doc); const r = precheck(invalid); blocked(r, /REFUSED business file: /); assert.doesNotMatch(r.out, /REFUSED business file: cannot read/); // stat follows a link, so a link to a 0700 directory would read as 0700. const link = host(t); const target = join(link.root, "elsewhere"); mkdirSync(target, { mode: 0o700 }); chmodSync(target, 0o700); mkdirSync(join(link.dataRoot, "launches"), { recursive: true }); symlinkSync(target, join(link.dataRoot, "launches", "acme")); blocked(precheck(link), /REFUSED run root .* is a symlink/); }); test("a bwrap that fails refuses and prints the sysctl and where bwrap is", (t) => { const x = host(t); blocked(precheck(x, { env: { CASE2_TEST_BWRAP: "1" } }), new RegExp(`REFUSED bwrap .* failed; bwrap: ${join(x.bin, "bwrap")}; kernel\\.apparmor_restrict_unprivileged_userns: 1`)); }); test("an unprivileged port floor at or below the Console's port refuses, and so does a missing Console unit", (t) => { for (const floor of ["443", "80"]) { const x = host(t); writeFileSync(join(x.sys, "net", "ipv4", "ip_unprivileged_port_start"), `${floor}\n`); blocked(precheck(x), new RegExp(`REFUSED net\\.ipv4\\.ip_unprivileged_port_start is ${floor}, at or below the Console's port 443`)); } const x = host(t); rmSync(join(x.units, "mosaic-webui.service")); blocked(precheck(x), /REFUSED no installed Console unit/); const noPort = host(t); writeFileSync(join(noPort.units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --business acme\n"); blocked(precheck(noPort), /REFUSED .*mosaic-webui\.service has no --port in its ExecStart/); const noFloor = host(t); rmSync(join(noFloor.sys, "net", "ipv4", "ip_unprivileged_port_start")); blocked(precheck(noFloor), /REFUSED cannot read net\.ipv4\.ip_unprivileged_port_start/); }); test("bus.tester: library refuses it on, factory needs it on, and factory refuses a library business", (t) => { blocked(precheck(host(t, { vars: { "bus.tester": true } })), /REFUSED bus\.tester is on; the library profile needs it off/); blocked(precheck(host(t), { profile: "factory" }), /REFUSED bus\.tester is off; the factory profile needs it on/); const on = precheck(host(t, { vars: { "bus.tester": true } }), { profile: "factory" }); assert.equal(on.code, 0, on.out); const lib = host(t, { vars: { "bus.tester": true } }); const file = join(lib.configDir, "businesses", "acme.json"); const doc = JSON.parse(read(file)); doc.projects.stack.kind = "library"; writeJson(file, doc); blocked(precheck(lib, { profile: "factory" }), /REFUSED profile-library-business: .* projects stack are library projects/); }); test("the claude check runs only for a claude-code instance, and refuses a claude that appends or answers wrong", (t) => { // No claude-code instance and no claude anywhere on PATH: the precheck passes. const none = host(t); const r = precheck(none, { path: pathWithoutClaude(none) }); assert.equal(r.code, 0, r.out); assert.doesNotMatch(r.out, /--system-prompt-file|claude is installed/); const good = host(t, { roles: { cto: { harness: "claude-code" } } }); fakeClaude(good.bin, "replace"); const pass = precheck(good); assert.equal(pass.code, 0, pass.out); assert.match(pass.out, /claude .*9\.9\.9 \(Claude Code, replace fake\)/); assert.match(pass.out, /ok {6}claude replaces its prompt with --system-prompt-file \(40 input tokens\)/); const bad = host(t, { roles: { cto: { harness: "claude-code" } } }); fakeClaude(bad.bin, "append"); blocked(precheck(bad), /REFUSED claude adds to its own prompt with --system-prompt-file: 9000 input tokens/); const nomarker = host(t, { roles: { cto: { harness: "claude-code" } } }); fakeClaude(nomarker.bin, "nomarker"); blocked(precheck(nomarker), /REFUSED claude's reply to the marker prompt lacks the marker \(40 input tokens\)/); const text = host(t, { roles: { cto: { harness: "claude-code" } } }); fakeClaude(text.bin, "text"); blocked(precheck(text), /REFUSED claude gave no JSON reply to the marker prompt/); const missing = host(t, { roles: { cto: { harness: "claude-code" } } }); blocked(precheck(missing, { path: pathWithoutClaude(missing) }), /REFUSED an instance runs claude-code and no claude is installed/); }); test("the claude check runs the bus unit's MOSAIC_CLAUDE_BIN over claude on PATH", (t) => { const pinned = join(tmp(t, "case2-claude-"), "pinned"); const replace = fakeClaude(pinned, "replace"); const x = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: replace }); fakeClaude(x.bin, "append"); const r = precheck(x); assert.equal(r.code, 0, r.out); assert.match(r.out, /replace fake/); const pinnedBad = join(tmp(t, "case2-claude-"), "pinned"); const append = fakeClaude(pinnedBad, "append"); const y = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: append }); fakeClaude(y.bin, "replace"); blocked(precheck(y), /REFUSED claude adds to its own prompt/); }); test("the session env file refuses when missing, open to others, a symlink, empty, or emptied by a later line", (t) => { blocked(precheck(host(t, { keyFile: null })), /REFUSED an instance's provider is zai and .*claude-session\.env is missing/); const open = host(t); chmodSync(join(open.secrets, "claude-session.env"), 0o644); blocked(precheck(open), /REFUSED .*claude-session\.env must be owned by .* at 0600 \(mode 644\)/); const link = host(t, { keyFile: null }); const real = join(link.root, "real.env"); writeFileSync(real, `ZAI_API_KEY=${FIXTURE_KEY}\n`); chmodSync(real, 0o600); symlinkSync(real, join(link.secrets, "claude-session.env")); blocked(precheck(link), /REFUSED .*claude-session\.env is a symlink/); blocked(precheck(host(t, { keyFile: "ZAI_API_KEY=\n" })), /REFUSED .*sets no nonempty ZAI_API_KEY/); blocked(precheck(host(t, { keyFile: `ZAI_API_KEY=${FIXTURE_KEY}\nZAI_API_KEY=\n` })), /REFUSED .*sets no nonempty ZAI_API_KEY/); const quoted = precheck(host(t, { keyFile: `ZAI_API_KEY=\nZAI_API_KEY="${FIXTURE_KEY}"\n` })); assert.equal(quoted.code, 0, quoted.out); }); // ---- the bind test (mos-1100) ---- // A session can't take the Console's port: inside the sandbox a real session gets, a bind below // 1024 fails with EACCES, and the same bind on a free port of 1024 or above succeeds. const sandboxSkip = sandboxProblem() ?? false; const LOW_PORT = 980; test("inside a session's sandbox a bind below 1024 fails with EACCES, and a bind on a free high port succeeds", { skip: sandboxSkip, timeout: 30000 }, async (t) => { const floor = Number(read("/proc/sys/net/ipv4/ip_unprivileged_port_start").trim()); assert.ok(floor > LOW_PORT, `this host's net.ipv4.ip_unprivileged_port_start is ${floor}, at or below the test port ${LOW_PORT}, so the test can't show the sandbox refuses it`); const root = tmp(t, "c2b-"); const runDir = join(root, "run"); const workspace = join(root, "ws"); mkdirSync(runDir); mkdirSync(workspace); mkdirSync(join(runDir, "bundle")); writeFileSync(join(runDir, "session.json"), "{}"); const adapter = join(root, "adapter.sh"); const brokerSocket = join(root, "broker.sock"); writeFileSync(adapter, ""); writeFileSync(brokerSocket, ""); prepareRun(runDir); const list = binds({ runDir, workspace, adapter, harnessMounts: [], brokerSocket }); const high = await new Promise((done) => { const s = createServer().listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => done(port)); }); }); const bindIn = (port) => { const code = `require("net").createServer().on("error", (e) => { console.log(e.code); process.exit(0); }).listen(${port}, "127.0.0.1", function () { console.log("bound"); this.close(); });`; const argv = sandboxArgv(list, runDir, [process.execPath, "-e", code], { business: "acme", role: "coder" }); const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 }); assert.equal(r.status, 0, r.stderr); return r.stdout.trim(); }; assert.equal(bindIn(LOW_PORT), "EACCES"); assert.equal(bindIn(high), "bound"); }); test("Vikunja 614 factory: scoped @anthropic-ai Claude path renders and passes installed read-back", (t) => { const h = hub(t); const claude = script(join(h.root, "opt", "claude-pin", "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"), "exit 0"); // The suite runs unprivileged. Model root ownership for this regular executable // and its ancestors only; file type/executable checks and both unit readers are real. const ancestors = []; for (let p = claude; p !== "/"; p = dirname(p)) ancestors.push(p); script(join(h.bin, "stat"), `if [ "$1" = -c ] && [ "$3" = -- ]; then case "$4" in ${ancestors.map((p) => JSON.stringify(p)).join("|")}) case "$2" in %u) echo 0; exit ;; %a) echo 755; exit ;; esac ;; esac fi exec /usr/bin/stat "$@"`); const first = unit(h, { profile: "factory", extra: ["--claude", claude] }); assert.equal(first.code, 0, first.out + first.err); assert.ok(lines(read(busFile(h)), "Environment").includes(`MOSAIC_CLAUDE_BIN=${claude}`)); const again = unit(h, { profile: "factory", copy: COPY_B, extra: ["--claude", claude] }); assert.equal(again.code, 0, again.out + again.err); assert.ok(lines(read(busFile(h)), "Environment").includes(`MOSAIC_CLAUDE_BIN=${claude}`)); assert.deepEqual(lines(read(busFile(h)), "WorkingDirectory"), [copyDir(h, COPY_B)]); }); test("Vikunja 614 factory: plain paths still refuse every named unsafe character", (t) => { const h = hub(t); for (const char of [" ", "%", "$", "\"", "'", "\\", "&", "|"]) { refuses(unit(h, { profile: "factory", extra: ["--claude", `/opt/claude${char}bin`] }), /--claude contains a character the unit cannot carry/); } }); test("Vikunja 614 precheck: explicit null and named notifier decisions pass", (t) => { for (const binding of [null, "host-notify"]) { const x = host(t); const dir = join(x.dataRoot, "notify", "acme"); mkdirSync(dir, { recursive: true, mode: 0o700 }); writeFileSync(join(dir, "notify.json"), JSON.stringify({ notifyVersion: 1, binding }), { mode: 0o600 }); const r = precheck(x); assert.equal(r.code, 0, r.out); assert.ok(r.out.includes(`ok notifier config ${dir}/notify.json validates (directory 0700, file 0600)`), r.out); } }); test("Vikunja 614 precheck: notifier config refuses missing, public, malformed and extra-key decisions", (t) => { for (const fault of ["missing", "mode", "directory", "extra", "json", "binding", "version", "symlink"]) { const x = host(t); const dir = join(x.dataRoot, "notify", "acme"), file = join(dir, "notify.json"); mkdirSync(dir, { recursive: true, mode: 0o700 }); writeFileSync(file, '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 }); if (fault === "missing") rmSync(file); if (fault === "mode") chmodSync(file, 0o644); if (fault === "directory") chmodSync(dir, 0o755); if (fault === "extra") writeFileSync(file, '{"notifyVersion":1,"binding":null,"extra":true}\n'); if (fault === "json") writeFileSync(file, '{bad json\n'); if (fault === "binding") writeFileSync(file, '{"notifyVersion":1,"binding":"bad binding"}\n'); if (fault === "version") writeFileSync(file, '{"notifyVersion":2,"binding":null}\n'); if (fault === "symlink") { const target = join(dir, "target.json"); writeFileSync(target, readFileSync(file), { mode: 0o600 }); rmSync(file); symlinkSync(target, file); } const r = precheck(x); assert.equal(r.code, 1, `${fault}: ${r.out}`); assert.match(r.out, /REFUSED notifier config:/, `${fault}: ${r.out}`); } }); // Row334: render only into the scratch hub; fake systemctl never touches the host. const PROXY_TEMPLATE = join(REPO, 'packages/cli/systemd/mosaic-proxy.service.in'); const proxyFile = h => join(h.units, 'mosaic-proxy.service'); const proxyUnit = (h, options = {}) => unit(h, { ...options, extra: ['--proxy', ...(options.extra ?? [])] }); // Frozen bytes of the renderer just before row 334 that already carries #385 (418f00bce:scripts/case2-unit.sh, // git blob d669e8cbd). A fixture, not a git-history read, so a shallow clone or an exported tree still has it. // The .txt suffix keeps it out of any *.sh enumeration; the test writes it out as an executable in the scratch hub. const PRE_ROW334_RENDERER = join(REPO, 'packages/cli/tests/fixtures/case2-unit-pre-row334-renderer.sh.txt'); const PRE_ROW334_BLOB = 'd669e8cbde7f16c9f6d16b8e550935f9fdfd9df4'; test('row334 no-proxy renders stay byte-identical to the pre-row renderer', t => { const h = hub(t); // Executed only with the same scratch seams. The blob id check stops a silent edit of the fixture. const bytes = readFileSync(PRE_ROW334_RENDERER); assert.equal(createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'), PRE_ROW334_BLOB); const old = join(h.root, 'baseline/scripts/case2-unit.sh'); mkdirSync(dirname(old), { recursive: true }); cpSync(PRE_ROW334_RENDERER, old); chmodSync(old, 0o755); const args = ['--user', 'mosaic', '--home', h.home, '--business', 'acme', '--copy', COPY_A, '--node', USR_NODE, '--', ...libraryFlags(h)]; const r = spawnSync(old, args, { encoding: 'utf8', env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, 'systemctl'), CASE2_GETENT: join(h.bin, 'getent'), CASE2_BUS_TEMPLATE: BUS_TEMPLATE, CASE2_WEBUI_TEMPLATE: WEBUI_TEMPLATE } }); assert.equal(r.status, 0, r.stderr); const before = [read(busFile(h)), read(webuiFile(h))]; assert.equal(unit(h).code, 0); assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before); assert.equal(existsSync(proxyFile(h)), false); }); test('row334 proxy uses P1 runner, bus dependencies and proxy bus Console start order', t => { const h = hub(t); const r = proxyUnit(h, { profile: 'factory', flags: ['--port', '443', '--business', 'acme'], extra: ['--claude', TRUE_BIN] }); assert.equal(r.code, 0, r.err); const proxy = read(proxyFile(h)); const expected = read(PROXY_TEMPLATE).replaceAll('@REPO@', copyDir(h)).replaceAll('@PATH@', '/usr/bin:/usr/local/bin:/bin') .replaceAll('@NODE@', USR_NODE).replace(/^@CONFIG_ENV@\n/m, '').replace('WantedBy=default.target', 'WantedBy=multi-user.target') .replace('TimeoutStopSec=60', `TimeoutStopSec=60\nUser=mosaic\nGroup=mosaic\n${HARDENING(h).join('\n')}`); assert.equal(proxy, expected); assert.equal(statSync(proxyFile(h)).mode & 0o777, 0o644); assert.match(read(busFile(h)), /Description=[^\n]+\nWants=mosaic-proxy.service\nAfter=mosaic-proxy.service\n/); assert.deepEqual(lines(proxy, 'ExecStart'), [`${USR_NODE} ${copyDir(h)}/packages/model-proxy/src/run.mjs`]); assert.doesNotMatch(proxy, /EnvironmentFile=|Capabilities=|CapabilityBoundingSet=|MOSAIC_CLAUDE_BIN/); assert.doesNotMatch(read(webuiFile(h)), /Wants=|After=/); assert.deepEqual(read(h.log).trim().split('\n'), [ 'is-active --quiet mosaic-proxy.service', 'is-active --quiet mosaic-bus@acme.service', 'is-active --quiet mosaic-webui.service', 'daemon-reload', 'enable mosaic-proxy.service mosaic-bus@acme.service mosaic-webui.service', 'start mosaic-proxy.service', 'start mosaic-bus@acme.service', 'start mosaic-webui.service', ]); }); const proxyRefusals = [ ['continuation', s => s.replace('Type=simple', 'Type=simple' + String.fromCharCode(92)), /a line continues onto the next/], ['not-setting', s => s.replace('Type=simple', 'not a setting'), /neither a section/], ...['RestrictNamespaces', 'PrivateUsers', 'SystemCallFilter', 'PermissionsStartOnly', 'EnvironmentFile', 'AmbientCapabilities', 'CapabilityBoundingSet'].map(k => [k, s => s.replace('Type=simple', `Type=simple\n${k}=yes`), new RegExp(`refusing ${k}=`)]), ['privileged', s => s.replace('ExecStart=', 'ExecStart=+'), /Exec line that runs with full privileges/], ['direct-binary', s => s.replace('packages/model-proxy/src/run.mjs', 'opt/cli-proxy-api'), /ExecStart= must be exactly once/], ['nnp', s => s.replace('NoNewPrivileges=yes', 'NoNewPrivileges=no'), /NoNewPrivileges= must be exactly once/], ['cwd', s => s.replace('WorkingDirectory=@REPO@', 'WorkingDirectory=/elsewhere'), /WorkingDirectory= must be exactly once/], ['environment', s => s.replace('Environment=PATH=@PATH@', 'Environment=PATH=/elsewhere'), /Environment= lines must be exactly/], ['install', s => s.replace('WantedBy=default.target', 'WantedBy=other.target'), /WantedBy= must be exactly once/], ['user', s => s.replace('Type=simple', 'Type=simple\nUser=other'), /User= must be exactly once/], ['group', s => s.replace('Type=simple', 'Type=simple\nGroup=other'), /Group= must be exactly once/], ...['ProtectSystem', 'ReadWritePaths', 'ProtectHome', 'PrivateTmp'].map(k => [k, s => s.replace('Type=simple', `Type=simple\n${k}=other`), new RegExp(`${k}= must be exactly once`)]), ['token', s => s.replace('Type=simple', 'Type=simple\nX-Note=@UNKNOWN@'), /a token is left after the render/], ]; for (const [name, edit, message] of proxyRefusals) test(`row334 proxy refuses ${name} by message before writing`, t => { const h = hub(t); const file = template(h, PROXY_TEMPLATE, `${name}.in`, edit); refuses(proxyUnit(h, { env: { CASE2_PROXY_TEMPLATE: file } }), message); assert.deepEqual(readdirSync(h.units), []); assert.equal(existsSync(h.log), false); }); test('row334 proxy template missing or symlink refuses', t => { const h = hub(t), missing = join(h.root, 'missing.in'); refuses(proxyUnit(h, { env: { CASE2_PROXY_TEMPLATE: missing } }), /template missing or not a regular file/); symlinkSync(PROXY_TEMPLATE, missing); refuses(proxyUnit(h, { env: { CASE2_PROXY_TEMPLATE: missing } }), /template missing or not a regular file/); }); test('row334 rendered ExecStart refuses wrong PIN and missing proxy with P1 status', t => { const h = hub(t); assert.equal(proxyUnit(h).code, 0); const copy = copyDir(h); cpSync(join(REPO, 'packages/model-proxy'), join(copy, 'packages/model-proxy'), { recursive: true }); cpSync(join(REPO, 'scripts/mosaic-config.mjs'), join(copy, 'scripts/mosaic-config.mjs')); const config = systemConfig(join(h.root, 'runner')); const doc = JSON.parse(read(config)); mkdirSync(doc.dataRoot, { recursive: true, mode: 0o700 }); const pin = JSON.parse(read(join(REPO, 'packages/model-proxy/PIN'))); const binary = join(doc.dataRoot, 'opt/cliproxyapi', pin.version, 'cli-proxy-api'); script(binary, `echo unexpected-spawn > '${h.root}/spawned'`); writeJson(join(dirname(config), 'proxy.json'), { port: 18541, binary }); const argv = lines(read(proxyFile(h)), 'ExecStart')[0].split(' '); const run = () => spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', env: { PATH: '/usr/bin:/bin', HOME: h.home, MOSAIC_CONFIG: config } }); const bad = run(); assert.equal(bad.status, 3, bad.stderr); assert.equal(bad.stderr, 'proxy-install-invalid\n'); assert.equal(existsSync(join(h.root, 'spawned')), false); rmSync(join(dirname(config), 'proxy.json')); const absent = run(); assert.equal(absent.status, 3, absent.stderr); assert.equal(absent.stderr, 'proxy-not-configured\n'); }); test('row334 reinstall repins all three and refuses a proxy copy mismatch', t => { const h = hub(t); assert.equal(proxyUnit(h).code, 0); assert.equal(proxyUnit(h, { copy: COPY_B }).code, 0); for (const file of [proxyFile(h), busFile(h), webuiFile(h)]) assert.match(read(file), new RegExp(copyDir(h, COPY_B))); writeFileSync(proxyFile(h), read(proxyFile(h)).replaceAll(copyDir(h, COPY_B), copyDir(h))); const before = [proxyFile(h), busFile(h), webuiFile(h)].map(read); refuses(proxyUnit(h), /installed units name different copies/); assert.deepEqual([proxyFile(h), busFile(h), webuiFile(h)].map(read), before); }); test('row334 installed proxy without --proxy refuses and preserves all bytes', t => { const h = hub(t); assert.equal(proxyUnit(h).code, 0); const files = [proxyFile(h), busFile(h), webuiFile(h)], before = files.map(read); writeFileSync(h.log, ''); refuses(unit(h), /installed mosaic-proxy.service requires --proxy/); assert.deepEqual(files.map(read), before); assert.equal(read(h.log), ''); }); test('row334 adding --proxy to a no-proxy install refuses by naming --proxy and preserves all bytes', t => { const h = hub(t); assert.equal(unit(h).code, 0); const files = [busFile(h), webuiFile(h)], before = files.map(read); writeFileSync(h.log, ''); refuses(proxyUnit(h), /--pm, --claude and --proxy must equal the installed ones/); assert.deepEqual(files.map(read), before); assert.equal(existsSync(proxyFile(h)), false); assert.equal(read(h.log), ''); }); test('row334 dangling proxy symlink without flag refuses without deleting', t => { const h = hub(t); symlinkSync(join(h.root, 'absent'), proxyFile(h)); refuses(unit(h), /installed mosaic-proxy.service requires --proxy/); assert.equal(readdirSync(h.units).includes('mosaic-proxy.service'), true); }); test('row334 active proxy reports old copy and all three must restart together', t => { const h = hub(t); assert.equal(proxyUnit(h).code, 0); const r = proxyUnit(h, { copy: COPY_B, env: { CASE2_TEST_ACTIVE: 'mosaic-proxy.service mosaic-bus@acme.service mosaic-webui.service' } }); assert.equal(r.code, 0, r.err); assert.match(r.out, /mosaic-proxy.service was already running/); assert.match(r.out, /restart mosaic-proxy.service, mosaic-bus@acme.service and mosaic-webui.service together/); }); for (const name of ['mosaic-proxy.service', 'mosaic-bus@acme.service']) test(`row334 failed ${name} start leaves later units unstarted`, t => { const h = hub(t); refuses(proxyUnit(h, { env: { CASE2_TEST_FAIL: `start ${name}` } }), /systemctl start .* failed/); assert.doesNotMatch(read(h.log), /start mosaic-webui.service/); if (name === 'mosaic-proxy.service') assert.doesNotMatch(read(h.log), /start mosaic-bus@acme.service/); }); test('row334 installed proxy enforces hardening order and remaining common refusals', t => { const h = hub(t); assert.equal(proxyUnit(h).code, 0); const original = read(proxyFile(h)); for (const [edit, message] of [ [s => s.replace('ProtectSystem=strict\nReadWritePaths=' + h.home, 'ReadWritePaths=' + h.home + '\nProtectSystem=strict'), /hardening lines must be in the order/], [s => s.replace('WorkingDirectory=' + copyDir(h), 'WorkingDirectory=%h'), /names no copy/], [s => s.replace('Type=simple', 'Type=simple\nX-Note=%h'), /%h is left; a system unit/], [s => s.replace('RestartSec=15', 'RestartSec=16'), /differs from the render in more than the copy id/], ]) { writeFileSync(proxyFile(h), edit(original)); refuses(proxyUnit(h), message); } }); test('row334 proxy installed path rejects symlink and non-regular file', t => { const h = hub(t); symlinkSync(join(h.root, 'absent'), proxyFile(h)); refuses(proxyUnit(h), /is a symlink/); rmSync(proxyFile(h)); mkdirSync(proxyFile(h)); refuses(proxyUnit(h), /is not a regular file/); }); // M3: the managed uid is a dedicated non-login service identity. These are // database fixtures only: no real passwd/group or instance directories change. test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => { for (const [kind, shell, extraPasswd, members] of [ ['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''], ['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''], ['supplementary primary-group member','/usr/sbin/nologin','','human'], ]) { const h = hub(t); script(join(h.bin,'getent'), `case "$1" in passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';; group) echo 'mosaic:x:${GID}:${members}';; esac`); const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false); } const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); } }); test('M3 host config and data directories belong to the selected uid and both units use it', t => { const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err); for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); } const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/); });