diff --git a/.ai/skills/hack-repo-verify/SKILL.md b/.ai/skills/hack-repo-verify/SKILL.md index 5d34f94c4..ce866bca3 100644 --- a/.ai/skills/hack-repo-verify/SKILL.md +++ b/.ai/skills/hack-repo-verify/SKILL.md @@ -103,6 +103,29 @@ 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`. 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. +`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. + +`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..85f51934d 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,12 +2,16 @@ 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", + "./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 3e979de5f..bc50d3740 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: + "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 // 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 e0e7928eb..eb21e5c69 100644 --- a/tests/daemon-sessions.test.ts +++ b/tests/daemon-sessions.test.ts @@ -25,6 +25,7 @@ import { startRequestTargetProxy } from "../src/daemon/request-target-proxy.ts"; import { handleSessionRoutes } from "../src/daemon/routes/sessions.ts"; import { exec } from "../src/lib/shell.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { registerPrivateTmuxRoot } from "./helpers/tmux-socket-guard.ts"; /** Real session-route tests require tmux, always on a task-owned socket. */ const tmuxPath = Bun.which("tmux"); @@ -33,6 +34,7 @@ let sessionRoot = ""; let sessionSocket = ""; let savedPath: string | undefined; let pathSaved = false; +let unregisterSocketRoot = () => {}; /** Never address the default server, even when a route case fails after create. */ function privateTmux(args: readonly string[]) { @@ -530,6 +532,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { beforeAll(async () => { // Short path for Darwin's Unix socket limit; each process owns a new server. sessionRoot = await mkdtemp("/tmp/hack-session-routes-"); + unregisterSocketRoot = registerPrivateTmuxRoot(sessionRoot); sessionSocket = join(sessionRoot, "tmux.sock"); await mkdir(join(sessionRoot, "bin")); // The route executes real tmux through this shim. Never inherit a caller's @@ -563,6 +566,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { await privateTmux(["kill-server"]); } finally { await rm(sessionRoot, { recursive: true, force: true }); + unregisterSocketRoot(); sessionRoot = ""; sessionSocket = ""; } 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/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/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..16cfb5504 --- /dev/null +++ b/tests/helpers/private-tmux.ts @@ -0,0 +1,80 @@ +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; +} { + 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) { + // 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; + }); + 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-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 new file mode 100644 index 000000000..6a57f02f6 --- /dev/null +++ b/tests/helpers/tmux-isolation-preload.ts @@ -0,0 +1,7 @@ +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(); +export const testMux = privateTmux({ restoreEnvironment: false }); diff --git a/tests/helpers/tmux-socket-guard.ts b/tests/helpers/tmux-socket-guard.ts new file mode 100644 index 000000000..de0c88016 --- /dev/null +++ b/tests/helpers/tmux-socket-guard.ts @@ -0,0 +1,232 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +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); +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() { + 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 { + 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) { + // 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 = childEnvForCommand(command, record?.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 }); + } + 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 = nodeOptionsIndex({ shell, args }); + 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 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[]; + 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/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/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) { 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/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-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..2a28248b2 --- /dev/null +++ b/tests/tmux-socket-guard.test.ts @@ -0,0 +1,460 @@ +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, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +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 + // 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 ?? ""; + 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: { 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 }); + } + 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 }); + } + } +); + +/** 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); + } + } +);