diff --git a/.changeset/slick-eyes-pay.md b/.changeset/slick-eyes-pay.md new file mode 100644 index 000000000..82d07b206 --- /dev/null +++ b/.changeset/slick-eyes-pay.md @@ -0,0 +1,4 @@ +--- +--- + +Enforce recorded sandbox identity before smoke-suite teardown commands. This changes test infrastructure only and does not alter any published package. diff --git a/bin/smoke/backup-conservation-live.smoke.ts b/bin/smoke/backup-conservation-live.smoke.ts index daf2f05c9..dbf176b48 100644 --- a/bin/smoke/backup-conservation-live.smoke.ts +++ b/bin/smoke/backup-conservation-live.smoke.ts @@ -53,6 +53,7 @@ import { type SpaceAuth, } from "@cotal-ai/core"; import { authDir, loadSoleSpaceAuth } from "@cotal-ai/workspace"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; const freePort = () => new Promise((resolvePort, reject) => { const server = createServer(); @@ -106,10 +107,15 @@ interface Mesh { async function openMesh(label: string, space: string): Promise { const root = realpathSync.native(mkdtempSync(join(tmpdir(), `cotal-${label}-root-`))); const home = realpathSync.native(mkdtempSync(join(tmpdir(), `cotal-${label}-home-`))); + const configDir = join(home, "xdg"); + const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); const server = `nats://127.0.0.1:${await freePort()}`; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => - spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; + const run = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(tsx, [cliPath, ...args], options); + }; return { root, home, server, space, run, must: (name, result) => { diff --git a/bin/smoke/backup-faults-live.smoke.ts b/bin/smoke/backup-faults-live.smoke.ts index 93c3efa77..3282c37bb 100644 --- a/bin/smoke/backup-faults-live.smoke.ts +++ b/bin/smoke/backup-faults-live.smoke.ts @@ -16,6 +16,7 @@ import { type SpaceBackupSelection, } from "@cotal-ai/core"; import { authDir, loadSoleSpaceAuth } from "@cotal-ai/workspace"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; import { BACKUP_MANIFEST_FORMAT, type BackupManifest } from "../../implementations/cli/src/lib/backup-artifact.js"; const freePort = () => new Promise((resolvePort, reject) => { @@ -77,11 +78,17 @@ function assertValidManifest(directory: string, space: string, selection: SpaceB async function backupStageFaultScenario(): Promise { const root = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-stages-root-"))); const home = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-stages-home-"))); + const configDir = join(home, "xdg"); + const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_faults_stages"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; + const run = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(tsx, [cliPath, ...args], options); + }; const must = (label: string, result: ReturnType) => { assert.equal(result.status, 0, `stages ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); }; @@ -143,12 +150,18 @@ const RESTORE_FAULTS = [ async function restoreExactIdTimeoutReplayScenario(): Promise { const root = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-replay-root-"))); const home = realpathSync.native(mkdtempSync(join(tmpdir(), "cotal-backup-faults-replay-home-"))); + const configDir = join(home, "xdg"); + const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); const artifact = join(root, "full-backup"); const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_faults_replay"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; + const run = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(tsx, [cliPath, ...args], options); + }; const must = (label: string, result: ReturnType) => { assert.equal(result.status, 0, `replay ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); }; diff --git a/bin/smoke/backup-restore-live.smoke.ts b/bin/smoke/backup-restore-live.smoke.ts index bcce8078a..ad9ed8908 100644 --- a/bin/smoke/backup-restore-live.smoke.ts +++ b/bin/smoke/backup-restore-live.smoke.ts @@ -26,6 +26,7 @@ import { createAttemptClone, startIsolatedBroker, } from "../../implementations/cli/src/lib/isolated-broker.js"; +import { assertSmokeSandboxDown, recordSmokeSandbox, type SmokeSandboxAnchor } from "@cotal-ai/smoke-kit"; const freePort = () => new Promise((resolvePort, reject) => { const server = createServer(); @@ -66,6 +67,22 @@ const cliPath = join(worktree, "bin", "cotal.ts"); const taskSeedPath = join(worktree, "implementations", "cli", "smoke", "seed-task-durable.ts"); const tsx = join(worktree, "node_modules", ".bin", "tsx"); +function sandboxRun(root: string, home: string): { + run: (...args: string[]) => SpawnSyncReturns; + env: NodeJS.ProcessEnv; + sandbox: SmokeSandboxAnchor; +} { + const configDir = join(home, "xdg"); + const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); + const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; + const run = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const, timeout: 240_000 }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(tsx, [cliPath, ...args], options); + }; + return { run, env, sandbox }; +} + interface Checkpoint { stream: string; name: string } async function scenario(mode: "open" | "auth"): Promise { @@ -76,8 +93,7 @@ async function scenario(mode: "open" | "auth"): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = `backup_live_${mode}`; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const must = (label: string, result: ReturnType) => { assert.equal(result.status, 0, `${mode} ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); }; @@ -291,8 +307,7 @@ async function occupiedRestoreScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_occupied_restore"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); let occupied: ChildProcess | undefined; try { assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0); @@ -325,8 +340,7 @@ async function restoreReentryScenario( const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = `backup_reentry_${label}`; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); try { assert.equal(run("up", "--detach", ...(authenticated ? [] : ["--open"]), "--server", server, "--space", space).status, 0); @@ -400,8 +414,7 @@ async function ordinaryResumeReentryScenario(injection: "resume-commit" | "resum const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = `backup_ordinary_${injection}`; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); try { assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0); @@ -450,8 +463,7 @@ async function deadBoundListenerReplacementScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_dead_listener_replacement"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); try { assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0); @@ -497,8 +509,7 @@ async function unboundRestoreReentryScenario(detached: boolean): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = `backup_unbound_${label}`; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); const pidPath = join(root, ".cotal", "nats.pid"); let listenerPid: number | undefined; @@ -540,8 +551,7 @@ async function boundForeignListenerScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_bound_foreign"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); let foreign: ChildProcess | undefined; try { @@ -589,8 +599,7 @@ async function missingPidfileListenerScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_missing_pid_listener"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const pidPath = join(root, ".cotal", "nats.pid"); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); let foreign: ChildProcess | undefined; @@ -632,17 +641,18 @@ async function preservationCommitCrashScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_preserve_commit_crash"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run, env, sandbox } = sandboxRun(root, home); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); try { assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0); - const interrupted = spawnSync(tsx, [cliPath, "down", "--preserve-state"], { + const interruptOptions = { cwd: root, env: { ...env, COTAL_SMOKE_EXIT_AFTER_PRESERVATION_MANAGER_COMMIT: "1" }, - encoding: "utf8", + encoding: "utf8" as const, timeout: 240_000, - }); + }; + assertSmokeSandboxDown(sandbox, ["down", "--preserve-state"], interruptOptions); + const interrupted = spawnSync(tsx, [cliPath, "down", "--preserve-state"], interruptOptions); assert.equal(interrupted.status, 90); assert.equal((JSON.parse(readFileSync(journalPath, "utf8")) as { state: string }).state, "cut-committed"); const recovered = run("down", "--preserve-state"); @@ -667,13 +677,13 @@ async function preservationStopCrashRecoveryScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = `backup_preserve_stop_${suffix}`; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run, env, sandbox } = sandboxRun(root, home); const journalPath = join(root, ".cotal", "maintenance", "v1", "journal.json"); assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0, `${suffix} up`); const mgrPid = Number(readFileSync(join(root, ".cotal", "manager.pid"), "utf8").trim()); - const crashed = spawnSync(tsx, [cliPath, "down", "--preserve-state"], - { cwd: root, env: { ...env, [hook]: "1" }, encoding: "utf8", timeout: 240_000 }); + const crashOptions = { cwd: root, env: { ...env, [hook]: "1" }, encoding: "utf8" as const, timeout: 240_000 }; + assertSmokeSandboxDown(sandbox, ["down", "--preserve-state"], crashOptions); + const crashed = spawnSync(tsx, [cliPath, "down", "--preserve-state"], crashOptions); assert.equal((JSON.parse(readFileSync(journalPath, "utf8")) as { state: string }).state, "cut-intent", `${suffix} parked at cut-intent`); if (alive(mgrPid)) process.kill(mgrPid, "SIGKILL"); await waitUntil(() => !alive(mgrPid), `${suffix} manager dead`); @@ -718,8 +728,7 @@ async function restoreClaimRaceScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_claim_race"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); try { assert.equal(run("up", "--detach", "--open", "--server", server, "--space", space).status, 0); assert.equal(run("down", "--preserve-state").status, 0); @@ -783,8 +792,7 @@ async function backupRestoreCycleScenario(): Promise { const port = await freePort(); const server = `nats://127.0.0.1:${port}`; const space = "backup_cycle"; - const env = { ...process.env, COTAL_HOME: home }; - const run = (...args: string[]) => spawnSync(tsx, [cliPath, ...args], { cwd: root, env, encoding: "utf8", timeout: 240_000 }); + const { run } = sandboxRun(root, home); const must = (label: string, result: ReturnType) => { assert.equal(result.status, 0, `cycle ${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); }; diff --git a/bin/smoke/backup-usermode-live.smoke.ts b/bin/smoke/backup-usermode-live.smoke.ts index 3be67c872..4df27de9c 100644 --- a/bin/smoke/backup-usermode-live.smoke.ts +++ b/bin/smoke/backup-usermode-live.smoke.ts @@ -26,13 +26,18 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; const worktree = resolve(import.meta.dirname, "..", ".."); // COTAL_HOME must be sandboxed BEFORE any @cotal-ai module loads (module-level state reads it). const home = mkdtempSync(join(tmpdir(), "cotal-bum-home-")); +const configDir = join(home, "xdg"); process.env.COTAL_HOME = home; +process.env.XDG_CONFIG_HOME = configDir; const root = mkdtempSync(join(tmpdir(), "cotal-bum-root-")); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const childEnv = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; // better-auth is a dependency of implementations/auth, not of this root package, so it does not // resolve from bin/. Resolve it from the package that owns it rather than widen root deps. @@ -86,7 +91,9 @@ const SEEDED_TEXT = "seeded before the user-mode backup"; * process's event loop — and the in-process IdP with it — deadlocking every user-mode step. */ function cotal(args: string[], timeoutMs = 120_000): Promise<{ status: number | null; out: string }> { return new Promise((resolveRun) => { - const child = spawn(TSX, [BIN, ...args], { cwd: root, env: { ...process.env, COTAL_HOME: home } }); + const options = { cwd: root, env: childEnv }; + assertSmokeSandboxDown(sandbox, args, options); + const child = spawn(TSX, [BIN, ...args], options); let out = ""; child.stdout.on("data", (d: Buffer) => { out += d.toString(); }); child.stderr.on("data", (d: Buffer) => { out += d.toString(); }); diff --git a/bin/smoke/dogfood-live.smoke.ts b/bin/smoke/dogfood-live.smoke.ts index f8dfc4480..14a12d66e 100644 --- a/bin/smoke/dogfood-live.smoke.ts +++ b/bin/smoke/dogfood-live.smoke.ts @@ -21,6 +21,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, wri import { createServer, type AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { assertSmokeSandboxDown, assertSmokeSandboxTargetDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; // Ephemeral OS-assigned ports: no fixed-port collision across back-to-back / concurrent runs. const freePort = (): Promise => @@ -39,6 +40,7 @@ const configDir = join(sandbox, "xdg"); const home = join(sandbox, "home"); const root = join(sandbox, "proj"); for (const d of [configDir, home, root]) mkdirSync(d, { recursive: true }); +const sandboxAnchor = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); let pass = 0; const ok = (name: string, cond: boolean, extra?: unknown) => { @@ -51,8 +53,13 @@ const env = { ...process.env, XDG_CONFIG_HOME: configDir, COTAL_HOME: home }; const realNode = spawnSync("which", ["node"], { encoding: "utf8" }).stdout.trim(); const tsxCli = join(REPO, "node_modules", "tsx", "dist", "cli.mjs"); const binCotal = join(REPO, "bin", "cotal.ts"); -const cotalAt = (cwd: string, args: string[], timeout = 180_000) => - spawnSync(realNode, [tsxCli, binCotal, ...args], { encoding: "utf8", env, cwd, timeout }); +const cotalAt = (cwd: string, args: string[], timeout = 180_000) => { + const options = { encoding: "utf8" as const, env, cwd, timeout }; + if (args[0] === "down" && args[1] === "web") + assertSmokeSandboxTargetDown(sandboxAnchor, args, options); + else assertSmokeSandboxDown(sandboxAnchor, args, options); + return spawnSync(realNode, [tsxCli, binCotal, ...args], options); +}; const cotal = (args: string[], timeout = 180_000) => cotalAt(root, args, timeout); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const alive = (pid: number) => { @@ -140,7 +147,7 @@ try { ok("live /api/meta answers with the mesh's space and serving pid", meta.space === SPACE && meta.pid === foregroundPid && alive(foregroundPid), meta); const removeLive = cotal(["ext", "remove", "@cotal-ai/web"]); ok("ext remove refuses to orphan a running web process", removeLive.status === 1 && /cotal down web/.test(removeLive.stderr), removeLive.stderr.slice(-400)); - const webDown = cotal(["down", "web"]); + const webDown = cotal(["down", "web", "--space", SPACE]); if (webChild.exitCode === null) await Promise.race([new Promise((resolve) => webChild!.once("exit", () => resolve())), sleep(2_000)]); ok("down web stops the extension-owned process only", webDown.status === 0 && webChild.exitCode !== null, webDown.stdout + webDown.stderr); @@ -199,7 +206,7 @@ try { console.log(`\nDOGFOOD LIVE SMOKE OK ✅ (${pass} checks)`); } finally { webChild?.kill("SIGKILL"); - spawnSync(realNode, [tsxCli, binCotal, "down"], { encoding: "utf8", env, cwd: root }); + cotal(["down"]); for (const p of ownPids) if (alive(p)) { try { process.kill(p, "SIGTERM"); } catch { /* gone */ } } rmSync(sandbox, { recursive: true, force: true }); } diff --git a/bin/smoke/ext-live.smoke.ts b/bin/smoke/ext-live.smoke.ts index 5c17467db..c9f215019 100644 --- a/bin/smoke/ext-live.smoke.ts +++ b/bin/smoke/ext-live.smoke.ts @@ -22,6 +22,7 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; // macOS commonly exposes tmpdir() through /var -> /private/var (and this harness may add another // alias). The CLI canonicalizes cwd, so register the same physical root or extension removal sees @@ -33,6 +34,7 @@ mkdirSync(configDir, { recursive: true }); mkdirSync(home, { recursive: true }); // COTAL_HOME isolates only the registry; CLI project-root resolution still walks for `.cotal`. mkdirSync(join(sandbox, ".cotal"), { recursive: true }); +const sandboxAnchor = recordSmokeSandbox({ root: sandbox, cotalHome: home, xdgConfigHome: configDir }); let pass = 0; const ok = (name: string, cond: boolean, extra?: unknown) => { @@ -48,8 +50,11 @@ const env = { ...process.env, XDG_CONFIG_HOME: configDir, COTAL_HOME: home, COTA const realNode = spawnSync("which", ["node"], { encoding: "utf8" }).stdout.trim(); const tsxCli = resolve(import.meta.dirname, "..", "..", "node_modules", "tsx", "dist", "cli.mjs"); const binCotal = resolve(import.meta.dirname, "..", "cotal.ts"); -const cotal = (args: string[], timeout = 180_000) => - spawnSync(realNode, [tsxCli, binCotal, ...args], { encoding: "utf8", env, cwd: sandbox, timeout }); +const cotal = (args: string[], timeout = 180_000) => { + const options = { encoding: "utf8" as const, env, cwd: sandbox, timeout }; + assertSmokeSandboxDown(sandboxAnchor, args, options); + return spawnSync(realNode, [tsxCli, binCotal, ...args], options); +}; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const target = cotal(["meshes", "add", "main", "--server", "nats://127.0.0.1:1", "--root", sandbox, "--mode", "open", "--force"]); @@ -442,7 +447,9 @@ registry.register( for (let i = 0; i < 50 && !existsSync(slowReady); i++) await sleep(20); writeFileSync(join(sandbox, ".cotal", "manager.pid"), String(slowManagerPid)); writeFileSync(join(sandbox, ".cotal", "manager.delivery-aware"), String(slowManagerPid)); - const concurrentDown = spawn(realNode, [tsxCli, binCotal, "down", "manager"], { env, cwd: sandbox }); + const concurrentOptions = { env, cwd: sandbox }; + assertSmokeSandboxDown(sandboxAnchor, ["down", "manager"], concurrentOptions); + const concurrentDown = spawn(realNode, [tsxCli, binCotal, "down", "manager"], concurrentOptions); let concurrentOut = ""; let concurrentErr = ""; concurrentDown.stdout?.on("data", (data: Buffer) => (concurrentOut += data.toString())); diff --git a/bin/smoke/mutations/sandbox-guard.json b/bin/smoke/mutations/sandbox-guard.json new file mode 100644 index 000000000..2c922032d --- /dev/null +++ b/bin/smoke/mutations/sandbox-guard.json @@ -0,0 +1,92 @@ +{ + "suite": "packages/smoke-kit/smoke/sandbox-guard.smoke.ts", + "guard": "smoke teardown reaches only the recorded sandbox root, target-addressed down uses the same parsed space the CLI honors, and the guard runs before any environment-dependent live suite", + "command": "pnpm smoke:sandbox-guard", + "proveWith": "node scripts/mutation-proof.mjs --config bin/smoke/mutations/sandbox-guard.json", + "why": [ + "This guard protects destructive smoke teardown from resolving through ambient operator state.", + "The mutations cover the independent ways that guarantee can be lost: directory identity can be", + "ignored, argv positions can replace parser semantics, repeated space flags can stop being last-wins,", + "a teardown call site can bypass the shared guard, or the proof can be removed from the check gate or", + "moved behind its first environment-dependent live suite. The ownership marker is a separate", + "identity from the root directory, so its mutations prove that removing or replacing it is", + "independently refused.", + "", + "The empty-space mutation is the acceptance-boundary regression. The CLI treats an empty parsed space", + "as absent, so the guard must refuse it rather than verify the empty-space record and let the CLI act", + "on a different current mesh. Its expected failure names both argv cells because the separate empty", + "value and the repeated last-empty value reach the same parsed state through different routes." + ], + "mutations": [ + { + "name": "M1 directory identity comparison accepts every observed root", + "file": "packages/smoke-kit/src/sandbox-guard.ts", + "find": "observed.physicalPath === expected.physicalPath && observed.dev === expected.dev && observed.ino === expected.ino", + "replace": "true", + "expectRed": "foreign sandbox root is refused by identity" + }, + { + "name": "M2 target routing scans argv position instead of parsed positionals", + "file": "packages/smoke-kit/src/sandbox-guard.ts", + "find": " if (requested.includes(\"web\"))", + "replace": " if (args[1] === \"web\")", + "expectRed": "generic guard refuses flag-before-target down web" + }, + { + "name": "M3 target space scans the first separate flag instead of using parser last-wins semantics", + "file": "packages/smoke-kit/src/sandbox-guard.ts", + "find": " const space = parsed.values.space;", + "replace": " const space = (() => { const i = args.indexOf(\"--space\"); return i >= 0 ? args[i + 1] : undefined; })();", + "expectRed": "target guard uses the CLI parser's last space value" + }, + { + "name": "M4 dogfood finally teardown bypasses the shared sandbox guard", + "file": "bin/smoke/dogfood-live.smoke.ts", + "find": " cotal([\"down\"]);", + "replace": " spawnSync(realNode, [tsxCli, binCotal, \"down\"], { encoding: \"utf8\", env, cwd: root });", + "expectRed": "raw down spawn is not immediately guarded" + }, + { + "name": "M5 sandbox guard proof is removed from the check gate", + "file": "package.json", + "find": " && pnpm smoke:sandbox-guard", + "replace": "", + "expectRed": "check reaches smoke:sandbox-guard" + }, + { + "name": "M6 sandbox guard proof remains in check but moves behind the first live suite", + "file": "package.json", + "find": "pnpm smoke:core-boundary && pnpm smoke:sandbox-guard && pnpm smoke:spawn-from-anywhere && pnpm smoke:spawn-from-anywhere:live && pnpm smoke:connect", + "replace": "pnpm smoke:core-boundary && pnpm smoke:spawn-from-anywhere && pnpm smoke:spawn-from-anywhere:live && pnpm smoke:sandbox-guard && pnpm smoke:connect", + "expectRed": "check reaches smoke:sandbox-guard before its first environment-dependent live suite" + }, + { + "name": "M7 target guard accepts empty parsed space values that the CLI treats as absent", + "file": "packages/smoke-kit/src/sandbox-guard.ts", + "find": " if (typeof space !== \"string\" || space === \"\")", + "replace": " if (typeof space !== \"string\")", + "expectRed": "target guard refuses both empty-space argv forms" + }, + { + "name": "M8 sandbox ownership marker identity is ignored", + "file": "packages/smoke-kit/src/sandbox-guard.ts", + "find": " const markerHeld = markerIdentity === \"same\";", + "replace": " const markerHeld = true;", + "expectRed": "missing sandbox ownership marker is refused by identity" + }, + { + "name": "M9 foreign sandbox ownership marker identity is accepted", + "file": "packages/smoke-kit/src/sandbox-guard.ts", + "find": " const markerHeld = markerIdentity === \"same\";", + "replace": " const markerHeld = markerIdentity === \"same\" || markerIdentity === \"foreign\";", + "expectRed": "replaced sandbox ownership marker is refused as foreign" + }, + { + "name": "M10 target guard ignores the canonical document space field", + "file": "packages/smoke-kit/src/sandbox-guard.ts", + "find": " if (observed.space !== space) {", + "replace": " if (false && observed.space !== space) {", + "expectRed": "target guard refuses a canonical record whose space field is not the requested space" + } + ] +} diff --git a/bin/smoke/orca-extension-live.smoke.ts b/bin/smoke/orca-extension-live.smoke.ts index a6da1ce91..bbe24f01c 100644 --- a/bin/smoke/orca-extension-live.smoke.ts +++ b/bin/smoke/orca-extension-live.smoke.ts @@ -23,6 +23,7 @@ import { createConnection, createServer, type AddressInfo } from "node:net"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; interface OrcaTerminal { handle: string; @@ -62,6 +63,7 @@ const root = mkdtempSync(join(REPO, ".cotal-orca-e2e-")); const home = mkdtempSync(join(tmpdir(), "cotal-orca-e2e-home-")); const config = mkdtempSync(join(tmpdir(), "cotal-orca-e2e-config-")); mkdirSync(join(root, ".cotal"), { recursive: true }); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: config }); const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: config }; let pass = 0; @@ -71,13 +73,16 @@ const ok = (name: string, cond: boolean, extra?: unknown): void => { console.log(` ✓ ${name}`); }; const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); -const cli = (args: string[], timeout = 180_000) => - spawnSync(process.execPath, ["--import", TSX_IMPORT, CLI, ...args], { +const cli = (args: string[], timeout = 180_000) => { + const options = { cwd: root, env, - encoding: "utf8", + encoding: "utf8" as const, timeout, - }); + }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(process.execPath, ["--import", TSX_IMPORT, CLI, ...args], options); +}; const commandExists = (name: string): boolean => spawnSync(process.platform === "win32" ? "where" : "which", [name], { encoding: "utf8", env }).status === 0; const alive = (pid: number): boolean => { diff --git a/bin/smoke/up-stack-live.smoke.ts b/bin/smoke/up-stack-live.smoke.ts index 7b4d558ba..0e08b0e36 100644 --- a/bin/smoke/up-stack-live.smoke.ts +++ b/bin/smoke/up-stack-live.smoke.ts @@ -21,6 +21,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { renderDetachedSummary } from "../../implementations/cli/src/lib/up-report.js"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; // Ephemeral OS-assigned port: no fixed-port collision across back-to-back / concurrent runs. const freePort = (): Promise => @@ -40,7 +41,13 @@ const home = mkdtempSync(join(tmpdir(), "cotal-upstack-home-")); const root = mkdtempSync(join(tmpdir(), "cotal-upstack-root-")); const autoRoot = mkdtempSync(join(tmpdir(), "cotal-upstack-auto-")); const occupantRoot = mkdtempSync(join(tmpdir(), "cotal-upstack-occupant-")); -const env = { ...process.env, COTAL_HOME: home }; +const configDir = join(home, "xdg"); +const anchors = new Map([ + [root, recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir })], + [autoRoot, recordSmokeSandbox({ root: autoRoot, cotalHome: home, xdgConfigHome: configDir })], + [occupantRoot, recordSmokeSandbox({ root: occupantRoot, cotalHome: home, xdgConfigHome: configDir })], +]); +const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; let pass = 0; const ok = (name: string, cond: boolean, extra?: unknown) => { @@ -48,7 +55,12 @@ const ok = (name: string, cond: boolean, extra?: unknown) => { pass++; console.log(` ✓ ${name}`); }; -const cli = (...args: string[]) => spawnSync(TSX, [CLI, ...args], { cwd: root, env, encoding: "utf8", timeout: 120_000 }); +const cliIn = (cwd: string, ...args: string[]) => { + const options = { cwd, env, encoding: "utf8" as const, timeout: 120_000 }; + assertSmokeSandboxDown(anchors.get(cwd), args, options); + return spawnSync(TSX, [CLI, ...args], options); +}; +const cli = (...args: string[]) => cliIn(root, ...args); const plain = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, ""); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const alive = (pid: number) => { @@ -67,7 +79,6 @@ const portOpenAt = (port: number) => s.setTimeout(400, () => { s.destroy(); res(false); }); }); const portOpen = () => portOpenAt(PORT); -const cliIn = (cwd: string, ...args: string[]) => spawnSync(TSX, [CLI, ...args], { cwd, env, encoding: "utf8", timeout: 120_000 }); const pids: number[] = []; let startedOccupant = false; @@ -178,9 +189,9 @@ try { console.log(`\nUP-STACK LIVE SMOKE OK ✅ (${pass} checks)`); } finally { - spawnSync(TSX, [CLI, "down"], { cwd: root, env, encoding: "utf8" }); - spawnSync(TSX, [CLI, "down"], { cwd: autoRoot, env, encoding: "utf8" }); - if (startedOccupant) spawnSync(TSX, [CLI, "down"], { cwd: occupantRoot, env, encoding: "utf8" }); + cliIn(root, "down"); + cliIn(autoRoot, "down"); + if (startedOccupant) cliIn(occupantRoot, "down"); for (const p of pids) if (alive(p)) { try { process.kill(p, "SIGTERM"); } catch { /* gone */ } } rmSync(home, { recursive: true, force: true }); for (const d of [root, autoRoot, occupantRoot]) rmSync(d, { recursive: true, force: true }); diff --git a/bin/smoke/up-tls-routes-live.smoke.ts b/bin/smoke/up-tls-routes-live.smoke.ts index 023b828d9..95cd0bf42 100644 --- a/bin/smoke/up-tls-routes-live.smoke.ts +++ b/bin/smoke/up-tls-routes-live.smoke.ts @@ -54,6 +54,7 @@ import { join } from "node:path"; import net from "node:net"; import tls from "node:tls"; import { connect, credsAuthenticator } from "@nats-io/transport-node"; +import { assertSmokeSandboxDown, recordSmokeSandbox, type SmokeSandboxAnchor } from "@cotal-ai/smoke-kit"; const CLI = join(import.meta.dirname, "..", "cotal.ts"); @@ -131,7 +132,7 @@ let caFile = ""; interface Run { status: number | null; out: string } function cotal(args: string[], home: string, cwd: string, env: Record = {}): Run { - const r = spawnSync("npx", ["tsx", CLI, ...args], { + const options = { encoding: "utf8", cwd, timeout: 180_000, // `up` verifies the broker it just started with its OWN client, and `EndpointOptions.tls` is a // boolean that cannot carry a CA file — so against a private CA that verification fails and the @@ -139,8 +140,10 @@ function cotal(args: string[], home: string, cwd: string, env: Record(); +function rememberSandbox(home: string, port: number, cwd: string): void { + sandboxAnchors.set(cwd, recordSmokeSandbox({ root: cwd, cotalHome: home, xdgConfigHome: join(home, "xdg") })); + homes.push({ home, port, cwd }); +} /** The live delivery child's argv for a given broker port, or "" when there is none. Routes M and * S11 both gate on the FLAG the launcher passed, not on daemon readiness: a flagless daemon is * healthy-looking by construction, so readiness cannot distinguish it. @@ -339,7 +347,7 @@ async function main(): Promise { await route("--detach", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const r = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.cert, "--tls-key", pkiFiles.key], home, cwd); @@ -366,7 +374,7 @@ async function main(): Promise { await route("-f manifest", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); writeFileSync(join(cwd, "cotal.yaml"), `apiVersion: cotal/v1\nkind: Mesh\nspace: tlsmanifest\nbroker:\n servers: nats://127.0.0.1:${port}\n auth: false\nchannels:\n general:\n subscribe: []\n`); const r = cotal(["up", "-f", "cotal.yaml", "--tls-cert", pkiFiles.cert, "--tls-key", pkiFiles.key], home, cwd); @@ -387,7 +395,7 @@ async function main(): Promise { await route("refresh", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const first = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`], home, cwd); assert.equal(first.status, 0, `plaintext mesh must start for the refresh case:\n${first.out}`); @@ -408,7 +416,7 @@ async function main(): Promise { await route("expired-cert", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const r = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.expiredCert, "--tls-key", pkiFiles.expiredKey], home, cwd); assert.notEqual(r.status, 0, `an EXPIRED certificate must not yield a started mesh:\n${r.out}`); @@ -421,7 +429,7 @@ async function main(): Promise { await route("wrong-host", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const r = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.otherCert, "--tls-key", pkiFiles.otherKey], home, cwd); assert.notEqual(r.status, 0, `a certificate that does not cover the dial host must refuse:\n${r.out}`); @@ -438,7 +446,7 @@ async function main(): Promise { await route("authed", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); // No --open: auth is the default, so the CLI provisions the space and we never touch // `setupSpaceStreams` or the JS API. Both of those carry fixture traps that present as a @@ -513,7 +521,7 @@ async function main(): Promise { for (const command of ["web", "status"] as const) { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const up = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.cert, "--tls-key", pkiFiles.key], home, cwd); @@ -630,7 +638,7 @@ async function main(): Promise { await route("no-orphan-on-postfail", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); // NODE_EXTRA_CA_CERTS deliberately BLANK: this is the operator who forgot it. const r = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.cert, "--tls-key", pkiFiles.key], home, cwd, { NODE_EXTRA_CA_CERTS: "" }); @@ -670,8 +678,8 @@ async function main(): Promise { mkdirSync(home, { recursive: true }); const parentPort = await freePort(); const childPort = await freePort(); - homes.push({ home, port: parentPort, cwd: parent }); - homes.push({ home, port: childPort, cwd: child }); + rememberSandbox(home, parentPort, parent); + rememberSandbox(home, childPort, child); const parentUp = cotal(["up", "--detach", "--space", "parent-space", "--server", `nats://127.0.0.1:${parentPort}`], home, parent); @@ -750,8 +758,8 @@ async function main(): Promise { mkdirSync(home, { recursive: true }); const parentPort = await freePort(); const childPort = await freePort(); - homes.push({ home, port: parentPort, cwd: parent }); - homes.push({ home, port: childPort, cwd: child }); + rememberSandbox(home, parentPort, parent); + rememberSandbox(home, childPort, child); const parentUp = cotal(["up", "--detach", "--space", "parent-oa", "--server", `nats://127.0.0.1:${parentPort}`], home, parent); @@ -810,7 +818,7 @@ async function main(): Promise { await route("refresh-refuse-no-policy-write", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const plain = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`], home, cwd); assert.equal(plain.status, 0, `plaintext mesh must start:\n${plain.out}`); @@ -878,7 +886,7 @@ async function main(): Promise { await route("delivery-launches-with-tls", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const up = cotal(["up", "--detach", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.cert, "--tls-key", pkiFiles.key], home, cwd); assert.equal(up.status, 0, `TLS auth mesh must start:\n${up.out}`); @@ -909,7 +917,7 @@ async function main(): Promise { await route("delivery-refresh-keeps-tls", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const up = cotal(["up", "--detach", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.cert, "--tls-key", pkiFiles.key], home, cwd); assert.equal(up.status, 0, `TLS auth mesh must start:\n${up.out}`); @@ -958,7 +966,7 @@ async function main(): Promise { await route("s10-no-ca-keeps-registry", async () => { const { home, cwd } = sandbox(); const port = await freePort(); - homes.push({ home, port, cwd }); + rememberSandbox(home, port, cwd); const up = cotal(["up", "--detach", "--open", "--server", `nats://127.0.0.1:${port}`, "--tls-cert", pkiFiles.cert, "--tls-key", pkiFiles.key], home, cwd); assert.equal(up.status, 0, `TLS mesh must start:\n${up.out}`); diff --git a/extensions/connector-codex/smoke/codex-installed.smoke.ts b/extensions/connector-codex/smoke/codex-installed.smoke.ts index 18ae19db2..2ffc79c28 100644 --- a/extensions/connector-codex/smoke/codex-installed.smoke.ts +++ b/extensions/connector-codex/smoke/codex-installed.smoke.ts @@ -32,6 +32,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { CotalEndpoint, isReachable } from "@cotal-ai/core"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; if (process.platform === "win32") { // Managed Codex agents are POSIX-only by design (the isolated CODEX_HOME symlinks the @@ -85,6 +86,7 @@ const COTAL_HOME = join(dir, "cotalhome"); const ROOT = join(dir, "project"); // the spawn's cwd / workspace root const SHIM = join(dir, "shim"); // prepended to PATH; holds the fake `codex` for (const d of [HOME, CONFIG, COTAL_HOME, ROOT, SHIM, join(HOME, ".codex")]) mkdirSync(d, { recursive: true }); +const sandbox = recordSmokeSandbox({ root: ROOT, cotalHome: COTAL_HOME, xdgConfigHome: CONFIG }); // The launch symlinks the operator's `~/.codex/auth.json` into the per-agent CODEX_HOME and fails // loud without it. HOME is sandboxed, so plant one: this must be the smoke's own file and never @@ -202,7 +204,9 @@ let tornDown: { status: number; stdout: string; stderr: string } | undefined; async function teardown(): Promise<{ status: number; stdout: string; stderr: string }> { if (tornDown) return tornDown; if (!meshUp) return (tornDown = { status: 0, stdout: "", stderr: "" }); - const r = spawnSync("node", [CLI, "down"], { cwd: ROOT, env: cliEnv, encoding: "utf8" }); + const options = { cwd: ROOT, env: cliEnv, encoding: "utf8" as const }; + assertSmokeSandboxDown(sandbox, ["down"], options); + const r = spawnSync("node", [CLI, "down"], options); tornDown = { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }; for (let i = 0; i < 40 && (await portOpen(PORT)); i++) await sleep(250); return tornDown; diff --git a/implementations/auth/smoke/_ps-arm2.smoke.ts b/implementations/auth/smoke/_ps-arm2.smoke.ts index 084a9d696..e5329e988 100644 --- a/implementations/auth/smoke/_ps-arm2.smoke.ts +++ b/implementations/auth/smoke/_ps-arm2.smoke.ts @@ -28,6 +28,7 @@ import type { AddressInfo } from "node:net"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; import { betterAuth } from "better-auth"; import { memoryAdapter } from "better-auth/adapters/memory"; import { jwt } from "better-auth/plugins/jwt"; @@ -37,8 +38,12 @@ import { toNodeHandler } from "better-auth/node"; import { pickFreePort } from "./_free-port.js"; const home = mkdtempSync(join(tmpdir(), "cotal-downf-home-")); +const configDir = join(home, "xdg"); process.env.COTAL_HOME = home; +process.env.XDG_CONFIG_HOME = configDir; const root = mkdtempSync(join(tmpdir(), "cotal-downf-root-")); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const childEnv = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; const { establishIdpSession } = await import("../src/index.js"); type DeviceLoginPrompt = import("../src/index.js").DeviceLoginPrompt; @@ -58,7 +63,9 @@ const BIN = join(import.meta.dirname, "..", "..", "..", "bin", "cotal.ts"); function cotal(args: string[], opts: { timeoutMs?: number } = {}): Promise<{ status: number | null; out: string }> { return new Promise((resolvePromise) => { - const child = spawn("npx", ["tsx", BIN, ...args], { cwd: root, env: { ...process.env, COTAL_HOME: home } }); + const options = { cwd: root, env: childEnv }; + assertSmokeSandboxDown(sandbox, args, options); + const child = spawn("npx", ["tsx", BIN, ...args], options); let out = ""; child.stdout.on("data", (d: Buffer) => { out += d.toString(); }); child.stderr.on("data", (d: Buffer) => { out += d.toString(); }); diff --git a/implementations/auth/smoke/down-manifest-usermode.smoke.ts b/implementations/auth/smoke/down-manifest-usermode.smoke.ts index afeb305e7..5be3cfeb3 100644 --- a/implementations/auth/smoke/down-manifest-usermode.smoke.ts +++ b/implementations/auth/smoke/down-manifest-usermode.smoke.ts @@ -28,6 +28,7 @@ import type { AddressInfo } from "node:net"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; import { betterAuth } from "better-auth"; import { memoryAdapter } from "better-auth/adapters/memory"; import { jwt } from "better-auth/plugins/jwt"; @@ -37,8 +38,12 @@ import { toNodeHandler } from "better-auth/node"; import { pickFreePort } from "./_free-port.js"; const home = mkdtempSync(join(tmpdir(), "cotal-downf-home-")); +const configDir = join(home, "xdg"); process.env.COTAL_HOME = home; +process.env.XDG_CONFIG_HOME = configDir; const root = mkdtempSync(join(tmpdir(), "cotal-downf-root-")); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const childEnv = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; const { establishIdpSession } = await import("../src/index.js"); type DeviceLoginPrompt = import("../src/index.js").DeviceLoginPrompt; @@ -58,7 +63,9 @@ const BIN = join(import.meta.dirname, "..", "..", "..", "bin", "cotal.ts"); function cotal(args: string[], opts: { timeoutMs?: number } = {}): Promise<{ status: number | null; out: string }> { return new Promise((resolvePromise) => { - const child = spawn("npx", ["tsx", BIN, ...args], { cwd: root, env: { ...process.env, COTAL_HOME: home } }); + const options = { cwd: root, env: childEnv }; + assertSmokeSandboxDown(sandbox, args, options); + const child = spawn("npx", ["tsx", BIN, ...args], options); let out = ""; child.stdout.on("data", (d: Buffer) => { out += d.toString(); }); child.stderr.on("data", (d: Buffer) => { out += d.toString(); }); @@ -166,7 +173,7 @@ agents: await wait(13_000); let superviseOut = ""; superviseChild = spawn("npx", ["tsx", BIN, "supervise", "--space", SPACE, "--server", SERVER], { - cwd: root, env: { ...process.env, COTAL_HOME: home }, stdio: ["ignore", "pipe", "pipe"] }); + cwd: root, env: childEnv, stdio: ["ignore", "pipe", "pipe"] }); superviseChild.stdout!.on("data", (d: Buffer) => { superviseOut += d.toString(); }); superviseChild.stderr!.on("data", (d: Buffer) => { superviseOut += d.toString(); }); // Wait until the replacement holds the lease and answers ps (down -f needs controlOk). @@ -229,7 +236,7 @@ agents: symlinkSync(join(root, "decoy"), worker2Tok); await wait(13_000); // the crashed holder's lease again lingers to the bucket TTL superviseChild = spawn("npx", ["tsx", BIN, "supervise", "--space", SPACE, "--server", SERVER], { - cwd: root, env: { ...process.env, COTAL_HOME: home }, stdio: "ignore" }); + cwd: root, env: childEnv, stdio: "ignore" }); let psOk2 = false; for (let i = 0; i < 60 && !psOk2; i++) { await wait(1000); diff --git a/implementations/auth/smoke/ps-operator-path.smoke.ts b/implementations/auth/smoke/ps-operator-path.smoke.ts index 58ccbb847..35e581013 100644 --- a/implementations/auth/smoke/ps-operator-path.smoke.ts +++ b/implementations/auth/smoke/ps-operator-path.smoke.ts @@ -22,6 +22,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { pickFreePort } from "./_free-port.js"; import { assertScratchHeld, foreignRootFor, killManagerAtRoot, makeScratch } from "../../../bin/smoke/_scratch.js"; +import { assertSmokeSandboxDown, recordSmokeSandbox, type SmokeSandboxAnchor } from "@cotal-ai/smoke-kit"; // Same temp-root sandbox as the user-mode sibling, and for the same reason: `findCotalRoot` walks to // `/` unbounded, so a `.cotal` above `tmpdir()` sends this fixture's `manager.pid` into that @@ -34,14 +35,16 @@ const cleanScratch = (e: unknown): never => { rmSync(scratch, { recursive: true, force: true }); throw new Error(`fixture setup failed (scratch removed): ${(e as Error).message}`, { cause: e }); }; -let home!: string, root!: string, SERVER!: string; +let home!: string, root!: string, configDir!: string, SERVER!: string, sandbox!: SmokeSandboxAnchor; try { home = mkdtempSync(join(scratch, "home-")); + configDir = join(home, "xdg"); process.env.COTAL_HOME = home; + process.env.XDG_CONFIG_HOME = configDir; root = mkdtempSync(join(scratch, "root-")); // ANCHOR THE ROOT BEFORE ANY PRODUCT COMMAND RUNS: `findCotalRoot` stops at the first `.cotal` // from the directory itself, so owning one here pins every later resolution to this root. - mkdirSync(join(root, ".cotal"), { recursive: true }); + sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); SERVER = `nats://127.0.0.1:${await pickFreePort()}`; } catch (e) { cleanScratch(e); } const SPACE = `psstatic-${Math.floor(Math.random() * 1e6)}`; @@ -77,7 +80,9 @@ type Run = { }; function cotal(args: string[], timeoutMs = 120_000): Promise { return new Promise((res) => { - const child = spawn("npx", ["tsx", BIN, ...args], { cwd: root, env: { ...process.env, COTAL_HOME: home }, stdio: ["ignore", "pipe", "pipe"] }); + const options = { cwd: root, env: { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }, stdio: ["ignore", "pipe", "pipe"] as const }; + assertSmokeSandboxDown(sandbox, args, options); + const child = spawn("npx", ["tsx", BIN, ...args], options); let out = ""; let timedOut = false; let settled = false; diff --git a/implementations/auth/smoke/ps-user-mode.smoke.ts b/implementations/auth/smoke/ps-user-mode.smoke.ts index ce9c8fe49..8b8d506f2 100644 --- a/implementations/auth/smoke/ps-user-mode.smoke.ts +++ b/implementations/auth/smoke/ps-user-mode.smoke.ts @@ -32,6 +32,7 @@ import { bearer } from "better-auth/plugins/bearer"; import { toNodeHandler } from "better-auth/node"; import { pickFreePort } from "./_free-port.js"; import { assertScratchHeld, foreignRootFor, killManagerAtRoot, makeScratch } from "../../../bin/smoke/_scratch.js"; +import { assertSmokeSandboxDown, recordSmokeSandbox, type SmokeSandboxAnchor } from "@cotal-ai/smoke-kit"; // Sandbox the temp root BEFORE minting the fixture. `findCotalRoot` walks to `/` unbounded, so a // `.cotal` above `tmpdir()` makes `cotal up` write `manager.pid` into that ancestor. Step 4 then @@ -47,6 +48,8 @@ const scratch = makeScratch("cotal-psuser-"); // otherwise. One transaction or none. let home!: string; let root!: string; +let configDir!: string; +let sandbox!: SmokeSandboxAnchor; let establishIdpSession!: typeof import("../src/index.js").establishIdpSession; // Was `cotal up` ever INVOKED? Not "did it succeed" — a failed, timed-out or signalled `up` can still @@ -95,9 +98,11 @@ type Run = { }; function cotal(args: string[], timeoutMs = 120_000): Promise { return new Promise((res) => { - const child = spawn(TSX, [BIN, ...args], { - cwd: root, env: { ...process.env, COTAL_HOME: home }, stdio: ["ignore", "pipe", "pipe"], - }); + const options = { + cwd: root, env: { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }, stdio: ["ignore", "pipe", "pipe"] as const, + }; + assertSmokeSandboxDown(sandbox, args, options); + const child = spawn(TSX, [BIN, ...args], options); let out = ""; let timedOut = false; let settled = false; @@ -187,14 +192,16 @@ const buildIdpAuth = (idpOrigin: string) => betterAuth({ let ba!: ReturnType; try { home = mkdtempSync(join(scratch, "home-")); + configDir = join(home, "xdg"); process.env.COTAL_HOME = home; + process.env.XDG_CONFIG_HOME = configDir; root = mkdtempSync(join(scratch, "root-")); // ANCHOR THE ROOT BEFORE ANY PRODUCT COMMAND RUNS. `findCotalRoot` stops at the first `.cotal` // starting from the directory itself, so owning one here makes every later resolution from this // root land on this root - during `up`, during `ps`, and during `down` - no matter what appears // above it in between. Ownership then does not depend on the timing of any check, which is the // only way to close a race against a child that re-resolves cwd for itself. - mkdirSync(join(root, ".cotal"), { recursive: true }); + sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); ({ establishIdpSession } = await import("../src/index.js")); SERVER = `nats://127.0.0.1:${await pickFreePort()}`; diff --git a/implementations/auth/smoke/user-auth-launch.smoke.ts b/implementations/auth/smoke/user-auth-launch.smoke.ts index ab52bbe69..7906f0c52 100644 --- a/implementations/auth/smoke/user-auth-launch.smoke.ts +++ b/implementations/auth/smoke/user-auth-launch.smoke.ts @@ -29,6 +29,7 @@ import type { AddressInfo } from "node:net"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; import { betterAuth } from "better-auth"; import { memoryAdapter } from "better-auth/adapters/memory"; import { jwt } from "better-auth/plugins/jwt"; @@ -38,8 +39,12 @@ import { toNodeHandler } from "better-auth/node"; import { pickFreePort } from "./_free-port.js"; const home = mkdtempSync(join(tmpdir(), "cotal-ua-home-")); +const configDir = join(home, "xdg"); process.env.COTAL_HOME = home; +process.env.XDG_CONFIG_HOME = configDir; const root = mkdtempSync(join(tmpdir(), "cotal-ua-root-")); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const childEnv = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; const { connect, credsAuthenticator } = await import("@nats-io/transport-node"); const { chatSubject, isReachable, mintCreds, newIdentity } = await import("@cotal-ai/core"); @@ -72,10 +77,12 @@ const BIN = join(import.meta.dirname, "..", "..", "..", "bin", "cotal.ts"); * deadlocking any subprocess step that calls back into the IdP (the user-mode send does). */ function cotal(args: string[], opts: { cwd?: string; timeoutMs?: number } = {}): Promise<{ status: number | null; out: string }> { return new Promise((resolvePromise) => { - const child = spawn("npx", ["tsx", BIN, ...args], { + const options = { cwd: opts.cwd ?? root, - env: { ...process.env, COTAL_HOME: home }, - }); + env: childEnv, + }; + assertSmokeSandboxDown(sandbox, args, options); + const child = spawn("npx", ["tsx", BIN, ...args], options); let out = ""; child.stdout.on("data", (d: Buffer) => { out += d.toString(); }); child.stderr.on("data", (d: Buffer) => { out += d.toString(); }); diff --git a/implementations/cli/smoke/bind-fence-live.smoke.ts b/implementations/cli/smoke/bind-fence-live.smoke.ts index 08b296bc9..b2cccd4be 100644 --- a/implementations/cli/smoke/bind-fence-live.smoke.ts +++ b/implementations/cli/smoke/bind-fence-live.smoke.ts @@ -47,6 +47,7 @@ import { type EndpointReply, } from "../../../packages/core/src/index.js"; import { pickFreePort } from "../../../packages/core/smoke/_free-port.js"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; // EPHEMERAL, not a fixed port distinct from the other live smokes'. A fixed port is only safe while // no two live smokes share a runner, which is a property of how smoke:ci is sharded rather than of @@ -76,7 +77,9 @@ if (!/^nats:\/\/127\.0\.0\.1:\d+$/.test(SERVER)) throw new Error(`this fixture o const home = mkdtempSync(join(tmpdir(), "cotal-split-home-")); const root = mkdtempSync(join(tmpdir(), "cotal-split-root-")); -const env = { ...process.env, COTAL_HOME: home }; +const configDir = join(home, "xdg"); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; let pass = 0, fail = 0; const ok = (name: string, cond: boolean, extra?: unknown) => { @@ -97,7 +100,11 @@ process.on("exit", () => { process.exitCode = 1; } }); -const cli = (...args: string[]) => spawnSync(TSX, [CLI, ...args], { cwd: root, env, encoding: "utf8" }); +const cli = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(TSX, [CLI, ...args], options); +}; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const portOpen = (port: number) => new Promise((res) => { @@ -184,7 +191,7 @@ try { } finally { // Always bare `cotal down`, never pkill — and it must run even when a cell above threw, or the // fixture leaks a broker on the fixed port and the NEXT run silently grades a stale mesh. - cli("down", "--server", SERVER); + cli("down"); rmSync(home, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true }); } diff --git a/implementations/cli/smoke/manager-singleton-live.smoke.ts b/implementations/cli/smoke/manager-singleton-live.smoke.ts index 4b95cac7b..110e71819 100644 --- a/implementations/cli/smoke/manager-singleton-live.smoke.ts +++ b/implementations/cli/smoke/manager-singleton-live.smoke.ts @@ -14,6 +14,7 @@ import { createConnection } from "node:net"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; const PORT = 14322; // distinct from the other live smokes' fixed ports const SERVER = `nats://127.0.0.1:${PORT}`; @@ -24,7 +25,9 @@ const TSX = join(WT, "node_modules", ".bin", "tsx"); const home = mkdtempSync(join(tmpdir(), "cotal-singleton-home-")); const root = mkdtempSync(join(tmpdir(), "cotal-singleton-root-")); -const env = { ...process.env, COTAL_HOME: home }; +const configDir = join(home, "xdg"); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; let pass = 0; const ok = (name: string, cond: boolean, extra?: unknown) => { @@ -32,7 +35,11 @@ const ok = (name: string, cond: boolean, extra?: unknown) => { pass++; console.log(` ✓ ${name}`); }; -const cli = (...args: string[]) => spawnSync(TSX, [CLI, ...args], { cwd: root, env, encoding: "utf8" }); +const cli = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(TSX, [CLI, ...args], options); +}; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const portOpen = (port: number) => new Promise((res) => { diff --git a/implementations/cli/smoke/spawn-manifest-live.smoke.ts b/implementations/cli/smoke/spawn-manifest-live.smoke.ts index 82d2a4f65..52d184ef2 100644 --- a/implementations/cli/smoke/spawn-manifest-live.smoke.ts +++ b/implementations/cli/smoke/spawn-manifest-live.smoke.ts @@ -14,6 +14,7 @@ import { createConnection } from "node:net"; import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; const PORT = 14321; const SERVER = `nats://127.0.0.1:${PORT}`; @@ -25,12 +26,16 @@ const CLI = join(WT, "bin", "cotal.ts"); // silently no-ops (null stdout) there. `import.meta.resolve` gives tsx an absolute URL so it resolves // regardless of the spawn's cwd (the temp project root below). const TSX_IMPORT = import.meta.resolve("tsx"); -const nodeRun = (...args: string[]) => - spawnSync(process.execPath, ["--import", TSX_IMPORT, CLI, ...args], { cwd: root, env, encoding: "utf8" }); - const home = mkdtempSync(join(tmpdir(), "cotal-spawnf-home-")); const root = mkdtempSync(join(tmpdir(), "cotal-spawnf-root-")); -const env = { ...process.env, COTAL_HOME: home }; +const configDir = join(home, "xdg"); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; +const nodeRun = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(process.execPath, ["--import", TSX_IMPORT, CLI, ...args], options); +}; let pass = 0; const ok = (name: string, cond: boolean, extra?: unknown) => { diff --git a/implementations/cli/smoke/sys-rotation-e2e.smoke.ts b/implementations/cli/smoke/sys-rotation-e2e.smoke.ts index d078428b0..1562a3709 100644 --- a/implementations/cli/smoke/sys-rotation-e2e.smoke.ts +++ b/implementations/cli/smoke/sys-rotation-e2e.smoke.ts @@ -49,6 +49,7 @@ import { } from "@cotal-ai/core"; import { getSpaceAuth, MEMBERSHIP_RW_CREDS_KEY, putSpaceAuth, SYSTEM_CREDS_FILES, workspaceSecretStore } from "@cotal-ai/workspace"; import { pickFreePort } from "../../../packages/core/smoke/_free-port.js"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; let pass = 0, fail = 0; @@ -84,6 +85,8 @@ const RUN = randomUUID().slice(0, 8); const SPACE = `sysrote2e-${RUN}`; const HOME = mkdtempSync(join(tmpdir(), `cotal-sysrot-e2e-home-${RUN}-`)); const root = mkdtempSync(join(tmpdir(), `cotal-sysrot-e2e-${RUN}-`)); +const CONFIG = join(HOME, "xdg"); +const sandbox = recordSmokeSandbox({ root, cotalHome: HOME, xdgConfigHome: CONFIG }); const cotalPath = (f: string) => join(root, ".cotal", f); const obsPath = cotalPath(SYSTEM_CREDS_FILES[0]); const evPath = cotalPath(SYSTEM_CREDS_FILES[1]); @@ -95,12 +98,14 @@ const SERVERS = `nats://127.0.0.1:${PORT}`; /** One `cotal` subprocess, on the sandboxed home, from the provisioned root. */ function cotal(args: string[], timeout = 120_000): { code: number | null; out: string } { - const r = spawnSync(process.execPath, [cotalJs, ...args], { + const options = { cwd: root, - encoding: "utf8", + encoding: "utf8" as const, timeout, - env: { ...process.env, NO_COLOR: "1", COTAL_HOME: HOME, COTAL_SKIP_CONNECTOR_SEED: "1" }, - }); + env: { ...process.env, NO_COLOR: "1", COTAL_HOME: HOME, XDG_CONFIG_HOME: CONFIG, COTAL_SKIP_CONNECTOR_SEED: "1" }, + }; + assertSmokeSandboxDown(sandbox, args, options); + const r = spawnSync(process.execPath, [cotalJs, ...args], options); return { code: r.status, out: (r.stdout ?? "") + (r.stderr ?? "") }; } diff --git a/implementations/cli/smoke/up-manifest-live.smoke.ts b/implementations/cli/smoke/up-manifest-live.smoke.ts index 7775df3e2..70f8619c6 100644 --- a/implementations/cli/smoke/up-manifest-live.smoke.ts +++ b/implementations/cli/smoke/up-manifest-live.smoke.ts @@ -14,6 +14,7 @@ import { createConnection, createServer, type AddressInfo } from "node:net"; import { mkdtempSync, rmSync, writeFileSync, existsSync, statSync, readdirSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { assertSmokeSandboxDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; // Ephemeral free ports + a per-run space: repeated or concurrent runs never collide on a fixed port // nor contaminate each other's `supervise` scan (the old fixed 14311 / "upf-live" flaked locally). @@ -33,7 +34,9 @@ const TSX = join(WT, "node_modules", ".bin", "tsx"); const home = mkdtempSync(join(tmpdir(), "cotal-upf-home-")); const root = mkdtempSync(join(tmpdir(), "cotal-upf-root-")); -const env = { ...process.env, COTAL_HOME: home }; +const configDir = join(home, "xdg"); +const sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); +const env = { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }; let pass = 0; const ok = (name: string, cond: boolean, extra?: unknown) => { @@ -41,7 +44,11 @@ const ok = (name: string, cond: boolean, extra?: unknown) => { pass++; console.log(` ✓ ${name}`); }; -const cli = (...args: string[]) => spawnSync(TSX, [CLI, ...args], { cwd: root, env, encoding: "utf8" }); +const cli = (...args: string[]) => { + const options = { cwd: root, env, encoding: "utf8" as const }; + assertSmokeSandboxDown(sandbox, args, options); + return spawnSync(TSX, [CLI, ...args], options); +}; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const portOpen = (port: number) => new Promise((res) => { @@ -51,7 +58,7 @@ const portOpen = (port: number) => }); function down(): void { - spawnSync(TSX, [CLI, "down"], { cwd: root, env, encoding: "utf8" }); + cli("down"); } const alive = (pid: number) => { diff --git a/package.json b/package.json index ad8413fce..eaa87b5c9 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,12 @@ "mutation-coverage": "node scripts/mutation-coverage.mjs", "doc-binding": "node scripts/doc-binding.mjs", "smoke:doc-binding": "node scripts/doc-binding.mjs --self-test", + "smoke:sandbox-guard": "tsx packages/smoke-kit/smoke/sandbox-guard.smoke.ts", "mutation-proof:selftest": "node scripts/mutation-proof.selftest.mjs", "smoke:mutation-proof": "node scripts/mutation-proof.selftest.mjs", "check:docsbundle": "pnpm check:docs-voice && pnpm gen:tooldocs && pnpm gen:docsbundle && git diff --exit-code -- docs/mcp-tools.md extensions/connector-core/src/docs-bundle.generated.ts", "test": "pnpm -r --if-present test", - "check": "pnpm typecheck && pnpm check:docsbundle && pnpm test && pnpm smoke:docs && pnpm smoke:orientation && pnpm smoke:view && pnpm smoke:spawn-from-anywhere && pnpm smoke:spawn-from-anywhere:live && pnpm smoke:connect && pnpm smoke:core-boundary && pnpm smoke:preflight && pnpm smoke:update-concurrency && pnpm smoke:ci && pnpm smoke:spawn-detach:live && pnpm smoke:readiness:live && pnpm smoke:setup-pure:live && pnpm smoke:up-stack:live && pnpm smoke:up-manifest:live && pnpm smoke:join-external:live && pnpm smoke:ext:live && pnpm smoke:dogfood:live && pnpm smoke:freeslot-barrier:live && pnpm smoke:int2-revoke:live && pnpm smoke:backup-perms:live && pnpm smoke:backup-restore:live && pnpm smoke:backup-conservation:live && pnpm smoke:backup-usermode:live && pnpm smoke:backup-faults:live && pnpm smoke:down-manifest-usermode:live", + "check": "pnpm typecheck && pnpm check:docsbundle && pnpm test && pnpm smoke:docs && pnpm smoke:orientation && pnpm smoke:view && pnpm smoke:core-boundary && pnpm smoke:sandbox-guard && pnpm smoke:spawn-from-anywhere && pnpm smoke:spawn-from-anywhere:live && pnpm smoke:connect && pnpm smoke:preflight && pnpm smoke:update-concurrency && pnpm smoke:ci && pnpm smoke:spawn-detach:live && pnpm smoke:readiness:live && pnpm smoke:setup-pure:live && pnpm smoke:up-stack:live && pnpm smoke:up-manifest:live && pnpm smoke:join-external:live && pnpm smoke:ext:live && pnpm smoke:dogfood:live && pnpm smoke:freeslot-barrier:live && pnpm smoke:int2-revoke:live && pnpm smoke:backup-perms:live && pnpm smoke:backup-restore:live && pnpm smoke:backup-conservation:live && pnpm smoke:backup-usermode:live && pnpm smoke:backup-faults:live && pnpm smoke:down-manifest-usermode:live", "smoke:ci": "node bin/smoke/shard.mjs 0 1", "clean:dry": "git clean -ndX -- node_modules .pnpm-store packages extensions implementations examples remotion", "clean": "git clean -fdX -- node_modules .pnpm-store packages extensions implementations examples remotion", diff --git a/packages/core/smoke/presence-ttl-refresh-cli.smoke.ts b/packages/core/smoke/presence-ttl-refresh-cli.smoke.ts index 0b0ca0e8f..e1a88f2df 100644 --- a/packages/core/smoke/presence-ttl-refresh-cli.smoke.ts +++ b/packages/core/smoke/presence-ttl-refresh-cli.smoke.ts @@ -31,6 +31,7 @@ import { getSpaceAuth, workspaceSecretStore } from "../../workspace/src/index.js import { pickFreePort } from "./_free-port.js"; import { assertEphemeralBroker, scrubAmbientBrokerEnv } from "./_ephemeral-only.js"; import { foreignRootFor, killManagerAtRoot, makeScratch } from "../../../bin/smoke/_scratch.js"; +import { assertSmokeSandboxDown, recordSmokeSandbox, type SmokeSandboxAnchor } from "@cotal-ai/smoke-kit"; // FENCE LAYER 4, FIRST STATEMENT OF THE SUITE. This operator environment carries the LIVE broker in // COTAL_SERVERS (and live COTAL_CREDS / COTAL_SPACE). This suite spawns the real `cotal` binary, @@ -46,15 +47,17 @@ const cleanScratch = (e: unknown): never => { rmSync(scratch, { recursive: true, force: true }); throw new Error(`fixture setup failed (scratch removed): ${(e as Error).message}`, { cause: e }); }; -let home!: string, root!: string, SERVER!: string; +let home!: string, root!: string, configDir!: string, SERVER!: string, sandbox!: SmokeSandboxAnchor; try { home = mkdtempSync(join(scratch, "home-")); + configDir = join(home, "xdg"); process.env.COTAL_HOME = home; + process.env.XDG_CONFIG_HOME = configDir; root = mkdtempSync(join(scratch, "root-")); // Anchor BEFORE any product command: `findCotalRoot` walks to `/`, so without this the child // resolves to whatever ancestor owns a `.cotal` — under $HOME that is the live credential store, // under /tmp it is the one every unanchored tree on this box shares. - mkdirSync(join(root, ".cotal"), { recursive: true }); + sandbox = recordSmokeSandbox({ root, cotalHome: home, xdgConfigHome: configDir }); SERVER = `nats://127.0.0.1:${await pickFreePort()}`; } catch (e) { cleanScratch(e); } assertEphemeralBroker(SERVER); @@ -69,7 +72,9 @@ const check = (n: string, v: boolean, x?: unknown) => { v ? (pass++, console.log type Run = { status: number | null; out: string; timedOut: boolean; signal: NodeJS.Signals | null; launchError?: string }; function cotal(args: string[], timeoutMs = 120_000): Promise { return new Promise((res) => { - const child = spawn("npx", ["tsx", BIN, ...args], { cwd: root, env: { ...process.env, COTAL_HOME: home }, stdio: ["ignore", "pipe", "pipe"] }); + const options = { cwd: root, env: { ...process.env, COTAL_HOME: home, XDG_CONFIG_HOME: configDir }, stdio: ["ignore", "pipe", "pipe"] as const }; + assertSmokeSandboxDown(sandbox, args, options); + const child = spawn("npx", ["tsx", BIN, ...args], options); let out = "", timedOut = false, settled = false, exited = false; let status: number | null = null, signal: NodeJS.Signals | null = null, drain: NodeJS.Timeout | undefined; const done = (r: Run) => { if (settled) return; settled = true; clearTimeout(cmd); clearTimeout(drain); res(r); }; diff --git a/packages/smoke-kit/smoke/sandbox-guard.smoke.ts b/packages/smoke-kit/smoke/sandbox-guard.smoke.ts new file mode 100644 index 000000000..990917a2d --- /dev/null +++ b/packages/smoke-kit/smoke/sandbox-guard.smoke.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { assertSmokeSandboxDown, assertSmokeSandboxTargetDown, recordSmokeSandbox } from "@cotal-ai/smoke-kit"; + +const base = mkdtempSync(join(tmpdir(), "cotal-sandbox-guard-")); +const repo = join(import.meta.dirname, "..", "..", ".."); +const root = join(base, "root"); +const cotalHome = join(base, "home"); +const xdgConfigHome = join(base, "config"); +const anchor = recordSmokeSandbox({ root, cotalHome, xdgConfigHome }); +const env = { COTAL_HOME: cotalHome, XDG_CONFIG_HOME: xdgConfigHome }; +const failures: string[] = []; + +function refuses(name: string, run: () => void, expected: RegExp): void { + try { + assert.throws(run, expected); + console.log(`✓ ${name}`); + } catch (error) { + failures.push(name); + console.error(`✗ ${name}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function permits(name: string, run: () => void): void { + try { + assert.doesNotThrow(run); + console.log(`✓ ${name}`); + } catch (error) { + failures.push(name); + console.error(`✗ ${name}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +try { + assert.doesNotThrow(() => assertSmokeSandboxDown(anchor, ["down"], { cwd: root, env })); + const foreign = join(base, "operator-checkout"); + const foreignHome = join(base, "operator-home"); + const foreignConfig = join(base, "operator-config"); + mkdirSync(join(foreign, ".cotal"), { recursive: true }); + mkdirSync(foreignHome, { recursive: true }); + mkdirSync(foreignConfig, { recursive: true }); + refuses( + "foreign sandbox root is refused by identity", + () => assertSmokeSandboxDown(anchor, ["down"], { cwd: foreign, env }), + /observed root.*operator-checkout.*expected root.*root.*identity verdicts root=foreign/, + ); + assert.throws( + () => assertSmokeSandboxDown(anchor, ["down"], { cwd: root, env: { ...env, COTAL_HOME: foreignHome } }), + /COTAL_HOME.*operator-home.*identity verdicts root=same, COTAL_HOME=foreign/, + ); + assert.throws( + () => assertSmokeSandboxDown(anchor, ["down"], { cwd: root, env: { ...env, XDG_CONFIG_HOME: foreignConfig } }), + /XDG_CONFIG_HOME.*operator-config.*identity verdicts root=same, COTAL_HOME=same, XDG_CONFIG_HOME=foreign/, + ); + assert.throws( + () => assertSmokeSandboxDown(undefined, ["down"], { cwd: root, env }), + /expected root ""/, + ); + assert.throws( + () => assertSmokeSandboxDown(anchor, ["down"], { cwd: root }), + /COTAL_HOME "".*XDG_CONFIG_HOME ""/, + ); + assert.throws( + () => assertSmokeSandboxDown(anchor, ["down"], { cwd: root, env: process.env }), + /COTAL_HOME .* expected .*XDG_CONFIG_HOME .* expected/, + ); + const marker = join(root, ".cotal"); + const recordedMarker = join(root, ".cotal-recorded"); + renameSync(marker, recordedMarker); + refuses( + "missing sandbox ownership marker is refused by identity", + () => assertSmokeSandboxDown(anchor, ["down"], { cwd: root, env }), + /identity verdicts root=same, COTAL_HOME=same, XDG_CONFIG_HOME=same, marker=missing/, + ); + mkdirSync(marker); + refuses( + "replaced sandbox ownership marker is refused as foreign", + () => assertSmokeSandboxDown(anchor, ["down"], { cwd: root, env }), + /identity verdicts root=same, COTAL_HOME=same, XDG_CONFIG_HOME=same, marker=foreign/, + ); + rmSync(marker, { recursive: true }); + renameSync(recordedMarker, marker); + + const alias = join(base, "root-alias"); + symlinkSync(root, alias, "dir"); + assert.doesNotThrow(() => assertSmokeSandboxDown(anchor, ["down"], { cwd: alias, env })); + assert.doesNotThrow(() => assertSmokeSandboxDown(anchor, ["down"], { cwd: root + sep, env })); + // Scoped to issue 884's destructive `down` verb only. This is not a general blessing of foreign cwd. + assert.doesNotThrow(() => assertSmokeSandboxDown(anchor, ["status"], { cwd: foreign })); + + const space = "target"; + const meshes = join(cotalHome, "meshes"); + const meshFile = join(meshes, `space.${Buffer.from(space).toString("hex")}.json`); + mkdirSync(meshes, { recursive: true }); + writeFileSync(meshFile, JSON.stringify({ space, root })); + assert.doesNotThrow(() => + assertSmokeSandboxTargetDown(anchor, ["down", "web", "--space", space], { cwd: root, env })); + permits( + "target guard resolves a flag before the target", + () => assertSmokeSandboxTargetDown(anchor, ["down", "--space", space, "web"], { cwd: root, env }), + ); + permits( + "target guard resolves equals-form space", + () => assertSmokeSandboxTargetDown(anchor, ["down", "web", `--space=${space}`], { cwd: root, env }), + ); + const operatorSpace = "operator"; + const operatorMeshFile = join(meshes, `space.${Buffer.from(operatorSpace).toString("hex")}.json`); + writeFileSync(meshFile, JSON.stringify({ space, root: foreign })); + writeFileSync(operatorMeshFile, JSON.stringify({ space: operatorSpace, root })); + permits( + "target guard uses the CLI parser's last space value", + () => assertSmokeSandboxTargetDown( + anchor, + ["down", "web", "--space", space, "--space", operatorSpace], + { cwd: root, env }, + ), + ); + writeFileSync(meshFile, JSON.stringify({ space, root: foreign })); + assert.throws( + () => assertSmokeSandboxTargetDown(anchor, ["down", "web", "--space", space], { cwd: root, env }), + /target-addressed cotal down: observed root.*operator-checkout.*expected root.*root/, + ); + writeFileSync(meshFile, JSON.stringify({ space: "other", root })); + writeFileSync(join(meshes, "legacy-target.json"), JSON.stringify({ space, root: foreign })); + refuses( + "target guard refuses a canonical record whose space field is not the requested space", + () => assertSmokeSandboxTargetDown(anchor, ["down", "web", "--space", space], { cwd: root, env }), + /target-addressed cotal down: observed space.*other.*expected space.*target/, + ); + writeFileSync(meshFile, JSON.stringify({ space, root })); + rmSync(join(meshes, "legacy-target.json"), { force: true }); + assert.throws( + () => assertSmokeSandboxTargetDown(anchor, ["down", "web"], { cwd: root, env }), + /must name a non-empty --space explicitly/, + ); + const emptySpaceMeshFile = join(meshes, "space..json"); + writeFileSync(emptySpaceMeshFile, JSON.stringify({ space: "", root })); + refuses( + "target guard refuses a separate empty --space value", + () => assertSmokeSandboxTargetDown(anchor, ["down", "web", "--space", ""], { cwd: root, env }), + /must name a non-empty --space explicitly/, + ); + refuses( + "target guard refuses a repeated flag whose last space value is empty", + () => assertSmokeSandboxTargetDown(anchor, ["down", "web", "--space=operator", "--space="], { cwd: root, env }), + /must name a non-empty --space explicitly/, + ); + const emptySpaceCells = [ + "target guard refuses a separate empty --space value", + "target guard refuses a repeated flag whose last space value is empty", + ]; + const emptySpaceFailures = emptySpaceCells.filter((name) => failures.includes(name)); + if (emptySpaceFailures.length === 0) + console.log("✓ target guard refuses both empty-space argv forms"); + else { + failures.push("target guard refuses both empty-space argv forms"); + console.error(`✗ target guard refuses both empty-space argv forms: ${emptySpaceFailures.join(", ")}`); + } + assert.throws( + () => assertSmokeSandboxDown(anchor, ["down", "web"], { cwd: root, env }), + /requires assertSmokeSandboxTargetDown/, + "bare down web still requires the target guard", + ); + assert.throws( + () => assertSmokeSandboxDown(anchor, ["down", "--space", space, "web"], { cwd: root, env }), + /requires assertSmokeSandboxTargetDown/, + "generic guard refuses flag-before-target down web", + ); + assert.throws( + () => assertSmokeSandboxDown(anchor, ["down", "--unrecognized", "web"], { cwd: root, env }), + /cannot classify arguments.*strict down parser/, + "generic guard fails closed when down arguments cannot be classified", + ); + + const rootPackage = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")) as { + scripts?: { check?: string }; + }; + const checkSteps = (rootPackage.scripts?.check ?? "").split("&&").map((step) => step.trim()); + const guardStep = checkSteps.indexOf("pnpm smoke:sandbox-guard"); + const firstLiveStep = checkSteps.findIndex((step) => /pnpm smoke:[^ ]*(?::live|-live)(?:\s|$)/.test(step)); + assert.notEqual(guardStep, -1, "check reaches smoke:sandbox-guard"); + assert.ok( + firstLiveStep === -1 || guardStep < firstLiveStep, + "check reaches smoke:sandbox-guard before its first environment-dependent live suite", + ); + + const semanticDownOnly = new Set([ + "implementations/runtime/smoke/mesh-wait.smoke.ts", + "packages/lang/smoke/engine.smoke.ts", + ]); + const files: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name.endsWith(".smoke.ts")) files.push(path); + } + }; + walk(repo); + const unguarded: string[] = []; + for (const file of files) { + const relative = file.slice(repo.length + 1); + const source = readFileSync(file, "utf8"); + if (!source.includes('"down"') && !source.includes("'down'")) continue; + if (semanticDownOnly.has(relative)) continue; + const guardCalls = [...source.matchAll(/assertSmokeSandboxDown\s*\(/g)]; + if (guardCalls.length < 1) unguarded.push(`${relative}: no shared guard call`); + for (const match of source.matchAll(/^(.*(?:spawnSync|spawn)\(.*["']down["'].*)$/gm)) { + const before = source.slice(0, match.index).split("\n").slice(-5).join("\n"); + if (!before.includes("assertSmokeSandboxDown")) + unguarded.push(`${relative}:${source.slice(0, match.index).split("\n").length}: raw down spawn is not immediately guarded`); + } + } + assert.deepEqual(unguarded, [], `unguarded smoke cotal down call sites:\n${unguarded.join("\n")}`); + assert.deepEqual(failures, [], `sandbox guard failed named cells:\n${failures.join("\n")}`); + + console.log("sandbox guard smoke: PASS"); +} finally { + rmSync(base, { recursive: true, force: true }); +} diff --git a/packages/smoke-kit/src/index.ts b/packages/smoke-kit/src/index.ts index 47cebe68f..2f470d457 100644 --- a/packages/smoke-kit/src/index.ts +++ b/packages/smoke-kit/src/index.ts @@ -14,4 +14,11 @@ * can disagree with this one. */ export { SMOKE_BROKER_PREFIX, SMOKE_BROKER_TOKEN, killAndAwaitExit, teardownOnSignal } from "./broker-teardown.js"; +export { + assertSmokeSandboxDown, + assertSmokeSandboxTargetDown, + recordSmokeSandbox, + type SmokeCommandOptions, + type SmokeSandboxAnchor, +} from "./sandbox-guard.js"; export { memorySubjectFrontier, type MemorySubjectFrontier } from "./subject-frontier.js"; diff --git a/packages/smoke-kit/src/sandbox-guard.ts b/packages/smoke-kit/src/sandbox-guard.ts new file mode 100644 index 000000000..0347e9eb9 --- /dev/null +++ b/packages/smoke-kit/src/sandbox-guard.ts @@ -0,0 +1,224 @@ +import { mkdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { parseArgs, type ParseArgsOptionsConfig } from "node:util"; + +const smokeSandboxAnchor: unique symbol = Symbol("smokeSandboxAnchor"); + +interface RecordedDirectory { + readonly path: string; + readonly physicalPath: string; + readonly dev: bigint; + readonly ino: bigint; +} + +/** Exact sandbox identity captured before a smoke can invoke the CLI. */ +export interface SmokeSandboxAnchor { + readonly [smokeSandboxAnchor]: { + readonly root: RecordedDirectory; + readonly marker: RecordedDirectory; + readonly cotalHome: RecordedDirectory; + readonly xdgConfigHome: RecordedDirectory; + }; +} + +export interface SmokeCommandOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; +} + +const downOptions: ParseArgsOptionsConfig = { + file: { type: "string", short: "f" }, + run: { type: "string" }, + space: { type: "string" }, + "dry-run": { type: "boolean" }, + "preserve-state": { type: "boolean" }, + "store-dir": { type: "string" }, +}; + +interface ParsedSmokeDown { + readonly positionals: string[]; + readonly values: Record; +} + +function parseSmokeDown(args: readonly string[]): ParsedSmokeDown | undefined { + if (args[0] !== "down") return undefined; + try { + // Calling the same Node parser as the CLI's parseCommandArgs is necessary. Applying the same + // acceptance test makes the guard agree on which parsed value the CLI will honor. This is a + // safety lower bound: the guard may refuse more values, but must never accept one the consumer + // treats as absent. Do not replace this with argv scanning: flag order, `--space=value`, and + // repeated non-multiple flags are valid, and parseArgs deterministically applies last-wins. + const { positionals, values } = parseArgs({ + args: args.slice(1), + options: downOptions, + allowPositionals: true, + strict: true, + }); + return { positionals, values: values as ParsedSmokeDown["values"] }; + } catch (error) { + throw new Error( + `smoke sandbox refused cotal down: cannot classify arguments ${JSON.stringify(args)} with the strict down parser`, + { cause: error }, + ); + } +} + +function exactAbsolute(name: string, value: string | undefined): string { + if (!value || !isAbsolute(value)) + throw new Error(`smoke sandbox ${name} must be an exact absolute path, received ${JSON.stringify(value)}`); + return value; +} + +function directoryIdentity(name: string, path: string): RecordedDirectory { + let stat: ReturnType; + let physicalPath: string; + try { + stat = statSync(path, { bigint: true }); + physicalPath = realpathSync.native(path); + } catch (error) { + throw new Error(`cannot establish smoke sandbox ${name} identity at ${JSON.stringify(path)}`, { cause: error }); + } + if (!stat.isDirectory()) throw new Error(`smoke sandbox ${name} is not a directory: ${JSON.stringify(path)}`); + return Object.freeze({ path, physicalPath, dev: stat.dev, ino: stat.ino }); +} + +function sameDirectory(expected: RecordedDirectory, observedPath: string): "same" | "foreign" | "missing" { + try { + const observed = directoryIdentity("observed directory", observedPath); + return observed.physicalPath === expected.physicalPath && observed.dev === expected.dev && observed.ino === expected.ino + ? "same" + : "foreign"; + } catch { + return "missing"; + } +} + +/** + * Record the sandbox's concrete identity once, before any CLI invocation can resolve ambient state. + * The owned `.cotal` directory is load-bearing: it terminates `findCotalRoot` inside the scratch root + * instead of letting a bare `down` walk upward into an operator checkout. Later guards inspect only + * these exact recorded paths and identities. They never resolve a mesh target or search ancestors. + */ +export function recordSmokeSandbox(input: { + root: string; + cotalHome: string; + xdgConfigHome: string; +}): SmokeSandboxAnchor { + const root = exactAbsolute("root", input.root); + const cotalHome = exactAbsolute("COTAL_HOME", input.cotalHome); + const xdgConfigHome = exactAbsolute("XDG_CONFIG_HOME", input.xdgConfigHome); + const marker = join(root, ".cotal"); + mkdirSync(marker, { recursive: true }); + mkdirSync(cotalHome, { recursive: true }); + mkdirSync(xdgConfigHome, { recursive: true }); + return Object.freeze({ + [smokeSandboxAnchor]: Object.freeze({ + root: directoryIdentity("root", root), + marker: directoryIdentity("root ownership marker", marker), + cotalHome: directoryIdentity("COTAL_HOME", cotalHome), + xdgConfigHome: directoryIdentity("XDG_CONFIG_HOME", xdgConfigHome), + }), + }); +} + +function assertRecordedSandboxDown( + anchor: SmokeSandboxAnchor | undefined, + args: readonly string[], + options: SmokeCommandOptions, + parsed = parseSmokeDown(args), +): void { + if (!parsed) return; + if (!anchor) { + throw new Error( + `smoke sandbox refused cotal down: observed root ${JSON.stringify(options.cwd ?? "")}, ` + + `expected root ""`, + ); + } + + const expected = anchor[smokeSandboxAnchor]; + const observedRoot = options.cwd; + const cotalHome = options.env?.COTAL_HOME; + const xdgConfigHome = options.env?.XDG_CONFIG_HOME; + const rootIdentity = typeof observedRoot === "string" ? sameDirectory(expected.root, observedRoot) : "missing"; + const markerIdentity = sameDirectory(expected.marker, expected.marker.path); + const homeIdentity = cotalHome === expected.cotalHome.path ? sameDirectory(expected.cotalHome, cotalHome) : "foreign"; + const configIdentity = xdgConfigHome === expected.xdgConfigHome.path ? sameDirectory(expected.xdgConfigHome, xdgConfigHome) : "foreign"; + const rootMatches = rootIdentity === "same"; + const markerHeld = markerIdentity === "same"; + const homeMatches = homeIdentity === "same"; + const configMatches = configIdentity === "same"; + if (rootMatches && markerHeld && homeMatches && configMatches) return; + + throw new Error( + `smoke sandbox refused cotal down: observed root ${JSON.stringify(observedRoot ?? "")}, ` + + `expected root ${JSON.stringify(expected.root.path)}; ` + + `COTAL_HOME ${JSON.stringify(cotalHome ?? "")}, expected ${JSON.stringify(expected.cotalHome.path)}; ` + + `XDG_CONFIG_HOME ${JSON.stringify(xdgConfigHome ?? "")}, expected ${JSON.stringify(expected.xdgConfigHome.path)}; ` + + `identity verdicts root=${rootIdentity}, COTAL_HOME=${homeIdentity}, XDG_CONFIG_HOME=${configIdentity}, ` + + `marker=${markerHeld ? "held" : markerIdentity}`, + ); +} + +/** Refuse a folder-rooted destructive call unless its actual spawn options retain the sandbox. */ +export function assertSmokeSandboxDown( + anchor: SmokeSandboxAnchor | undefined, + args: readonly string[], + options: SmokeCommandOptions, +): void { + const parsed = parseSmokeDown(args); + if (!parsed) return; + const requested = [...new Set(parsed.positionals)]; + if (requested.includes("web")) + throw new Error("target-addressed `down web` requires assertSmokeSandboxTargetDown"); + assertRecordedSandboxDown(anchor, args, options, parsed); +} + +/** + * Guard a target-addressed component such as `down web`. The call must name its space explicitly so + * the CLI cannot select a different `current` entry. The guard reads that ONE canonical record from + * the already-anchored COTAL_HOME, requires that document's space field to be the requested space + * (the same key loadMeshes/findMesh honor), and compares its concrete root with the root recorded at + * sandbox construction. It never runs the mesh resolver, searches the registry, or consults ambient + * state. + */ +export function assertSmokeSandboxTargetDown( + anchor: SmokeSandboxAnchor | undefined, + args: readonly string[], + options: SmokeCommandOptions, +): void { + const parsed = parseSmokeDown(args); + if (!parsed) return; + assertRecordedSandboxDown(anchor, args, options, parsed); + const requested = [...new Set(parsed.positionals)]; + if (requested.length !== 1 || requested[0] !== "web") + throw new Error("smoke sandbox target guard requires exactly the target-addressed component `down web`"); + if (!anchor) throw new Error("smoke sandbox target guard requires a recorded anchor"); + const space = parsed.values.space; + if (typeof space !== "string" || space === "") + throw new Error("smoke sandbox target down must name a non-empty --space explicitly"); + + const expected = anchor[smokeSandboxAnchor]; + const key = Buffer.from(space, "utf8").toString("hex"); + const recordPath = join(expected.cotalHome.path, "meshes", `space.${key}.json`); + let observed: { root?: unknown; space?: unknown }; + try { + observed = JSON.parse(readFileSync(recordPath, "utf8")) as { root?: unknown; space?: unknown }; + } catch (error) { + throw new Error(`cannot establish smoke sandbox target identity from ${JSON.stringify(recordPath)}`, { cause: error }); + } + // Filename location is not the consumer's key. loadMeshes/findMesh select by the document's + // space field, so a canonical file whose document names another space must not satisfy the + // requested space: the CLI would then honor a different record (including a legacy file). + if (observed.space !== space) { + throw new Error( + `smoke sandbox refused target-addressed cotal down: observed space ${JSON.stringify(observed.space ?? "")}, ` + + `expected space ${JSON.stringify(space)}`, + ); + } + const observedRoot = observed.root; + if (typeof observedRoot === "string" && sameDirectory(expected.root, observedRoot) === "same") return; + throw new Error( + `smoke sandbox refused target-addressed cotal down: observed root ${JSON.stringify(observedRoot ?? "")}, ` + + `expected root ${JSON.stringify(expected.root.path)}`, + ); +}