From ee1b830e1fb0c37ac7a4b2fbc7cc01ef6d80e28a Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:05:50 +0000 Subject: [PATCH 1/6] fix(test): isolate tmux servers and guard default sockets --- .ai/skills/hack-repo-verify/SKILL.md | 11 ++ bunfig.toml | 3 + tests/cli-spawn-env-isolation.test.ts | 6 + tests/daemon-sessions.test.ts | 8 +- tests/e2e/harness.ts | 25 +++- tests/e2e/run.ts | 10 +- tests/helpers/private-cli-env.ts | 6 +- tests/helpers/private-tmux.ts | 54 +++++++++ tests/helpers/tmux-isolation-preload.ts | 9 ++ tests/helpers/tmux-socket-guard.ts | 106 +++++++++++++++++ tests/project-views.test.ts | 2 + tests/session-list-stop-tmux.test.ts | 6 +- tests/tmux-backend-locale.test.ts | 4 + tests/tmux-private-input.test.ts | 3 + tests/tmux-socket-guard.test.ts | 146 ++++++++++++++++++++++++ 15 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 tests/helpers/private-tmux.ts create mode 100644 tests/helpers/tmux-isolation-preload.ts create mode 100644 tests/helpers/tmux-socket-guard.ts create mode 100644 tests/tmux-socket-guard.test.ts diff --git a/.ai/skills/hack-repo-verify/SKILL.md b/.ai/skills/hack-repo-verify/SKILL.md index b8ca1ba9d..f96abe8e4 100644 --- a/.ai/skills/hack-repo-verify/SKILL.md +++ b/.ai/skills/hack-repo-verify/SKILL.md @@ -100,6 +100,17 @@ source provenance. Raw `bun test ` remains available for focused tests; it bypasses the suite preflight and does not prepare compiler-dependent fixtures. All test files share one Bun process. `bunfig.toml` preloads +private tmux isolation before the state guard: every run owns a short socket +directory and stops its server in `afterAll`, including assertion failures. +`privateCliEnv` forwards that socket root, never inherited `TMUX`. Direct +`Bun.spawn`/`spawnSync` tmux calls require a private 0700 directory registered +by the fixture with `registerPrivateTmuxRoot`; a separate socket fixture must +stop its own server before removing its root. The E2E scenario runner owns a +separate tmux scope and closes it in `finally`. This guard covers direct Bun +spawns, not arbitrary shell scripts or independently launched children: those +must receive the private socket environment explicitly. + +`bunfig.toml` also preloads [the global-state guard](../../../tests/helpers/global-state-guard-preload.ts) for every `bun test` run: a test or file that leaves the environment, cwd, umask, exit code, a guarded global function or a process listener changed fails with a diff --git a/bunfig.toml b/bunfig.toml index e1e144f3b..ccc164e95 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,12 +2,15 @@ root = "./tests" # preload: replaces Bun.secrets with an in-memory backend and disables the native # keychain. It runs first, so the state guard's baseline already has the flag. +# tmux-isolation: owns a private server, guards direct tmux spawns, and stops +# that server in afterAll, including when a test fails (HACK-2242). # global-state-guard: fails a test or file that leaves PATH, HOME, cwd or other # process state changed, and restores it so later files run clean (HACK-1327). # checkout-write-guard: fails the run when a test creates or changes a file in # the checkout (HACK-1343). preload = [ "./tests/preload.ts", + "./tests/helpers/tmux-isolation-preload.ts", "./tests/helpers/global-state-guard-preload.ts", "./tests/helpers/checkout-write-guard-preload.ts", ] diff --git a/tests/cli-spawn-env-isolation.test.ts b/tests/cli-spawn-env-isolation.test.ts index 3e979de5f..4f8c2d324 100644 --- a/tests/cli-spawn-env-isolation.test.ts +++ b/tests/cli-spawn-env-isolation.test.ts @@ -24,6 +24,11 @@ import { const ALLOWED: Readonly< Record > = { + "tests/helpers/tmux-socket-guard.ts": { + reason: + "tmux-only spawn forwarding pins the validated environment, not a source CLI invocation", + count: 1, + }, // `runCommand` runs git and docker for the real-engine E2E suite, not the // CLI. CLI invocations in this harness use `buildCliEnv`. "tests/e2e/harness.ts": { @@ -434,6 +439,7 @@ test("privateCliEnv keeps only the allow-list, test values and a private HOME", "HACK_HOME", "HOME", "PATH", + "TMUX_TMPDIR", PRIVATE_CLI_DAEMON_AUTOSTART_ENV, ].sort() ); diff --git a/tests/daemon-sessions.test.ts b/tests/daemon-sessions.test.ts index 4ab1b0dbb..12bb3f3a1 100644 --- a/tests/daemon-sessions.test.ts +++ b/tests/daemon-sessions.test.ts @@ -595,8 +595,8 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { }); const url = new URL(req.url); const result = await handleSessionRoutes({ req, url }); - // May fail if tmux isn't available, but should not fail on validation - expect(result?.status).not.toBe(400); + // The test preload owns a private tmux server and stops it in afterAll. + expect(result?.status).toBe(201); }); test("accepts names with dashes", async () => { @@ -607,7 +607,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { }); const url = new URL(req.url); const result = await handleSessionRoutes({ req, url }); - expect(result?.status).not.toBe(400); + expect(result?.status).toBe(201); }); test("accepts names with underscores", async () => { @@ -618,7 +618,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { }); const url = new URL(req.url); const result = await handleSessionRoutes({ req, url }); - expect(result?.status).not.toBe(400); + expect(result?.status).toBe(201); }); test("rejects names with dots", async () => { diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 42de6b9ea..7877e515e 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -27,6 +27,7 @@ import { LAUNCHD_DISABLE_ENV, } from "../../src/daemon/launchd.ts"; import { fixtureGitEnv } from "../helpers/fixture-git.ts"; +import { privateTmux } from "../helpers/private-tmux.ts"; import { type ComposeResource, @@ -74,6 +75,7 @@ const PASSTHROUGH_ENV_KEYS = [ "PATH", "HOME", "TMPDIR", + "TMUX_TMPDIR", "USER", "SHELL", "LANG", @@ -1010,6 +1012,17 @@ function newNames(opts: { */ export async function runScenarios( opts: RunScenariosOptions +): Promise { + const mux = privateTmux(); + try { + return await runIsolatedScenarios(opts); + } finally { + mux.close(); + } +} + +async function runIsolatedScenarios( + opts: RunScenariosOptions ): Promise { const outcomes: ScenarioOutcome[] = []; const selected = selectScenarios(opts); @@ -1090,8 +1103,7 @@ export async function runScenarios( durationMs: Date.now() - startedAt, reason: error instanceof Error ? error.message : String(error), }; - preserveFailedFixture = - outcome.status === "fail" && scenario.preserveFixtureOnFailure === true; + preserveFailedFixture = shouldPreserveFailedFixture(outcome, scenario); } finally { await cleanupScenarioRoots({ keep: opts.keepTempDirs === true || preserveFailedFixture, @@ -1117,6 +1129,15 @@ export async function runScenarios( return outcomes; } +function shouldPreserveFailedFixture( + outcome: ScenarioOutcome, + scenario: Scenario +): boolean { + return ( + outcome.status === "fail" && scenario.preserveFixtureOnFailure === true + ); +} + async function applyDockerLeakCheck(opts: { readonly outcome: ScenarioOutcome; readonly docker: DockerRunner; diff --git a/tests/e2e/run.ts b/tests/e2e/run.ts index 2f170054a..a40d09d7a 100644 --- a/tests/e2e/run.ts +++ b/tests/e2e/run.ts @@ -1,3 +1,5 @@ +import { privateTmux } from "../helpers/private-tmux.ts"; +import { installTmuxSocketGuard } from "../helpers/tmux-socket-guard.ts"; import { describeResources, listFixtureNetworks, @@ -271,7 +273,13 @@ async function main(): Promise { path: launchAgentPath, }); try { - await runSuite({ args, shardNames }); + installTmuxSocketGuard(); + const mux = privateTmux(); + try { + await runSuite({ args, shardNames }); + } finally { + mux.close(); + } } finally { const change = describeLaunchAgentChange({ before: launchAgentBefore, diff --git a/tests/helpers/private-cli-env.ts b/tests/helpers/private-cli-env.ts index 8d9023f66..8d527a1fc 100644 --- a/tests/helpers/private-cli-env.ts +++ b/tests/helpers/private-cli-env.ts @@ -10,7 +10,11 @@ import { requireFixtureRoot } from "./fixture-root.ts"; * `CI`, credentials and shell state. A harness passes what its test needs * through `env` instead. */ -export const PRIVATE_CLI_INHERITED_ENV = ["PATH", "TMPDIR"] as const; +export const PRIVATE_CLI_INHERITED_ENV = [ + "PATH", + "TMPDIR", + "TMUX_TMPDIR", +] as const; /** * Set to `1` on every private child unless the test passes its own value in diff --git a/tests/helpers/private-tmux.ts b/tests/helpers/private-tmux.ts new file mode 100644 index 000000000..c1de0329c --- /dev/null +++ b/tests/helpers/private-tmux.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { restoreEnv } from "./env.ts"; +import { registerPrivateTmuxRoot } from "./tmux-socket-guard.ts"; + +/** Own one short socket root, and always stop its server before removing it. */ +export function privateTmux(opts: { restoreEnvironment?: boolean } = {}): { + close(): void; +} { + const root = mkdtempSync("/tmp/hack-test-mux-"); + const unregister = registerPrivateTmuxRoot(root); + const savedRoot = process.env.TMUX_TMPDIR; + const savedTmux = process.env.TMUX; + const tmux = Bun.which("tmux"); + let closed = false; + process.env.TMUX_TMPDIR = root; + Reflect.deleteProperty(process.env, "TMUX"); + return { + close() { + if (closed) { + return; + } + let stopped = tmux === null; + try { + if (tmux) { + // Explicit environment cannot be redirected by a failing test. + const result = Bun.spawnSync([tmux, "kill-server"], { + env: { PATH: process.env.PATH ?? "", TMUX_TMPDIR: root }, + stdout: "ignore", + stderr: "ignore", + timeout: 5000, + }); + // 1 means there was no server. A timeout/signal is not cleanup: + // retain the root so a live server's socket is not unlinked. + stopped = [0, 1].includes(result.exitCode) && !result.signalCode; + if (!stopped) { + throw new Error( + "Private test tmux server did not stop; retained its socket root." + ); + } + } + } finally { + if (opts.restoreEnvironment !== false) { + restoreEnv("TMUX_TMPDIR", savedRoot); + restoreEnv("TMUX", savedTmux); + } + if (stopped) { + unregister(); + rmSync(root, { recursive: true, force: true }); + closed = true; + } + } + }, + }; +} diff --git a/tests/helpers/tmux-isolation-preload.ts b/tests/helpers/tmux-isolation-preload.ts new file mode 100644 index 000000000..83ce68324 --- /dev/null +++ b/tests/helpers/tmux-isolation-preload.ts @@ -0,0 +1,9 @@ +import { afterAll } from "bun:test"; +import { privateTmux } from "./private-tmux.ts"; +import { installTmuxSocketGuard } from "./tmux-socket-guard.ts"; + +// Before the global-state baseline. This covers in-process mux calls and +// children using privateCliEnv, including daemon route validation tests. +installTmuxSocketGuard(); +const mux = privateTmux({ restoreEnvironment: false }); +afterAll(() => mux.close()); diff --git a/tests/helpers/tmux-socket-guard.ts b/tests/helpers/tmux-socket-guard.ts new file mode 100644 index 000000000..1e2b8e570 --- /dev/null +++ b/tests/helpers/tmux-socket-guard.ts @@ -0,0 +1,106 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute } from "node:path"; + +const privateRoots = new Set(); +let installed = false; + +/** Parse only client options, never session names or keys after the command. */ +function explicitSocket(command: readonly string[]): string | undefined { + let socket: string | undefined; + for (let index = 1; index < command.length; index++) { + const argument = command[index] ?? ""; + if (!argument.startsWith("-")) { + break; + } + if (["-u", "-2", "-8", "-C", "-D", "-N", "-v", "-V"].includes(argument)) { + continue; + } + if (["-S", "-L", "-f"].includes(argument)) { + const value = command[++index]; + if ( + !value || + (argument === "-S" && (socket !== undefined || !isAbsolute(value))) + ) { + throw new Error( + "[tmux-socket-guard] invalid or repeated socket option." + ); + } + if (argument === "-S") { + socket = value; + } + continue; + } + throw new Error( + "[tmux-socket-guard] unsupported client option; use separate explicit options." + ); + } + return socket; +} + +/** Register only a directory the calling fixture just created and owns. */ +export function registerPrivateTmuxRoot(root: string): () => void { + const canonical = realpathSync(root); + privateRoots.add(canonical); + return () => privateRoots.delete(canonical); +} + +/** Refuse default-socket access before a tmux client is started by a test. */ +export function assertPrivateTmuxSocket( + command: readonly string[], + env: Readonly> +): void { + if (basename(command[0] ?? "") !== "tmux") { + return; + } + const socket = explicitSocket(command); + const root = socket ? dirname(socket) : env.TMUX_TMPDIR; + if ((!socket && env.TMUX) || !root || !isAbsolute(root)) { + throw new Error( + "[tmux-socket-guard] tmux requires a private socket directory; inherited TMUX is refused." + ); + } + const info = lstatSync(root); + if ( + !(privateRoots.has(realpathSync(root)) && info.isDirectory()) || + (info.mode & 0o077) !== 0 || + info.uid !== process.getuid?.() + ) { + throw new Error( + "[tmux-socket-guard] socket directory must be registered by its fixture, owned by this user and private (0700)." + ); + } +} + +/** Install before the global-state baseline; cover both Bun spawn overloads. */ +export function installTmuxSocketGuard(): void { + if (installed) { + return; + } + installed = true; + for (const key of ["spawn", "spawnSync"] as const) { + const original = Bun[key]; + const guarded = function (this: unknown, ...args: unknown[]) { + const first = args[0]; + const options = Array.isArray(first) ? args[1] : first; + const record = options as + | { cmd?: string[]; env?: Record } + | undefined; + const command = Array.isArray(first) ? first : record?.cmd; + if (command && basename(command[0] ?? "") === "tmux") { + // Without explicit env Bun can use its startup snapshot. Validate + // exactly what the child will receive, including after a preload + // replaced inherited TMUX/TMUX_TMPDIR. + const env = record?.env ?? { ...process.env }; + assertPrivateTmuxSocket(command, env); + const optionsWithEnv = { ...record, env }; + const forwarded = Array.isArray(first) + ? [first, optionsWithEnv] + : [optionsWithEnv]; + return Reflect.apply(original, this, forwarded); + } + return Reflect.apply(original, this, args); + }; + // Bun's overloaded generic spawn signatures cannot express this forwarding wrapper. + Object.assign(Bun, { [key]: guarded }); + } +} diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index dcd5f620d..eaec104eb 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -748,6 +748,7 @@ test("project views accept an unambiguous legacy alias without rewriting runtime runtimeOk: true, filter: "my-app", includeUnregistered: true, + muxSessions: [], }); expect(views).toHaveLength(1); expect(views[0]?.name).toBe("my_app"); @@ -760,6 +761,7 @@ test("project views accept an unambiguous legacy alias without rewriting runtime runtimeOk: true, filter: "my_app", includeUnregistered: true, + muxSessions: [], }) ).rejects.toThrow("Ambiguous project name"); }); diff --git a/tests/session-list-stop-tmux.test.ts b/tests/session-list-stop-tmux.test.ts index 227424df0..c763c374b 100644 --- a/tests/session-list-stop-tmux.test.ts +++ b/tests/session-list-stop-tmux.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { registerPrivateTmuxRoot } from "./helpers/tmux-socket-guard.ts"; /** * HACK-1798: `hack session list` printed lifecycle sessions as one string of @@ -16,6 +17,7 @@ const workspace = "hack-1798-fixture-project--lifecycle"; // stop touches only the session it named. const sibling = `${workspace}-sibling`; let root = ""; +let unregister = () => {}; function isolatedEnv(): Record { return { @@ -57,7 +59,8 @@ beforeAll(async () => { // Unix socket paths are length-limited, so keep the root short. root = await mkdtemp("/tmp/hack-1798-"); await mkdir(join(root, "home")); - await mkdir(join(root, "tmux")); + await mkdir(join(root, "tmux"), { mode: 0o700 }); + unregister = registerPrivateTmuxRoot(join(root, "tmux")); await mkdir(join(root, "outside")); }); @@ -68,6 +71,7 @@ afterAll(async () => { if (root) { await rm(root, { recursive: true, force: true }); } + unregister(); }); test.skipIf(!tmuxPath)( diff --git a/tests/tmux-backend-locale.test.ts b/tests/tmux-backend-locale.test.ts index 55ce77092..7611e230e 100644 --- a/tests/tmux-backend-locale.test.ts +++ b/tests/tmux-backend-locale.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { resolve } from "node:path"; +import { registerPrivateTmuxRoot } from "./helpers/tmux-socket-guard.ts"; /** * HACK-1797: without a UTF-8 locale, tmux replaces the tab separators in @@ -13,6 +14,7 @@ const tmuxPath = Bun.which("tmux"); const backendModule = resolve(import.meta.dir, "../src/mux/tmux-backend.ts"); const sessionName = "hack-locale-probe--lifecycle"; let socketDir = ""; +let unregister = () => {}; function isolatedEnv(): Record { return { @@ -27,6 +29,7 @@ function isolatedEnv(): Record { beforeAll(async () => { // Unix socket paths are length-limited, so keep the directory short. socketDir = await mkdtemp("/tmp/hack-tmux-"); + unregister = registerPrivateTmuxRoot(socketDir); }); afterAll(async () => { @@ -40,6 +43,7 @@ afterAll(async () => { if (socketDir) { await rm(socketDir, { recursive: true, force: true }); } + unregister(); }); test.skipIf(!tmuxPath)( diff --git a/tests/tmux-private-input.test.ts b/tests/tmux-private-input.test.ts index ae6fd22d2..c59bc1bf7 100644 --- a/tests/tmux-private-input.test.ts +++ b/tests/tmux-private-input.test.ts @@ -7,12 +7,14 @@ import type { ExecOptions } from "../src/lib/shell.ts"; import { findExecutableInPath } from "../src/lib/shell.ts"; import { isolateMuxTempDir } from "./helpers/mux-temp.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; +import { registerPrivateTmuxRoot } from "./helpers/tmux-socket-guard.ts"; // Staging sweeps the temp directory: never the developer's real one. isolateMuxTempDir(); const tmux = findExecutableInPath("tmux"); const root = await mkdtemp(join(tmpdir(), "tmux-private-test-")); +const unregister = registerPrivateTmuxRoot(root); const socket = join(root, "socket"); const calls: (readonly string[])[] = []; async function privateExec( @@ -57,6 +59,7 @@ afterAll(async () => { await privateExec([tmux, "-S", socket, "kill-server"], { stdin: "ignore" }); } await rm(root, { recursive: true, force: true }); + unregister(); }); test.skipIf(!tmux)( diff --git a/tests/tmux-socket-guard.test.ts b/tests/tmux-socket-guard.test.ts new file mode 100644 index 000000000..0b3aa4c43 --- /dev/null +++ b/tests/tmux-socket-guard.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { + assertPrivateTmuxSocket, + registerPrivateTmuxRoot, +} from "./helpers/tmux-socket-guard.ts"; + +test("both spawn forms refuse tmux without a private socket before executing", () => { + const root = mkdtempSync("/tmp/hack-mux-refused-"); + try { + // If the guard regresses, this harmless stand-in runs, never real tmux. + const tmux = join(root, "tmux"); + writeFileSync(tmux, "#!/bin/sh\nexit 91\n", { mode: 0o755 }); + expect(() => Bun.spawn([tmux, "list-sessions"], { env: {} })).toThrow( + "tmux-socket-guard" + ); + expect(() => + Bun.spawnSync({ cmd: [tmux, "list-sessions"], env: {} }) + ).toThrow("tmux-socket-guard"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(() => + assertPrivateTmuxSocket(["tmux", "list-sessions"], { + TMUX: "/user/default,1,0", + TMUX_TMPDIR: process.env.TMUX_TMPDIR, + }) + ).toThrow("inherited TMUX"); +}); + +test("tmux socket guard accepts private roots and rejects shared directories", () => { + const root = mkdtempSync("/tmp/hack-socket-guard-"); + const unregister = registerPrivateTmuxRoot(root); + try { + expect(() => + assertPrivateTmuxSocket(["tmux", "list-sessions"], { TMUX_TMPDIR: root }) + ).not.toThrow(); + for (const args of [ + ["tmux", "-S/tmp/default", "list-sessions"], + ["tmux", "-uS/tmp/default", "list-sessions"], + [ + "tmux", + "-S", + join(root, "socket"), + "-S", + "/tmp/default", + "list-sessions", + ], + ]) { + expect(() => + assertPrivateTmuxSocket(args, { TMUX_TMPDIR: root }) + ).toThrow("tmux-socket-guard"); + } + expect(() => + assertPrivateTmuxSocket( + ["tmux", "-S", join(root, "socket"), "list-sessions"], + {} + ) + ).not.toThrow(); + chmodSync(root, 0o755); + expect(() => + assertPrivateTmuxSocket(["tmux", "list-sessions"], { TMUX_TMPDIR: root }) + ).toThrow("0700"); + chmodSync(root, 0o700); + unregister(); + expect(() => + assertPrivateTmuxSocket( + ["tmux", "-S", join(root, "default"), "list-sessions"], + {} + ) + ).toThrow("registered"); + } finally { + unregister(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test.skipIf(!Bun.which("tmux"))( + "preload stops its private tmux server after a failing test", + async () => { + const root = mkdtempSync("/tmp/hack-mux-failure-"); + try { + const receipt = join(root, "socket-root"); + const fixture = join(root, "failure.test.ts"); + writeFileSync( + fixture, + ` + import { test, expect } from "bun:test"; + import { writeFileSync } from "node:fs"; + test("deliberate failure after starting a session", () => { + const child = Bun.spawnSync(["tmux", "new-session", "-d", "-s", "owned-test", "sleep 60"]); + expect(child.exitCode).toBe(0); + const pid = Bun.spawnSync(["tmux", "display-message", "-p", "#{pid}"]); + expect(pid.exitCode).toBe(0); + writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({ root: process.env.TMUX_TMPDIR, pid: Number(pid.stdout.toString().trim()) })); + throw new Error("expected fixture failure"); + }); + ` + ); + const child = Bun.spawnSync([process.execPath, "test", fixture], { + cwd: resolve(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + }); + expect(child.exitCode).toBe(1); + expect(child.stderr.toString()).toContain("expected fixture failure"); + const owned = JSON.parse(readFileSync(receipt, "utf8")) as { + root: string; + pid: number; + }; + expect(existsSync(owned.root)).toBe(false); + // kill-server acknowledges before tmux has finished exiting. Bound the + // observation; a defunct process is no live server, even before reaping. + let stopped = false; + for (let attempt = 0; attempt < 40; attempt++) { + const server = Bun.spawnSync( + ["ps", "-p", String(owned.pid), "-o", "stat="], + { + stdout: "pipe", + stderr: "pipe", + timeout: 1000, + } + ); + stopped = + server.exitCode === 1 || + server.stdout.toString().trim().startsWith("Z"); + if (stopped) { + break; + } + await Bun.sleep(50); + } + expect(stopped).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } +); From 88ebc8b50efca13253f8a345dbddbbdf7f3cadbb Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:18:53 +0000 Subject: [PATCH 2/6] fix(test): isolate indirect tmux callers in child processes --- .ai/skills/hack-repo-verify/SKILL.md | 11 +- bunfig.toml | 1 + tests/cli-spawn-env-isolation.test.ts | 2 +- tests/helpers/global-state-guard.ts | 12 +- .../helpers/tmux-isolation-cleanup-preload.ts | 5 + tests/helpers/tmux-isolation-preload.ts | 4 +- tests/helpers/tmux-socket-guard.ts | 75 ++++++- tests/tmux-socket-guard.test.ts | 189 +++++++++++++++++- 8 files changed, 277 insertions(+), 22 deletions(-) create mode 100644 tests/helpers/tmux-isolation-cleanup-preload.ts diff --git a/.ai/skills/hack-repo-verify/SKILL.md b/.ai/skills/hack-repo-verify/SKILL.md index f96abe8e4..accad6692 100644 --- a/.ai/skills/hack-repo-verify/SKILL.md +++ b/.ai/skills/hack-repo-verify/SKILL.md @@ -106,9 +106,14 @@ directory and stops its server in `afterAll`, including assertion failures. `Bun.spawn`/`spawnSync` tmux calls require a private 0700 directory registered by the fixture with `registerPrivateTmuxRoot`; a separate socket fixture must stop its own server before removing its root. The E2E scenario runner owns a -separate tmux scope and closes it in `finally`. This guard covers direct Bun -spawns, not arbitrary shell scripts or independently launched children: those -must receive the private socket environment explicitly. +separate tmux scope and closes it in `finally`. All Bun and Node child-process +calls receive an explicit live environment with inherited `TMUX` removed and +a registered private `TMUX_TMPDIR`, even when a caller supplies a minimal env. +Scripts therefore inherit isolation too; a script that deliberately resets its +environment or names an explicit socket must maintain that isolation itself. +The fixed `/usr/bin/env -0` native-state probe remains unwrapped so the global +state guard can still detect Bun's stale native environment. Tmux cleanup is +registered last, after the checkout guard has finished spawning Git children. `bunfig.toml` also preloads [the global-state guard](../../../tests/helpers/global-state-guard-preload.ts) for diff --git a/bunfig.toml b/bunfig.toml index ccc164e95..85f51934d 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -13,4 +13,5 @@ preload = [ "./tests/helpers/tmux-isolation-preload.ts", "./tests/helpers/global-state-guard-preload.ts", "./tests/helpers/checkout-write-guard-preload.ts", + "./tests/helpers/tmux-isolation-cleanup-preload.ts", ] diff --git a/tests/cli-spawn-env-isolation.test.ts b/tests/cli-spawn-env-isolation.test.ts index 4f8c2d324..bc50d3740 100644 --- a/tests/cli-spawn-env-isolation.test.ts +++ b/tests/cli-spawn-env-isolation.test.ts @@ -26,7 +26,7 @@ const ALLOWED: Readonly< > = { "tests/helpers/tmux-socket-guard.ts": { reason: - "tmux-only spawn forwarding pins the validated environment, not a source CLI invocation", + "child spawn forwarding pins the private tmux environment, not a source CLI invocation", count: 1, }, // `runCommand` runs git and docker for the real-engine E2E suite, not the diff --git a/tests/helpers/global-state-guard.ts b/tests/helpers/global-state-guard.ts index 4f427393a..82fb2b4a7 100644 --- a/tests/helpers/global-state-guard.ts +++ b/tests/helpers/global-state-guard.ts @@ -19,6 +19,7 @@ import { addProcessListener, isProcessOnceListener, } from "./process-once-listeners.ts"; +import { probeNativeTestEnvironment } from "./tmux-socket-guard.ts"; /** * Process events whose listener lists are compared. A change is reported per @@ -384,9 +385,6 @@ export function restoreProcessListeners( */ export type NativeEnv = ReadonlyMap; -// Bound at load so a leaked `Bun.spawnSync` fake can never answer for Bun. -const realSpawnSync = Bun.spawnSync.bind(Bun); - let nativeEnvSourceForGuardTest: NativeEnv | undefined; /** @@ -421,11 +419,9 @@ export function captureNativeEnv(): NativeEnv | undefined { return presented; } try { - const child = realSpawnSync(["/usr/bin/env", "-0"], { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - }); + // Bound before child isolation: the probe must observe Bun's native + // snapshot rather than the live environment injected into test children. + const child = probeNativeTestEnvironment(); if (child.exitCode !== 0 || !child.stdout) { return; } diff --git a/tests/helpers/tmux-isolation-cleanup-preload.ts b/tests/helpers/tmux-isolation-cleanup-preload.ts new file mode 100644 index 000000000..d1a3b9674 --- /dev/null +++ b/tests/helpers/tmux-isolation-cleanup-preload.ts @@ -0,0 +1,5 @@ +import { afterAll } from "bun:test"; +import { testMux } from "./tmux-isolation-preload.ts"; + +// Register last: the checkout guard also spawns children during its teardown. +afterAll(() => testMux.close()); diff --git a/tests/helpers/tmux-isolation-preload.ts b/tests/helpers/tmux-isolation-preload.ts index 83ce68324..6a57f02f6 100644 --- a/tests/helpers/tmux-isolation-preload.ts +++ b/tests/helpers/tmux-isolation-preload.ts @@ -1,9 +1,7 @@ -import { afterAll } from "bun:test"; import { privateTmux } from "./private-tmux.ts"; import { installTmuxSocketGuard } from "./tmux-socket-guard.ts"; // Before the global-state baseline. This covers in-process mux calls and // children using privateCliEnv, including daemon route validation tests. installTmuxSocketGuard(); -const mux = privateTmux({ restoreEnvironment: false }); -afterAll(() => mux.close()); +export const testMux = privateTmux({ restoreEnvironment: false }); diff --git a/tests/helpers/tmux-socket-guard.ts b/tests/helpers/tmux-socket-guard.ts index 1e2b8e570..44a2d9f56 100644 --- a/tests/helpers/tmux-socket-guard.ts +++ b/tests/helpers/tmux-socket-guard.ts @@ -1,8 +1,32 @@ +import childProcess from "node:child_process"; import { lstatSync, realpathSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; import { basename, dirname, isAbsolute } from "node:path"; const privateRoots = new Set(); let installed = false; +const nativeSpawnSync = Bun.spawnSync.bind(Bun); + +/** Fixed read-only probe: the state guard must still see Bun's native env. */ +export function probeNativeTestEnvironment() { + return nativeSpawnSync(["/usr/bin/env", "-0"], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); +} + +/** Every child inherits a private socket, even with an otherwise minimal env. */ +function isolatedChildEnv( + input?: Record +): Record { + const env = { ...(input ?? process.env) }; + const root = env.TMUX_TMPDIR ?? process.env.TMUX_TMPDIR; + assertPrivateTmuxSocket(["tmux", "list-sessions"], { TMUX_TMPDIR: root }); + env.TMUX_TMPDIR = root; + Reflect.deleteProperty(env, "TMUX"); + return env; +} /** Parse only client options, never session names or keys after the command. */ function explicitSocket(command: readonly string[]): string | undefined { @@ -86,11 +110,11 @@ export function installTmuxSocketGuard(): void { | { cmd?: string[]; env?: Record } | undefined; const command = Array.isArray(first) ? first : record?.cmd; - if (command && basename(command[0] ?? "") === "tmux") { + if (command) { // Without explicit env Bun can use its startup snapshot. Validate // exactly what the child will receive, including after a preload // replaced inherited TMUX/TMUX_TMPDIR. - const env = record?.env ?? { ...process.env }; + const env = isolatedChildEnv(record?.env); assertPrivateTmuxSocket(command, env); const optionsWithEnv = { ...record, env }; const forwarded = Array.isArray(first) @@ -103,4 +127,51 @@ export function installTmuxSocketGuard(): void { // Bun's overloaded generic spawn signatures cannot express this forwarding wrapper. Object.assign(Bun, { [key]: guarded }); } + installNodeChildIsolation(); +} + +/** Node runners with literal envs need the same containment as Bun runners. */ +function installNodeChildIsolation(): void { + for (const key of [ + "spawn", + "spawnSync", + "execFile", + "execFileSync", + "exec", + "execSync", + "fork", + ] as const) { + const original = childProcess[key]; + const guarded = function (this: unknown, ...args: unknown[]) { + const shell = key === "exec" || key === "execSync"; + const index = !shell && Array.isArray(args[1]) ? 2 : 1; + const value = args[index]; + const options = + typeof value === "object" && value !== null + ? (value as { env?: Record }) + : undefined; + const env = isolatedChildEnv(options?.env); + validateNodeCommand({ shell, args, env }); + const forwarded = [...args]; + if (typeof value === "function") { + forwarded.splice(index, 0, { env }); + } else { + forwarded[index] = { ...options, env }; + } + return Reflect.apply(original, this, forwarded); + }; + Object.assign(childProcess, { [key]: guarded }); + } + syncBuiltinESMExports(); +} + +function validateNodeCommand(opts: { + shell: boolean; + args: unknown[]; + env: Record; +}): void { + if (!opts.shell && typeof opts.args[0] === "string") { + const argv = Array.isArray(opts.args[1]) ? opts.args[1] : []; + assertPrivateTmuxSocket([opts.args[0], ...argv], opts.env); + } } diff --git a/tests/tmux-socket-guard.test.ts b/tests/tmux-socket-guard.test.ts index 0b3aa4c43..133b87f5f 100644 --- a/tests/tmux-socket-guard.test.ts +++ b/tests/tmux-socket-guard.test.ts @@ -1,4 +1,14 @@ import { expect, test } from "bun:test"; +import { + exec, + execFile, + execFileSync, + execSync, + fork, + spawn, + spawnSync, +} from "node:child_process"; +import { once } from "node:events"; import { chmodSync, existsSync, @@ -8,22 +18,191 @@ import { writeFileSync, } from "node:fs"; import { join, resolve } from "node:path"; +import { privateTmux } from "./helpers/private-tmux.ts"; import { assertPrivateTmuxSocket, registerPrivateTmuxRoot, } from "./helpers/tmux-socket-guard.ts"; -test("both spawn forms refuse tmux without a private socket before executing", () => { +const childEnvironmentScript = + "process.stdout.write(JSON.stringify({root:process.env.TMUX_TMPDIR,tmux:process.env.TMUX??null}))"; + +test("Bun and Node children receive private sockets with omitted or minimal envs", async () => { + const mux = privateTmux(); + const root = process.env.TMUX_TMPDIR ?? ""; + try { + expect(root).not.toBe(""); + // A synthetic inherited value must never reach a child or be dereferenced. + process.env.TMUX = "/not-a-real-socket/default,1,0"; + const minimal = { PATH: process.env.PATH }; + for (const env of [undefined, minimal]) { + const synchronous = Bun.spawnSync( + [process.execPath, "-e", childEnvironmentScript], + { env, stdout: "pipe", stderr: "pipe" } + ); + expect(synchronous.exitCode, synchronous.stderr.toString()).toBe(0); + expect(JSON.parse(synchronous.stdout.toString())).toEqual({ + root, + tmux: null, + }); + const asynchronous = Bun.spawn({ + cmd: [process.execPath, "-e", childEnvironmentScript], + env, + stdout: "pipe", + stderr: "pipe", + }); + const output = await new Response(asynchronous.stdout).text(); + expect(await asynchronous.exited).toBe(0); + expect(JSON.parse(output)).toEqual({ root, tmux: null }); + } + expect( + JSON.parse( + execFileSync(process.execPath, ["-e", childEnvironmentScript], { + env: minimal, + encoding: "utf8", + }) + ) + ).toEqual({ root, tmux: null }); + const nodeChild = spawnSync( + process.execPath, + ["-e", childEnvironmentScript], + { env: minimal, encoding: "utf8" } + ); + expect(nodeChild.status).toBe(0); + expect(JSON.parse(nodeChild.stdout)).toEqual({ root, tmux: null }); + expect( + execSync('printf "%s" "$TMUX_TMPDIR"', { env: minimal, encoding: "utf8" }) + ).toBe(root); + } finally { + mux.close(); + } +}); + +test("Node callback, shell, spawn and fork overloads preserve private child envs", async () => { + const root = process.env.TMUX_TMPDIR ?? ""; + expect(root).not.toBe(""); + const env = { + PATH: process.env.PATH, + TMUX: "/not-a-real-socket/default,1,0", + }; + const fileOutput = await new Promise((resolveOutput, reject) => { + execFile( + process.execPath, + ["-e", childEnvironmentScript], + { env, encoding: "utf8" }, + (error, stdout) => { + if (error) { + reject(error); + } else { + resolveOutput(stdout); + } + } + ); + }); + expect(JSON.parse(fileOutput)).toEqual({ root, tmux: null }); + const shellOutput = await new Promise((resolveOutput, reject) => { + exec('printf "%s" "$TMUX_TMPDIR"', { env }, (error, stdout) => { + if (error) { + reject(error); + } else { + resolveOutput(stdout); + } + }); + }); + expect(shellOutput).toBe(root); + const spawned = spawn(process.execPath, ["-e", childEnvironmentScript], { + env, + }); + let spawnedOutput = ""; + spawned.stdout.on("data", (chunk) => { + spawnedOutput += chunk.toString(); + }); + const [code] = await once(spawned, "close"); + expect(code).toBe(0); + expect(JSON.parse(spawnedOutput)).toEqual({ root, tmux: null }); + const fixtureRoot = mkdtempSync("/tmp/hack-fork-env-"); + try { + const script = join(fixtureRoot, "child.js"); + writeFileSync(script, childEnvironmentScript); + const forked = fork(script, { env, silent: true }); + if (!forked.stdout) { + throw new Error("Expected piped fork output"); + } + let output = ""; + forked.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + const [status] = await once(forked, "close"); + expect(status).toBe(0); + expect(JSON.parse(output)).toEqual({ root, tmux: null }); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } +}); + +test.skipIf(!Bun.which("tmux"))( + "indirect real tmux calls from both child env forms land on the owned server", + () => { + const mux = privateTmux(); + try { + const pids: number[] = []; + for (const [index, env] of [ + undefined, + { PATH: process.env.PATH }, + ].entries()) { + const name = `indirect-${index}`; + const script = ` + const made=Bun.spawnSync(["tmux","new-session","-d","-s",${JSON.stringify(name)},"sleep 60"],{stdout:"pipe",stderr:"pipe"}); + if(made.exitCode!==0)process.exit(2); + const pid=Bun.spawnSync(["tmux","display-message","-p","#{pid}"],{stdout:"pipe",stderr:"pipe"}); + process.stdout.write(pid.stdout); + `; + const child = Bun.spawnSync([process.execPath, "-e", script], { + env, + stdout: "pipe", + stderr: "pipe", + }); + expect(child.exitCode, child.stderr.toString()).toBe(0); + pids.push(Number(child.stdout.toString().trim())); + } + const pid = Bun.spawnSync(["tmux", "display-message", "-p", "#{pid}"], { + stdout: "pipe", + stderr: "pipe", + }); + expect(pid.exitCode).toBe(0); + expect(pids).toEqual([ + Number(pid.stdout.toString().trim()), + Number(pid.stdout.toString().trim()), + ]); + const sessions = Bun.spawnSync( + ["tmux", "list-sessions", "-F", "#{session_name}"], + { stdout: "pipe", stderr: "pipe" } + ); + expect(sessions.exitCode).toBe(0); + expect(sessions.stdout.toString().trim().split("\n").sort()).toEqual([ + "indirect-0", + "indirect-1", + ]); + } finally { + mux.close(); + } + } +); + +test("both spawn forms refuse an unsafe explicit tmux socket before executing", () => { const root = mkdtempSync("/tmp/hack-mux-refused-"); try { // If the guard regresses, this harmless stand-in runs, never real tmux. const tmux = join(root, "tmux"); writeFileSync(tmux, "#!/bin/sh\nexit 91\n", { mode: 0o755 }); - expect(() => Bun.spawn([tmux, "list-sessions"], { env: {} })).toThrow( - "tmux-socket-guard" - ); expect(() => - Bun.spawnSync({ cmd: [tmux, "list-sessions"], env: {} }) + Bun.spawn([tmux, "list-sessions"], { env: { TMUX_TMPDIR: "/tmp" } }) + ).toThrow("tmux-socket-guard"); + expect(() => + Bun.spawnSync({ + cmd: [tmux, "list-sessions"], + env: { TMUX_TMPDIR: "/tmp" }, + }) ).toThrow("tmux-socket-guard"); } finally { rmSync(root, { recursive: true, force: true }); From da2f935803159332b58c3aa3ec506b173dea5147 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:23:24 +0000 Subject: [PATCH 3/6] fix(test): preserve Node spawn options with omitted argv --- .ai/skills/hack-repo-verify/SKILL.md | 2 ++ tests/helpers/tmux-socket-guard.ts | 9 ++++++- tests/tmux-socket-guard.test.ts | 38 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.ai/skills/hack-repo-verify/SKILL.md b/.ai/skills/hack-repo-verify/SKILL.md index accad6692..38c83ae19 100644 --- a/.ai/skills/hack-repo-verify/SKILL.md +++ b/.ai/skills/hack-repo-verify/SKILL.md @@ -111,6 +111,8 @@ calls receive an explicit live environment with inherited `TMUX` removed and a registered private `TMUX_TMPDIR`, even when a caller supplies a minimal env. Scripts therefore inherit isolation too; a script that deliberately resets its environment or names an explicit socket must maintain that isolation itself. +`Bun.$` uses the live environment by default, but an explicit `.env(...)` is +not wrapped: supply the private `TMUX_TMPDIR` in that object too. The fixed `/usr/bin/env -0` native-state probe remains unwrapped so the global state guard can still detect Bun's stale native environment. Tmux cleanup is registered last, after the checkout guard has finished spawning Git children. diff --git a/tests/helpers/tmux-socket-guard.ts b/tests/helpers/tmux-socket-guard.ts index 44a2d9f56..8a918fb5e 100644 --- a/tests/helpers/tmux-socket-guard.ts +++ b/tests/helpers/tmux-socket-guard.ts @@ -144,7 +144,7 @@ function installNodeChildIsolation(): void { const original = childProcess[key]; const guarded = function (this: unknown, ...args: unknown[]) { const shell = key === "exec" || key === "execSync"; - const index = !shell && Array.isArray(args[1]) ? 2 : 1; + const index = nodeOptionsIndex({ shell, args }); const value = args[index]; const options = typeof value === "object" && value !== null @@ -165,6 +165,13 @@ function installNodeChildIsolation(): void { syncBuiltinESMExports(); } +function nodeOptionsIndex(opts: { shell: boolean; args: unknown[] }): number { + const hasArgvSlot = + Array.isArray(opts.args[1]) || + (opts.args[1] == null && opts.args.length > 2); + return !opts.shell && hasArgvSlot ? 2 : 1; +} + function validateNodeCommand(opts: { shell: boolean; args: unknown[]; diff --git a/tests/tmux-socket-guard.test.ts b/tests/tmux-socket-guard.test.ts index 133b87f5f..f2133e215 100644 --- a/tests/tmux-socket-guard.test.ts +++ b/tests/tmux-socket-guard.test.ts @@ -27,6 +27,44 @@ import { const childEnvironmentScript = "process.stdout.write(JSON.stringify({root:process.env.TMUX_TMPDIR,tmux:process.env.TMUX??null}))"; +test("Node spawn overloads keep third-argument options with null or undefined argv", async () => { + for (const argv of [undefined, null]) { + // Node supports these runtime overloads even though the type declarations + // do not include null argv. Reflect calls the actual exported API. + const result: ReturnType = Reflect.apply( + spawnSync, + undefined, + [ + "pwd", + argv, + { + cwd: "/", + encoding: "utf8", + env: { PATH: process.env.PATH }, + timeout: 1000, + }, + ] + ); + expect(result.status).toBe(0); + expect(result.stdout.toString()).toBe("/\n"); + const child: ReturnType = Reflect.apply(spawn, undefined, [ + "pwd", + argv, + { cwd: "/", env: { PATH: process.env.PATH }, stdio: "pipe" }, + ]); + if (!child.stdout) { + throw new Error("Expected piped spawn output"); + } + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + const [status] = await once(child, "close"); + expect(status).toBe(0); + expect(output).toBe("/\n"); + } +}); + test("Bun and Node children receive private sockets with omitted or minimal envs", async () => { const mux = privateTmux(); const root = process.env.TMUX_TMPDIR ?? ""; From 2cfc8de13cec717de15a461789a550cc649350c4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:31:04 +0000 Subject: [PATCH 4/6] fix(test): preserve exact compiler fixture environments --- .ai/skills/hack-repo-verify/SKILL.md | 5 +++ tests/helpers/tmux-socket-guard.ts | 50 +++++++++++++++++++++- tests/native-config-compiler.test.ts | 25 +++++++---- tests/native-config-import-preview.test.ts | 6 ++- tests/test-config-compiler.test.ts | 6 ++- tests/tmux-socket-guard.test.ts | 44 +++++++++++++++++++ 6 files changed, 125 insertions(+), 11 deletions(-) diff --git a/.ai/skills/hack-repo-verify/SKILL.md b/.ai/skills/hack-repo-verify/SKILL.md index 38c83ae19..2b7b06d9c 100644 --- a/.ai/skills/hack-repo-verify/SKILL.md +++ b/.ai/skills/hack-repo-verify/SKILL.md @@ -113,6 +113,11 @@ Scripts therefore inherit isolation too; a script that deliberately resets its environment or names an explicit socket must maintain that isolation itself. `Bun.$` uses the live environment by default, but an explicit `.env(...)` is not wrapped: supply the private `TMUX_TMPDIR` in that object too. +The exact-environment compiler transport tests use +`withCompilerFixtureEnvironment` only around a freshly-created pure compiler +fixture that reports its input/env and never invokes tmux. Only that exact +executable keeps the explicit compiler env; other children in the callback +and every child outside it remain isolated. Keep the PATH-only assertions. The fixed `/usr/bin/env -0` native-state probe remains unwrapped so the global state guard can still detect Bun's stale native environment. Tmux cleanup is registered last, after the checkout guard has finished spawning Git children. diff --git a/tests/helpers/tmux-socket-guard.ts b/tests/helpers/tmux-socket-guard.ts index 8a918fb5e..de0c88016 100644 --- a/tests/helpers/tmux-socket-guard.ts +++ b/tests/helpers/tmux-socket-guard.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import childProcess from "node:child_process"; import { lstatSync, realpathSync } from "node:fs"; import { syncBuiltinESMExports } from "node:module"; @@ -6,6 +7,53 @@ import { basename, dirname, isAbsolute } from "node:path"; const privateRoots = new Set(); let installed = false; const nativeSpawnSync = Bun.spawnSync.bind(Bun); +const compilerFixture = new AsyncLocalStorage(); + +/** + * Only for a freshly-created, pure compiler transport fixture that reports its + * received environment and never invokes tmux. Keep the actual PATH-only + * compiler contract observable, rather than relaxing its assertions for the + * isolation harness. Other commands in the callback still get isolation. + */ +export function withCompilerFixtureEnvironment(opts: { + binary: string; + run: () => T; +}): T { + const parent = lstatSync(dirname(opts.binary)); + const file = lstatSync(opts.binary); + if ( + !isAbsolute(opts.binary) || + basename(opts.binary) !== "compiler" || + !parent.isDirectory() || + (parent.mode & 0o077) !== 0 || + parent.uid !== process.getuid?.() || + !file.isFile() || + file.uid !== process.getuid?.() || + file.nlink !== 1 + ) { + throw new Error( + "Exact compiler environment requires an owned fixture in a private directory." + ); + } + return compilerFixture.run(opts.binary, opts.run); +} + +function preserveCompilerEnvironment(command: readonly string[]): boolean { + return command[0] !== undefined && command[0] === compilerFixture.getStore(); +} + +function childEnvForCommand( + command: readonly string[], + env?: Record +): Record { + if (!preserveCompilerEnvironment(command)) { + return isolatedChildEnv(env); + } + if (!env) { + throw new Error("Compiler fixture must receive an explicit environment."); + } + return env; +} /** Fixed read-only probe: the state guard must still see Bun's native env. */ export function probeNativeTestEnvironment() { @@ -114,7 +162,7 @@ export function installTmuxSocketGuard(): void { // Without explicit env Bun can use its startup snapshot. Validate // exactly what the child will receive, including after a preload // replaced inherited TMUX/TMUX_TMPDIR. - const env = isolatedChildEnv(record?.env); + const env = childEnvForCommand(command, record?.env); assertPrivateTmuxSocket(command, env); const optionsWithEnv = { ...record, env }; const forwarded = Array.isArray(first) diff --git a/tests/native-config-compiler.test.ts b/tests/native-config-compiler.test.ts index 5287b35a4..eb667abf0 100644 --- a/tests/native-config-compiler.test.ts +++ b/tests/native-config-compiler.test.ts @@ -10,6 +10,7 @@ import { resolveNativeConfigCompilerBinary, } from "../src/lib/native-config-compiler.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { withCompilerFixtureEnvironment } from "./helpers/tmux-socket-guard.ts"; const PROTOCOL = { transport_version: 1, authored_version: 1, plan_version: 1 }; const SUCCESS = { @@ -46,12 +47,16 @@ test("local resolution preserves original document text and explicit tri-state", ) ); for (const explicitOverlay of [undefined, null, "qa"]) { - const result = await resolveNativeConfig({ - input: INPUT, - checkoutLocal: new TextEncoder().encode(local), - explicitOverlay, + const result = await withCompilerFixtureEnvironment({ binary, - profiles: ["qa"], + run: () => + resolveNativeConfig({ + input: INPUT, + checkoutLocal: new TextEncoder().encode(local), + explicitOverlay, + binary, + profiles: ["qa"], + }), }); expect(result.ok).toBe(true); if (!result.ok) { @@ -464,10 +469,14 @@ test("compiler receives no inherited credentials and forwards profile arguments `const value = ${JSON.stringify(SUCCESS)}; value.plan.received = await Bun.stdin.text(); value.plan.profiles = process.argv.slice(3); value.plan.keys = Object.keys(process.env).sort(); console.log(JSON.stringify(value));` ) ); - const result = await compileNativeConfig({ - input: INPUT, + const result = await withCompilerFixtureEnvironment({ binary, - profiles: ["with spaces", "--unsafe"], + run: () => + compileNativeConfig({ + input: INPUT, + binary, + profiles: ["with spaces", "--unsafe"], + }), }); expect(result.ok).toBe(true); if (!result.ok) { diff --git a/tests/native-config-import-preview.test.ts b/tests/native-config-import-preview.test.ts index 7d57945a0..f68bcc859 100644 --- a/tests/native-config-import-preview.test.ts +++ b/tests/native-config-import-preview.test.ts @@ -16,6 +16,7 @@ import { join, resolve } from "node:path"; import { previewNativeConfigImport } from "../src/lib/native-config-import-preview.ts"; import { restoreEnv } from "./helpers/env.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; +import { withCompilerFixtureEnvironment } from "./helpers/tmux-socket-guard.ts"; const CANARY = "synthetic-private-import-owner"; const PROTOCOL = { transport_version: 1, authored_version: 1, plan_version: 1 }; @@ -109,7 +110,10 @@ test("owner validates private candidate in memory and leaves exact selected inpu await symlink(join(root, "absent-key"), join(projectDir, "hack.env.key")); const before = await readdir(projectDir); const binary = await compiler(); - const result = await previewNativeConfigImport({ projectRoot, binary }); + const result = await withCompilerFixtureEnvironment({ + binary, + run: () => previewNativeConfigImport({ projectRoot, binary }), + }); expect(result.report.complete).toBe(true); expect(result.candidate).toMatchObject({ name: "fixture", diff --git a/tests/test-config-compiler.test.ts b/tests/test-config-compiler.test.ts index 7225995ab..8f1947764 100644 --- a/tests/test-config-compiler.test.ts +++ b/tests/test-config-compiler.test.ts @@ -7,6 +7,7 @@ import { resolveTestConfigCompilerBinary, } from "../scripts/check-test-config-compiler.ts"; import { isRecord } from "../src/lib/guards.ts"; +import { withCompilerFixtureEnvironment } from "./helpers/tmux-socket-guard.ts"; const ROOT = resolve(import.meta.dir, ".."); const PROTOCOL = { @@ -70,7 +71,10 @@ async function compiler( test("uses caller selection and sends only fixed input with no caller environment", async () => { const binary = await compiler(); expect(resolveTestConfigCompilerBinary({ override: binary })).toBe(binary); - await assertTestConfigCompiler({ binary }); + await withCompilerFixtureEnvironment({ + binary, + run: () => assertTestConfigCompiler({ binary }), + }); const receipt: unknown = await Bun.file(join(directory, "received")).json(); expect(receipt).toEqual({ input: '{"schema_version":1,"name":"hack-test-preflight"}', diff --git a/tests/tmux-socket-guard.test.ts b/tests/tmux-socket-guard.test.ts index f2133e215..f80736b91 100644 --- a/tests/tmux-socket-guard.test.ts +++ b/tests/tmux-socket-guard.test.ts @@ -22,11 +22,55 @@ import { privateTmux } from "./helpers/private-tmux.ts"; import { assertPrivateTmuxSocket, registerPrivateTmuxRoot, + withCompilerFixtureEnvironment, } from "./helpers/tmux-socket-guard.ts"; const childEnvironmentScript = "process.stdout.write(JSON.stringify({root:process.env.TMUX_TMPDIR,tmux:process.env.TMUX??null}))"; +test("exact compiler environment scope preserves only its pure fixture command", () => { + const root = mkdtempSync("/tmp/hack-compiler-env-"); + try { + const binary = join(root, "compiler"); + writeFileSync( + binary, + `#!${process.execPath}\nprocess.stdout.write(JSON.stringify(Object.keys(process.env).sort()));\n`, + { mode: 0o700 } + ); + const run = () => + Bun.spawnSync([binary], { + env: { PATH: "/usr/bin:/bin" }, + stdout: "pipe", + stderr: "pipe", + }); + withCompilerFixtureEnvironment({ + binary, + run: () => { + const exact = run(); + expect(exact.exitCode).toBe(0); + expect(JSON.parse(exact.stdout.toString())).toEqual(["PATH"]); + const peer = Bun.spawnSync( + [process.execPath, "-e", childEnvironmentScript], + { env: { PATH: "/usr/bin:/bin" }, stdout: "pipe", stderr: "pipe" } + ); + expect(peer.exitCode).toBe(0); + expect(JSON.parse(peer.stdout.toString())).toEqual({ + root: process.env.TMUX_TMPDIR, + tmux: null, + }); + }, + }); + const outside = run(); + expect(outside.exitCode).toBe(0); + expect(JSON.parse(outside.stdout.toString())).toEqual([ + "PATH", + "TMUX_TMPDIR", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("Node spawn overloads keep third-argument options with null or undefined argv", async () => { for (const argv of [undefined, null]) { // Node supports these runtime overloads even though the type declarations From 1a09a70372c81380ddd9242d02afaeaba6450892 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:42:04 +0000 Subject: [PATCH 5/6] fix(test): preserve plan transport environment contracts --- tests/native-env-plan-transport.test.ts | 19 ++++++++++++------- tests/native-host-plan-transport.test.ts | 17 +++++++++++------ tests/native-routing-plan-transport.test.ts | 21 +++++++++++++-------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/tests/native-env-plan-transport.test.ts b/tests/native-env-plan-transport.test.ts index c9cc8e477..06a09c7d5 100644 --- a/tests/native-env-plan-transport.test.ts +++ b/tests/native-env-plan-transport.test.ts @@ -14,6 +14,7 @@ import { parseNativeEnvironmentPlan, } from "../src/lib/native-env-plan-protocol.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { withCompilerFixtureEnvironment } from "./helpers/tmux-socket-guard.ts"; const PROTOCOL = { transport_version: 1, @@ -214,14 +215,18 @@ test("preserves immutable original documents, tri-state selection, and PATH-only body: `const received=JSON.parse(await Bun.stdin.text()); const result=${JSON.stringify(SUCCESS)}; result.plan.received=received; result.plan.arguments=process.argv.slice(2); result.plan.environment=process.env; process.stdout.write(JSON.stringify(result));`, }); for (const explicitOverlay of [undefined, null, "qa"]) { - const result = await planNativeConfig({ - input, - primaryLocal, - checkoutLocal, - explicitOverlay, - envMetadata: METADATA, - profiles: ["with spaces", "--unsafe"], + const result = await withCompilerFixtureEnvironment({ binary, + run: () => + planNativeConfig({ + input, + primaryLocal, + checkoutLocal, + explicitOverlay, + envMetadata: METADATA, + profiles: ["with spaces", "--unsafe"], + binary, + }), }); expect(result.ok).toBe(true); if (!result.ok) { diff --git a/tests/native-host-plan-transport.test.ts b/tests/native-host-plan-transport.test.ts index 36a07a2ed..e038c6b25 100644 --- a/tests/native-host-plan-transport.test.ts +++ b/tests/native-host-plan-transport.test.ts @@ -15,6 +15,7 @@ import { } from "../src/lib/native-env-plan-protocol.ts"; import { planNativeProject } from "../src/lib/native-project-validation.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { withCompilerFixtureEnvironment } from "./helpers/tmux-socket-guard.ts"; const PROTOCOL = { transport_version: 1, @@ -297,13 +298,17 @@ test("keeps host and workload names separate across hooks and processes with raw const binary = await fixture({ body: `const received=JSON.parse(await Bun.stdin.text()); const result=${JSON.stringify(SUCCESS)}; result.plan.received=received; result.plan.arguments=process.argv.slice(2); result.plan.environment=process.env; process.stdout.write(JSON.stringify(result));`, }); - const result = await planNativeConfig({ - input, - checkoutLocal, - explicitOverlay: null, - envMetadata: metadata, + const result = await withCompilerFixtureEnvironment({ binary, - profiles: ["with spaces", "--unsafe"], + run: () => + planNativeConfig({ + input, + checkoutLocal, + explicitOverlay: null, + envMetadata: metadata, + binary, + profiles: ["with spaces", "--unsafe"], + }), }); expect(result.ok).toBe(true); if (!result.ok) { diff --git a/tests/native-routing-plan-transport.test.ts b/tests/native-routing-plan-transport.test.ts index 1001754bb..f280b35f5 100644 --- a/tests/native-routing-plan-transport.test.ts +++ b/tests/native-routing-plan-transport.test.ts @@ -12,6 +12,7 @@ import { import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { withCompilerFixtureEnvironment } from "./helpers/tmux-socket-guard.ts"; const PROTOCOL = { transport_version: 1, @@ -501,15 +502,19 @@ test("preserves routing request text, document bytes, and PATH-only compiler env const binary = await fixture({ body: `const received=JSON.parse(await Bun.stdin.text()); const result=${JSON.stringify(BRANCHED_SUCCESS)}; result.plan.received=received; result.plan.arguments=process.argv.slice(2); result.plan.environment=process.env; process.stdout.write(JSON.stringify(result));`, }); - const result = await planNativeConfig({ - input, - checkoutLocal, - explicitDomain: "dev.test", - globalDomain: "global.test", - branch: "feature-routing", - envMetadata: METADATA, - profiles: ["with spaces", "--unsafe"], + const result = await withCompilerFixtureEnvironment({ binary, + run: () => + planNativeConfig({ + input, + checkoutLocal, + explicitDomain: "dev.test", + globalDomain: "global.test", + branch: "feature-routing", + envMetadata: METADATA, + profiles: ["with spaces", "--unsafe"], + binary, + }), }); expect(result.ok).toBe(true); if (!result.ok) { From 4d00738431cb520bc47269e1e3a464138894fa8b Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:10:34 +0000 Subject: [PATCH 6/6] fix(test): stop every server under a private tmux root on close The guard admits `-L name` and `-S /x.sock`, but cleanup only killed the root's default server, so a named or explicit-socket server could outlive its removed root. Close now stops each socket found under the owned root (symlinks not followed) and keeps the root if any stop fails. Regression starts both forms and checks their servers stop. Co-Authored-By: Claude Opus 5.5 --- tests/helpers/private-tmux.ts | 46 +++++++++++++++++++++------- tests/tmux-socket-guard.test.ts | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/tests/helpers/private-tmux.ts b/tests/helpers/private-tmux.ts index c1de0329c..16cfb5504 100644 --- a/tests/helpers/private-tmux.ts +++ b/tests/helpers/private-tmux.ts @@ -1,7 +1,22 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { lstatSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; import { restoreEnv } from "./env.ts"; import { registerPrivateTmuxRoot } from "./tmux-socket-guard.ts"; +/** Every Unix socket under a private root; symlinks are never followed. */ +export function privateSockets(root: string): string[] { + const sockets: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + sockets.push(...privateSockets(path)); + } else if (lstatSync(path).isSocket()) { + sockets.push(path); + } + } + return sockets; +} + /** Own one short socket root, and always stop its server before removing it. */ export function privateTmux(opts: { restoreEnvironment?: boolean } = {}): { close(): void; @@ -22,16 +37,27 @@ export function privateTmux(opts: { restoreEnvironment?: boolean } = {}): { let stopped = tmux === null; try { if (tmux) { - // Explicit environment cannot be redirected by a failing test. - const result = Bun.spawnSync([tmux, "kill-server"], { - env: { PATH: process.env.PATH ?? "", TMUX_TMPDIR: root }, - stdout: "ignore", - stderr: "ignore", - timeout: 5000, + // The guard accepts `-L name` and `-S /x.sock`, so a test can + // start servers besides the root's default one. Stop every socket + // under the root, not just the default server. + stopped = privateSockets(root).every((socket) => { + // tmux keeps `-L`/default sockets in `/tmux-/`; that + // directory is inside this owned root, so admit it for the kill. + const unregisterDir = registerPrivateTmuxRoot(dirname(socket)); + // Explicit environment cannot be redirected by a failing test. + const result = Bun.spawnSync([tmux, "-S", socket, "kill-server"], { + env: { PATH: process.env.PATH ?? "", TMUX_TMPDIR: root }, + stdout: "ignore", + stderr: "ignore", + timeout: 5000, + }); + if (dirname(socket) !== root) { + unregisterDir(); + } + // 1 means no server answered (a stale socket). A timeout/signal is + // not cleanup: retain the root so a live server's socket is kept. + return [0, 1].includes(result.exitCode) && !result.signalCode; }); - // 1 means there was no server. A timeout/signal is not cleanup: - // retain the root so a live server's socket is not unlinked. - stopped = [0, 1].includes(result.exitCode) && !result.signalCode; if (!stopped) { throw new Error( "Private test tmux server did not stop; retained its socket root." diff --git a/tests/tmux-socket-guard.test.ts b/tests/tmux-socket-guard.test.ts index f80736b91..2a28248b2 100644 --- a/tests/tmux-socket-guard.test.ts +++ b/tests/tmux-socket-guard.test.ts @@ -405,3 +405,56 @@ test.skipIf(!Bun.which("tmux"))( } } ); + +/** kill-server acknowledges before exit; a defunct process is no live server. */ +async function serverStopped(pid: number): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + const server = Bun.spawnSync(["ps", "-p", String(pid), "-o", "stat="], { + stdout: "pipe", + stderr: "pipe", + timeout: 1000, + }); + if ( + server.exitCode === 1 || + server.stdout.toString().trim().startsWith("Z") + ) { + return true; + } + await Bun.sleep(50); + } + return false; +} + +test.skipIf(!Bun.which("tmux"))( + "closing a private root stops named and explicit-socket servers inside it", + async () => { + const mux = privateTmux(); + const root = process.env.TMUX_TMPDIR ?? ""; + const pids: number[] = []; + try { + for (const client of [ + ["-L", "named-fixture"], + ["-S", join(root, "explicit.sock")], + ]) { + const made = Bun.spawnSync( + ["tmux", ...client, "new-session", "-d", "-s", "extra", "sleep 60"], + { stdout: "pipe", stderr: "pipe" } + ); + expect(made.exitCode, made.stderr.toString()).toBe(0); + const pid = Bun.spawnSync( + ["tmux", ...client, "display-message", "-p", "#{pid}"], + { stdout: "pipe", stderr: "pipe" } + ); + expect(pid.exitCode).toBe(0); + pids.push(Number(pid.stdout.toString().trim())); + } + } finally { + mux.close(); + } + expect(pids).toHaveLength(2); + expect(existsSync(root)).toBe(false); + for (const pid of pids) { + expect(await serverStopped(pid)).toBe(true); + } + } +);