From 6db8fb2dced691385ecff181b587a77d46542e17 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 10 Nov 2026 14:54:19 -0600 Subject: [PATCH] feat(cli): enforce stamped-copy and deployed-instance boundaries (#1873) --- packages/cli/src/cli.mjs | 3 + packages/cli/src/deployment.mjs | 56 ++++++++++++ packages/cli/tests/case2-install.test.mjs | 5 + .../cli/tests/deployment-boundary.test.mjs | 91 +++++++++++++++++++ packages/cli/tests/stack-copy.test.mjs | 3 +- packages/webui/src/cli.mjs | 7 ++ scripts/case2-install.sh | 3 + scripts/stack-copy.sh | 1 + 8 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/deployment.mjs create mode 100644 packages/cli/tests/deployment-boundary.test.mjs diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index b177cebb9..e825364c6 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -8,6 +8,7 @@ // host (src/host.mjs) the systemd unit mosaic-bus@ runs; with // --pm it also launches the business's PM (src/launcher.mjs). +import { assertBuildStart, assertDevInstance } from './deployment.mjs'; import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { createInterface } from "node:readline/promises"; @@ -614,8 +615,10 @@ async function bus(rest, io, deps) { const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] }); if (parsed.positional.length !== 1) throw usage("bus start takes one organization"); if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both"); + assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") }); if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env); const ctx = organizationContext(io, deps); + if (parsed.flags.has("--dev-local-owner")) assertDevInstance(ctx.system, io.env); const businessId = parsed.positional[0]; if (!parsed.flags.has("--internal-host")) { const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") }); diff --git a/packages/cli/src/deployment.mjs b/packages/cli/src/deployment.mjs new file mode 100644 index 000000000..079320470 --- /dev/null +++ b/packages/cli/src/deployment.mjs @@ -0,0 +1,56 @@ +// M3: executable and instance boundaries. Presence is checked with lstat, so +// broken links and unreadable metadata never turn a deployed path into dev. +import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +const configDir = (env = process.env) => dirname(resolve(env.MOSAIC_CONFIG || join(homedir(), '.config/mosaic-dev/config.json'))); + +const CODE_ROOT = resolve(import.meta.dirname, '../../..'); +export function present(file) { + try { lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } +} +function metadata(file, label, check) { + let fd; + try { + fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const st = fstatSync(fd); + if (!st.isFile() || st.size > 4096 || !check(st)) throw new Error('metadata refused'); + return JSON.parse(readFileSync(fd, 'utf8')); + } catch { throw new Error(`${label} is missing or invalid`); } + finally { if (fd !== undefined) closeSync(fd); } +} +export function buildStamp(root = CODE_ROOT) { + const file = join(root, '.stack-build.json'); + if (!present(file)) return null; + const doc = metadata(file, 'build stamp', st => !(st.mode & 0o222)); + if (!doc || Object.keys(doc).length !== 1 || !/^[0-9a-f]{40}$/.test(doc.commit) || doc.commit !== basename(root)) throw new Error('build stamp does not name this copy commit'); + return doc; +} +export function assertBuildStart({ serve = false, sso = false, devOwner = false, devBind = null, devNames = [] } = {}) { + const stamp = buildStamp(); + if (stamp && (devOwner || devBind !== null || devNames.length)) throw new Error('stamped build refuses dev-local-owner and LAN flags'); + if (stamp && serve && !sso) throw new Error('stamped build serve requires --sso'); + return stamp; +} +export function assertDevInstance(system, env = process.env) { + if (present(join(configDir(env), 'accounts.json'))) throw new Error('--dev-local-owner refuses accounts.json'); + if (system.console?.sso) throw new Error('--dev-local-owner refuses console.sso'); + if (present(join(system.dataRoot, '.deployed'))) throw new Error('--dev-local-owner refuses .deployed'); +} +const instanceName = dir => /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir))?.[1] ?? 'dev'; +export function assertDeployed(system, dir = configDir()) { + const doc = metadata(join(system.dataRoot, '.deployed'), '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid()); + if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance'); +} +// Both managed host cases install through case2-install.sh. Existing metadata +// must validate; an install never replaces it or follows a destination link. +export function writeDeployment(system, dir = configDir()) { + const file = join(system.dataRoot, '.deployed'); + if (present(file)) { + const doc = metadata(file, '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid()); + if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance'); + return; + } + const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o444); + try { writeFileSync(fd, JSON.stringify({ instance: instanceName(dir) }) + '\n'); fchmodSync(fd, 0o444); } finally { closeSync(fd); } +} diff --git a/packages/cli/tests/case2-install.test.mjs b/packages/cli/tests/case2-install.test.mjs index 63db8cb84..51380506e 100644 --- a/packages/cli/tests/case2-install.test.mjs +++ b/packages/cli/tests/case2-install.test.mjs @@ -148,6 +148,8 @@ test("the library install writes the business file, personas and slot dir, makes const x = world(t); const r = install(x); assert.equal(r.code, 0, r.out); + assert.equal(mode(join(x.dataRoot, '.deployed')), 0o444); + assert.deepEqual(JSON.parse(readFileSync(join(x.dataRoot, '.deployed'), 'utf8')), { instance: 'dev' }); assert.equal(mode(x.business), 0o600); const doc = JSON.parse(readFileSync(x.business, "utf8")); for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) { @@ -494,6 +496,9 @@ function dependencyFreeClone(t) { for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) { cpSync(join(REPO, "scripts", file), join(clone, "scripts", file)); } + mkdirSync(join(clone, 'packages/cli/src'), { recursive: true }); + cpSync(join(REPO, 'packages/cli/src/deployment.mjs'), join(clone, 'packages/cli/src/deployment.mjs')); + writeJson(x.config, { dataRoot: x.dataRoot }); // Isolate the persona step; bootstrap and business validation are covered above. script(join(clone, "scripts", "bootstrap.sh"), "exit 0"); script(join(clone, "scripts", "mosaic"), "exit 0"); diff --git a/packages/cli/tests/deployment-boundary.test.mjs b/packages/cli/tests/deployment-boundary.test.mjs new file mode 100644 index 000000000..4e44af756 --- /dev/null +++ b/packages/cli/tests/deployment-boundary.test.mjs @@ -0,0 +1,91 @@ +// M3 executable boundary: copies and instances are fixtures only, no live ports. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { fixture } from './helpers.mjs'; +const REPO = fileURLToPath(new URL('../../../', import.meta.url)); +const SHA = 'a'.repeat(40); +function setup(t) { + const root = mkdtempSync(join(tmpdir(), 'db-')); t.after(() => rmSync(root, { recursive: true, force: true })); + const copy = join(root, SHA); mkdirSync(copy); + for (const dir of ['packages', 'scripts', 'roles', 'adapters']) cpSync(join(REPO, dir), join(copy, dir), { recursive: true, filter: p => !p.split('/').some(x => ['tests', 'node_modules'].includes(x)) }); + writeFileSync(join(copy, 'package.json'), '{"type":"module"}'); + const stamp = join(copy, '.stack-build.json'); writeFileSync(stamp, JSON.stringify({ commit: SHA }), { mode: 0o444 }); chmodSync(stamp, 0o444); + const f = fixture(join(root, 'instance')); const home = join(root, 'home'); const dir = join(home, '.config/mosaic-test'); mkdirSync(dir, { recursive: true }); + const config = join(dir, 'config.json'); cpSync(f.env.MOSAIC_CONFIG, config); + writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' })); + const env = { ...process.env, HOME: home, MOSAIC_CONFIG: config }; + const run = (args, from = copy) => spawnSync(process.execPath, [join(from, args[0]), ...args.slice(1)], { env, encoding: 'utf8', timeout: 5000 }); + return { root, copy, stamp, f, env, config, dir, run }; +} +const serve = flags => ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...flags]; +test('stamped serve refuses dev owner, LAN flags, missing --sso and missing .deployed before listening', t => { + const s = setup(t); + for (const flags of [['--dev-local-owner'], ['--dev-insecure-bind','0.0.0.0'], ['--dev-allow-host','test.example'], []]) { + const r = s.run(serve(flags)); assert.equal(r.status, 2); assert.match(r.stderr, /stamped build/); assert.doesNotMatch(r.stdout, /Mosaic Console:/); + } + const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/); + const bus = s.run(['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']); assert.notEqual(bus.status, 0); assert.match(bus.stderr, /stamped build/); +}); +test('writable, linked, malformed, wrong-commit and directory stamps refuse every start', t => { + const s = setup(t); + for (const kind of ['writable','symlink','wrong','malformed','directory']) { + rmSync(s.stamp, { force: true, recursive: true }); + if (kind === 'symlink') { const other = join(s.root, 'stamp'); writeFileSync(other, JSON.stringify({ commit: SHA }), { mode: 0o444 }); symlinkSync(other, s.stamp); } + else if (kind === 'directory') mkdirSync(s.stamp); + else { writeFileSync(s.stamp, kind === 'malformed' ? '{' : JSON.stringify({ commit: kind === 'wrong' ? 'b'.repeat(40) : SHA })); chmodSync(s.stamp, kind === 'writable' ? 0o644 : 0o444); } + for (const args of [serve([]), serve(['--sso']), ['packages/cli/src/cli.mjs','bus','start','acme']]) { const r = s.run(args); assert.notEqual(r.status, 0, kind); assert.match(r.stderr, /build stamp/, `${kind}: ${r.stderr}`); } + } +}); +test('stamped serve requires 0444 metadata naming its instance and a loadable accounts file', t => { + const s = setup(t), file = join(s.f.dataRoot, '.deployed'); + for (const [mode, instance] of [[0o644,'test'], [0o444,'other']]) { + writeFileSync(file, JSON.stringify({ instance })); chmodSync(file, mode); + const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/); + } + chmodSync(file, 0o644); writeFileSync(file, JSON.stringify({ instance: 'test' })); chmodSync(file, 0o444); + const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /accounts/); +}); +test('checkout dev owner refuses accounts, SSO or deployment presence, including dangling links', t => { + const s = setup(t); + for (const name of ['accounts.json', '.deployed']) { + const file = join(name === 'accounts.json' ? s.dir : s.f.dataRoot, name); symlinkSync(join(s.root, 'missing'), file); + for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses/); } + rmSync(file); + } + const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } }; + writeFileSync(s.config, JSON.stringify(config)); const r = s.run(serve(['--dev-local-owner']), REPO); assert.equal(r.status, 2); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); +}); +import { readFileSync as awaitRead } from 'node:fs'; + +test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => { + const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed'); + writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' }); + assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode); + rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file); + writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); +}); +import { statSync } from 'node:fs'; +import { writeDeployment } from '../src/deployment.mjs'; + +test('valid stamped accounts Console starts with --sso and answers health on its own port zero', async t => { + const { spawn } = await import('node:child_process'); + const { once } = await import('node:events'); + const { fakeOidc } = await import('../../webui/tests/helpers/fake-oidc.mjs'); + const s = setup(t), op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a'); + symlinkSync(join(REPO, 'node_modules'), join(s.copy, 'node_modules')); + const config = JSON.parse(awaitRead(s.config)); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } }; + writeFileSync(s.config, JSON.stringify(config)); mkdirSync(join(s.dir, 'secrets/mosaic-stack'), { recursive: true, mode: 0o700 }); + writeFileSync(join(s.dir, 'secrets/mosaic-stack/a'), iss.clientSecret, { mode: 0o600 }); + writeFileSync(join(s.dir, 'accounts.json'), JSON.stringify({ 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:[] }), { mode: 0o600 }); + writeDeployment({ dataRoot: s.f.dataRoot }, s.dir); const ca = join(s.root, 'ca.pem'); writeFileSync(ca, op.cert); + const child = spawn(process.execPath, [join(s.copy,'packages/webui/src/cli.mjs'), 'serve','--port','0','--sso','--board','http://127.0.0.1:9'], { env: { ...s.env, NODE_EXTRA_CA_CERTS: ca } }); + let out = '', err = ''; child.stderr.on('data', c => err += c); + t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const done = once(child, 'close'); child.kill(); await done; } }); + await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('start timeout: '+err)), 10000); child.stdout.on('data', c => { out += c; if(out.includes('Ctrl-C')) {clearTimeout(timer);resolve();} }); child.on('exit', () => {clearTimeout(timer);reject(new Error(err));}); }); + const port = Number(out.match(/127\.0\.0\.1:(\d+)\//)[1]); assert.ok(port); assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200); +}); diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs index 3e3c75cd6..c2e8019d7 100644 --- a/packages/cli/tests/stack-copy.test.mjs +++ b/packages/cli/tests/stack-copy.test.mjs @@ -58,6 +58,7 @@ test('a copy carries every file the harness modules read from it',async t=>{ test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',async t=>{ const s=setup(t),r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(r.status,0,r.stderr); const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555); + assert.deepEqual(JSON.parse(readFileSync(join(copy,'.stack-build.json'),'utf8')), {commit:s.id}); assert.equal(statSync(join(copy,'.stack-build.json')).mode&0o222,0); for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f); const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755); const existing=(await s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]));assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm); @@ -158,7 +159,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}} const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr=''; web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c); - try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);} + try{await until(()=>web.exitCode!==null);assert.equal(web.exitCode,2);assert.match(webErr,/stamped build serve requires --sso/);assert.doesNotMatch(webOut,/Mosaic Console:/);} finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}} // The production runtime derives cliPath. Only agent ancestry is mocked; // readProcess passes through for tester.mjs (row 110). diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 714947a9d..022cae2e3 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { assertBuildStart, assertDevInstance, assertDeployed } from '../../cli/src/deployment.mjs'; // MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP. import { createChat } from './chat.mjs'; import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs'; @@ -99,6 +100,7 @@ try { } else if (flag === '--dev-allow-host') devNames.push(value); else throw new Error(`unknown option: ${flag}`); } + const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames }); if (selector.given) options.business = selector.value; if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket'); const lan = devLanOptions({ devOwner, bind: devBind, names: devNames }); @@ -119,6 +121,11 @@ try { let processing = null, ownerCall = null; try { system = loadSystem({ instance }); + if (devOwner) assertDevInstance(system); + if (stamp) { + assertDeployed(system); + loadAccounts(join(configDir(), 'accounts.json'), { providers: system.console?.sso?.providers ?? [] }); + } const { dataRoot } = system; const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); // FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs). diff --git a/scripts/case2-install.sh b/scripts/case2-install.sh index 1a6cd63c7..fd7a8b79a 100755 --- a/scripts/case2-install.sh +++ b/scripts/case2-install.sh @@ -203,6 +203,9 @@ if [ ! -e "$CONFIG_DIR" ]; then fi out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)" say "config $CONFIG" +# Case 1 and Case 2 share this installer. Deployment identity is instance-scoped, +# never a switch that can be silently removed to enable the local dev owner. +out="$(cd "$REPO" && node --input-type=module -e 'import { readFileSync } from "node:fs"; import { writeDeployment } from "./packages/cli/src/deployment.mjs"; writeDeployment(JSON.parse(readFileSync(process.argv[1], "utf8")));' "$CONFIG" 2>&1)" || refuse "deployment: $(printf '%s' "$out" | tail -n 1)" # 3. The stack copy row 195's units run from. out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)" diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh index 2918e3725..146c10b7d 100755 --- a/scripts/stack-copy.sh +++ b/scripts/stack-copy.sh @@ -81,6 +81,7 @@ copy_tree() { git -C "$1" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written" [ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run' (cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written' + printf '{"commit":"%s"}\n' "$full" > "$tmp/.stack-build.json" find "$tmp" -type f -exec chmod a-w {} + find "$tmp" -type d -exec chmod 0555 {} + if ! mv -T "$tmp" "$target" 2>/dev/null; then