diff --git a/src/backends/native-https-owner-server.ts b/src/backends/native-https-owner-server.ts index 206292bb2..54269bf49 100644 --- a/src/backends/native-https-owner-server.ts +++ b/src/backends/native-https-owner-server.ts @@ -11,6 +11,7 @@ import { createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { isRecord } from "../lib/guards.ts"; +import { listenPublishedUnixSocket } from "../lib/unix-socket-publish.ts"; import { decodeNativeHttpsOwnerFrame, encodeNativeHttpsOwnerFrame, @@ -250,7 +251,8 @@ export async function serveNativeHttpsOwner(opts: { } }; const closeServer = async () => { - // Node may unlink its bound path on close. Refuse before invoking it when replaced. + // Refuse before closing when any owned path changed. The runtime's close-time + // unlink reaches only the retired staging name (listenPublishedUnixSocket). await checkPaths(); for (const client of clients) { client.destroy(); @@ -582,10 +584,9 @@ export async function serveNativeHttpsOwner(opts: { } }; void frontend.exited.then(exited, exited); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(socketPath, resolve); - }); + // Published by link from a staging name: closing the server never removes + // the control endpoint, which only retireSocketPath removes by identity. + await listenPublishedUnixSocket(server, socketPath); const before = await lstat(socketPath); await chmod(socketPath, 0o600); const after = await lstat(socketPath); diff --git a/src/backends/native-project-https.ts b/src/backends/native-project-https.ts index c2f9f5f5f..0e61e8911 100644 --- a/src/backends/native-project-https.ts +++ b/src/backends/native-project-https.ts @@ -27,6 +27,7 @@ import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { connect as tlsConnect } from "node:tls"; import { isRecord } from "../lib/guards.ts"; +import { listenPublishedUnixSocket } from "../lib/unix-socket-publish.ts"; import { checkNativeHttpsPort } from "./native-https-port.ts"; import { invokeNativeRuntime, @@ -709,22 +710,11 @@ async function startOwnerChallenge( } }); }); - let listening = false; let identity: { dev: number; ino: number } | undefined; try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(path, () => { - server.off("error", reject); - listening = true; - resolve(); - }); - }); - const metadata = await lstat(path); - if (!metadata.isSocket() || metadata.uid !== process.getuid?.()) { - throw refused(); - } - identity = { dev: metadata.dev, ino: metadata.ino }; + // Published by link from an owner-only staging name (a verified socket of + // this user): closing the server never removes a replacement at path. + identity = await listenPublishedUnixSocket(server, path); await setMode(path, 0o600); const prepared = await lstat(path); if ( @@ -738,10 +728,9 @@ async function startOwnerChallenge( } return { server, publicKey, identity }; } catch { + // Without an identity, publication failed and already closed the server. if (identity) { await stopOwnerChallenge({ path, server, identity }); - } else if (listening) { - await new Promise((resolve) => server.close(() => resolve())); } throw refused(); } diff --git a/src/lib/unix-socket-publish.ts b/src/lib/unix-socket-publish.ts new file mode 100644 index 000000000..a95d4d4f4 --- /dev/null +++ b/src/lib/unix-socket-publish.ts @@ -0,0 +1,141 @@ +import { randomBytes } from "node:crypto"; +import { link, lstat, unlink } from "node:fs/promises"; +import type { Server } from "node:net"; +import { basename, dirname, join } from "node:path"; + +/** Device and inode of a published Unix-socket endpoint. */ +export interface UnixSocketIdentity { + readonly dev: number; + readonly ino: number; +} + +/** Thrown when a file already exists at the requested endpoint path. */ +export class UnixSocketEndpointExists extends Error { + readonly code = "EEXIST"; + readonly path: string; + constructor(path: string) { + super("Unix socket endpoint already exists"); + this.path = path; + } +} + +function missing(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ); +} + +async function removeIfSame( + path: string, + identity: UnixSocketIdentity +): Promise { + const current = await lstat(path).catch((error: unknown) => { + if (missing(error)) { + return null; + } + throw error; + }); + if (current && current.dev === identity.dev && current.ino === identity.ino) { + await unlink(path); + } +} + +/** + * Listens on a Unix socket published at `path`, so that the runtime never replaces or removes a + * file at `path`. + * + * Bun replaces an existing file at a Unix server's bound path when it listens, and from 1.3.14 it + * unlinks the bound path by name when the server closes, whatever that path holds by then. Binding + * `path` directly would let a close remove a foreign replacement. Instead the server binds a fresh + * staging name in the same directory, never longer than `path`'s own name so path-length budgets + * hold, under an owner-only umask. It is then published with link(2), which refuses an existing + * `path` (EEXIST) rather than replacing it. The staging name is removed right away, so a + * close-time unlink by the runtime finds nothing. `path` is removed only by the caller's + * identity-checked cleanup, using the returned identity. + * + * The directory must be private to this user. On any failure after listening, the server is + * closed, the staging name is removed, and `path` is removed only while it still holds this + * server's socket. + */ +export async function listenPublishedUnixSocket( + server: Server, + path: string +): Promise { + const name = basename(path); + const staging = join( + dirname(path), + `.${randomBytes(Math.ceil(name.length / 2)) + .toString("hex") + .slice(0, Math.max(4, name.length - 1))}` + ); + if ( + await lstat(staging).then( + () => true, + (error: unknown) => (missing(error) ? false : Promise.reject(error)) + ) + ) { + throw new Error("Unix socket staging name is already in use"); + } + await new Promise((resolve, reject) => { + server.once("error", reject); + // Bun binds synchronously inside listen(); keep the socket owner-only from creation and + // restore the mask before any await. + const previousMask = process.umask(); + process.umask(previousMask | 0o077); + try { + server.listen(staging, () => { + server.off("error", reject); + resolve(); + }); + } finally { + process.umask(previousMask); + } + }); + let published: UnixSocketIdentity | undefined; + try { + const bound = await lstat(staging); + if (!bound.isSocket() || bound.uid !== process.getuid?.()) { + throw new Error( + "Unix socket staging endpoint changed before publication" + ); + } + try { + await link(staging, path); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "EEXIST" + ) { + throw new UnixSocketEndpointExists(path); + } + throw error; + } + published = { dev: bound.dev, ino: bound.ino }; + const current = await lstat(path); + if ( + !current.isSocket() || + current.dev !== bound.dev || + current.ino !== bound.ino + ) { + throw new Error("Unix socket endpoint changed during publication"); + } + await unlink(staging); + return published; + } catch (error) { + await new Promise((resolve) => server.close(() => resolve())); + await unlink(staging).catch((cleanup: unknown) => { + if (!missing(cleanup)) { + throw cleanup; + } + }); + if (published) { + await removeIfSame(path, published); + } + throw error; + } +} diff --git a/src/mcp/socket-backend.ts b/src/mcp/socket-backend.ts index 8f5170fd1..b617350a2 100644 --- a/src/mcp/socket-backend.ts +++ b/src/mcp/socket-backend.ts @@ -4,6 +4,10 @@ import { isAbsolute, join } from "node:path"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js"; import { isRecord } from "../lib/guards.ts"; +import { + listenPublishedUnixSocket, + UnixSocketEndpointExists, +} from "../lib/unix-socket-publish.ts"; import { McpCommandAdmission } from "./command-admission.ts"; import { createMcpServer } from "./server.ts"; import { recordMcpSocketReceipt } from "./socket-receipt.ts"; @@ -173,24 +177,17 @@ async function prepareBackend( throw new Error("MCP socket endpoint already exists"); } startup.check(); - await new Promise((resolve, reject) => { - listener.once("error", reject); - // Bun binds a local Unix endpoint synchronously in listen(). Restrict its - // creation mode so concurrent adapters cannot observe a public socket - // before chmod. Restore immediately; never retain this mask across await - // or apply it to commands executed for connected clients. - const previousMask = process.umask(); - process.umask(previousMask | 0o077); - try { - listener.listen(socketPath, () => { - listener.off("error", reject); - resolve(); - }); - } finally { - process.umask(previousMask); + // Published by link from an owner-only staging name, so no runtime close can + // remove a replacement at socketPath; only removeOwnedEndpoint removes it. + endpointIdentity = await listenPublishedUnixSocket( + listener, + socketPath + ).catch((error: unknown) => { + if (error instanceof UnixSocketEndpointExists) { + throw new Error("MCP socket endpoint already exists"); } + throw error; }); - endpointIdentity = await lstat(socketPath); startup.check(); await chmod(socketPath, 0o600); startup.check(); diff --git a/tests/cli-command.test.ts b/tests/cli-command.test.ts index 325b5e1da..e63a10ae4 100644 --- a/tests/cli-command.test.ts +++ b/tests/cli-command.test.ts @@ -6,6 +6,7 @@ import { resolveCommand, } from "../src/cli/command.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; +import { restoreEnv } from "./helpers/env.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -21,7 +22,7 @@ beforeEach(() => { }); afterEach(() => { - process.env.HACK_LOGGER = originalLogger; + restoreEnv("HACK_LOGGER", originalLogger); }); test("resolveCommand finds nested subcommand and remaining positionals", () => { diff --git a/tests/clipboard.test.ts b/tests/clipboard.test.ts index 39d6622aa..f8270d7e2 100644 --- a/tests/clipboard.test.ts +++ b/tests/clipboard.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { copyToClipboard } from "../src/ui/clipboard.ts"; +import { restoreEnv } from "./helpers/env.ts"; const ORIGINAL_PATH = process.env.PATH; @@ -9,7 +10,7 @@ beforeEach(() => { }); afterEach(() => { - process.env.PATH = ORIGINAL_PATH; + restoreEnv("PATH", ORIGINAL_PATH); }); test("copyToClipboard returns error when no helper is available", async () => { diff --git a/tests/config-command.test.ts b/tests/config-command.test.ts index 0129967e0..226ffb462 100644 --- a/tests/config-command.test.ts +++ b/tests/config-command.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -30,9 +31,9 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; - process.env.HACK_LOGGER = originalLogger; - process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + restoreEnv("HOME", originalHome); + restoreEnv("HACK_LOGGER", originalLogger); + restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalGlobalConfigPath); }); test("config set --global updates extension enabled using bracket path", async () => { diff --git a/tests/config-paths.test.ts b/tests/config-paths.test.ts index 8ea2958bf..44b1bfa2f 100644 --- a/tests/config-paths.test.ts +++ b/tests/config-paths.test.ts @@ -23,7 +23,11 @@ const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; const tempDirs: string[] = []; function restoreEnv(key: string, value: string | undefined): void { - process.env[key] = value; + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } } afterEach(async () => { @@ -67,7 +71,7 @@ test("resolveGlobalConfigPath override beats HACK_HOME", () => { }); test("resolveGlobalHackDir defaults to HOME/.hack when HACK_HOME is unset", () => { - process.env.HACK_HOME = undefined; + Reflect.deleteProperty(process.env, "HACK_HOME"); process.env.HACK_GLOBAL_CONFIG_PATH = ""; process.env.HOME = "/tmp/hack-home"; @@ -129,7 +133,7 @@ test("resolveDaemonPaths explicit home wins over HACK_HOME", () => { }); test("resolveDaemonPaths is home-based when HACK_HOME is unset", () => { - process.env.HACK_HOME = undefined; + Reflect.deleteProperty(process.env, "HACK_HOME"); process.env.HOME = "/tmp/hack-home"; const paths = resolveDaemonPaths({}); @@ -142,7 +146,7 @@ test("projects registry is read from under HACK_HOME when set", async () => { const hackHome = await makeTempDir("hack-home-"); process.env.HACK_HOME = hackHome; process.env.HOME = "/tmp/hack-home"; - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); const registryPath = join(hackHome, GLOBAL_PROJECTS_REGISTRY_FILENAME); const record = { diff --git a/tests/config.test.ts b/tests/config.test.ts index 31d52a4c4..938bd64c9 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -8,6 +8,7 @@ import { updateGlobalConfig, updateProjectConfigBatch, } from "../src/lib/config.ts"; +import { restoreEnv } from "./helpers/env.ts"; describe("global config utilities", () => { let tempDir: string; @@ -23,9 +24,9 @@ describe("global config utilities", () => { afterEach(async () => { if (originalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = originalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } await rm(tempDir, { recursive: true, force: true }); }); diff --git a/tests/control-plane-config.test.ts b/tests/control-plane-config.test.ts index cb9c8947f..9a58e23bb 100644 --- a/tests/control-plane-config.test.ts +++ b/tests/control-plane-config.test.ts @@ -28,7 +28,7 @@ afterEach(async () => { tempGlobalConfig = null; } if (originalGlobalConfigPath === undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } else { process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; } diff --git a/tests/control-plane-extensions.test.ts b/tests/control-plane-extensions.test.ts index 14b3a9d98..a20b338c9 100644 --- a/tests/control-plane-extensions.test.ts +++ b/tests/control-plane-extensions.test.ts @@ -27,7 +27,7 @@ beforeEach(() => { afterEach(() => { if (originalGlobalConfigPath === undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } else { process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; } diff --git a/tests/daemon-autostart-bounded.test.ts b/tests/daemon-autostart-bounded.test.ts index 711e12338..263bf3777 100644 --- a/tests/daemon-autostart-bounded.test.ts +++ b/tests/daemon-autostart-bounded.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { requestDaemonJson } from "../src/daemon/client.ts"; +import { restoreEnv } from "./helpers/env.ts"; const AUTOSTART_BUDGET_MS = 5000; @@ -25,7 +26,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); if (originalNoInteractive === undefined) { Reflect.deleteProperty(process.env, "HACK_NO_INTERACTIVE"); } else { diff --git a/tests/daemon-client.test.ts b/tests/daemon-client.test.ts index d746f3a7d..a26b1a3ce 100644 --- a/tests/daemon-client.test.ts +++ b/tests/daemon-client.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { requestDaemonJson } from "../src/daemon/client.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -19,7 +20,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); }); test("requestDaemonJson skips daemon autostart when autoStart is false", async () => { diff --git a/tests/daemon-command.test.ts b/tests/daemon-command.test.ts index a746edaa0..360199ff7 100644 --- a/tests/daemon-command.test.ts +++ b/tests/daemon-command.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { resolveDaemonPaths } from "../src/daemon/paths.ts"; import { findOrphanDaemonProcesses } from "../src/daemon/process.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -29,8 +30,8 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; - process.env.HACK_LOGGER = originalLogger; + restoreEnv("HOME", originalHome); + restoreEnv("HACK_LOGGER", originalLogger); }); test.skipIf(!Bun.which("lsof"))( diff --git a/tests/daemon-env.test.ts b/tests/daemon-env.test.ts index 7a5984c52..d8cbd1430 100644 --- a/tests/daemon-env.test.ts +++ b/tests/daemon-env.test.ts @@ -10,6 +10,7 @@ import { import { handleEnvRoutes } from "../src/daemon/routes/env.ts"; import { ensureDir, writeTextFileIfChanged } from "../src/lib/fs.ts"; import { upsertProjectRegistration } from "../src/lib/projects-registry.ts"; +import { restoreEnv } from "./helpers/env.ts"; function mockRequest(opts: { readonly method: string; @@ -95,7 +96,7 @@ describe("handleEnvRoutes", () => { afterEach(async () => { if (originalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = originalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalConfigPath); } else { Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } diff --git a/tests/daemon-paths.test.ts b/tests/daemon-paths.test.ts index 6fd6e52a0..23169c6d0 100644 --- a/tests/daemon-paths.test.ts +++ b/tests/daemon-paths.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { resolveDaemonPaths } from "../src/daemon/paths.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -19,7 +20,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); }); test("resolveDaemonPaths uses ~/.hack/daemon defaults", () => { diff --git a/tests/dependency-cache.test.ts b/tests/dependency-cache.test.ts index 8e032a6cb..36ee7076b 100644 --- a/tests/dependency-cache.test.ts +++ b/tests/dependency-cache.test.ts @@ -7,6 +7,7 @@ import { resolveDependencyCacheBootstrapServices, resolveDependencyCacheOverride, } from "../src/lib/dependency-cache.ts"; +import { restoreEnv } from "./helpers/env.ts"; const tempDirs: string[] = []; @@ -155,7 +156,7 @@ test("default platform is explicit and never inferred from host architecture", a const project = await createProject(); const original = process.env.DOCKER_DEFAULT_PLATFORM; try { - process.env.DOCKER_DEFAULT_PLATFORM = undefined; + Reflect.deleteProperty(process.env, "DOCKER_DEFAULT_PLATFORM"); expect( (await changeRuntime(project, { image: "oven/bun" })) .sharingDisabledReason @@ -175,7 +176,7 @@ test("default platform is explicit and never inferred from host architecture", a ).fingerprint ).toBe(amd.fingerprint); } finally { - process.env.DOCKER_DEFAULT_PLATFORM = original; + restoreEnv("DOCKER_DEFAULT_PLATFORM", original); } }); diff --git a/tests/dispatch-e2e.test.ts b/tests/dispatch-e2e.test.ts index bbb48b8a6..05de7c77b 100644 --- a/tests/dispatch-e2e.test.ts +++ b/tests/dispatch-e2e.test.ts @@ -8,6 +8,7 @@ import { } from "../src/lib/nodes-registry.ts"; import { findProjectContext } from "../src/lib/project.ts"; import { upsertProjectRegistration } from "../src/lib/projects-registry.ts"; +import { restoreEnv } from "./helpers/env.ts"; const shouldRun = process.env.HACK_DISPATCH_E2E === "1"; const runTest = shouldRun ? test : test.skip; @@ -52,11 +53,11 @@ afterEach(async () => { await rm(tempHome, { recursive: true, force: true }); tempHome = null; } - process.env.HOME = previousHome; + restoreEnv("HOME", previousHome); if (previousGlobalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = previousGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", previousGlobalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } }); diff --git a/tests/disposable-cache-volumes.test.ts b/tests/disposable-cache-volumes.test.ts index 40d6331ca..84a338a31 100644 --- a/tests/disposable-cache-volumes.test.ts +++ b/tests/disposable-cache-volumes.test.ts @@ -13,6 +13,7 @@ import type { RuntimeProject, } from "../src/lib/runtime-projects.ts"; import { serializeRuntimeProject } from "../src/lib/runtime-projects.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalPath: string | undefined; @@ -23,7 +24,7 @@ beforeEach(async () => { }); afterEach(async () => { - process.env.PATH = originalPath; + restoreEnv("PATH", originalPath); if (tempDir) { await rm(tempDir, { recursive: true, force: true }); tempDir = null; diff --git a/tests/doctor-fix-noninteractive.test.ts b/tests/doctor-fix-noninteractive.test.ts index 27e4299c4..5a1028083 100644 --- a/tests/doctor-fix-noninteractive.test.ts +++ b/tests/doctor-fix-noninteractive.test.ts @@ -16,6 +16,7 @@ import { GLOBAL_HACK_DIR_NAME, } from "../src/constants.ts"; import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; /** @@ -138,7 +139,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); if (originalNoInteractive === undefined) { Reflect.deleteProperty(process.env, "HACK_NO_INTERACTIVE"); } else { @@ -149,7 +150,7 @@ afterEach(async () => { } else { process.env.HACK_MUTAGEN_PATH = originalMutagenPath; } - process.env.HACK_LOGGER = originalLogger; + restoreEnv("HACK_LOGGER", originalLogger); }); afterAll(() => { diff --git a/tests/env-backend-command.test.ts b/tests/env-backend-command.test.ts index 5ee09bea0..d04a0d734 100644 --- a/tests/env-backend-command.test.ts +++ b/tests/env-backend-command.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { restoreEnv } from "./helpers/env.ts"; type RunResult = { readonly exitCode: number; @@ -29,14 +30,14 @@ afterEach(async () => { } tempGlobalConfigPath = null; if (previousGlobalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = previousGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", previousGlobalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } if (previousSecretsKey !== undefined) { process.env.HACK_SECRETS_FILE_KEY = previousSecretsKey; } else { - process.env.HACK_SECRETS_FILE_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); } }); diff --git a/tests/env-command-modern.test.ts b/tests/env-command-modern.test.ts index e644bd866..61956952a 100644 --- a/tests/env-command-modern.test.ts +++ b/tests/env-command-modern.test.ts @@ -20,7 +20,7 @@ beforeEach(async () => { originalLogger = process.env.HACK_LOGGER; originalProjectEnvKey = process.env.HACK_ENV_SECRET_KEY; process.env.HACK_LOGGER = "console"; - process.env.HACK_ENV_SECRET_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_ENV_SECRET_KEY"); }); afterEach(async () => { @@ -29,12 +29,12 @@ afterEach(async () => { tempDir = null; } if (originalLogger === undefined) { - process.env.HACK_LOGGER = undefined; + Reflect.deleteProperty(process.env, "HACK_LOGGER"); } else { process.env.HACK_LOGGER = originalLogger; } if (originalProjectEnvKey === undefined) { - process.env.HACK_ENV_SECRET_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_ENV_SECRET_KEY"); } else { process.env.HACK_ENV_SECRET_KEY = originalProjectEnvKey; } diff --git a/tests/env-exec-command.test.ts b/tests/env-exec-command.test.ts index 9524cb45d..d81dd2275 100644 --- a/tests/env-exec-command.test.ts +++ b/tests/env-exec-command.test.ts @@ -10,6 +10,7 @@ import { PROJECT_CONFIG_FILENAME, } from "../src/constants.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const runCalls: Array<{ @@ -54,8 +55,8 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); - process.env.HOME = originalHome; - process.env.SHELL = originalShell; + restoreEnv("HOME", originalHome); + restoreEnv("SHELL", originalShell); }); afterAll(() => { diff --git a/tests/global-command.macos.test.ts b/tests/global-command.macos.test.ts index dafacff40..e34a34715 100644 --- a/tests/global-command.macos.test.ts +++ b/tests/global-command.macos.test.ts @@ -25,6 +25,7 @@ import { import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; import { CURRENT_CA_PEM, OLD_CA_PEM } from "./helpers/ca-certificates.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; // These fixtures exercise macOS host commands and require that platform. @@ -268,9 +269,9 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; - process.env.USER = originalUser; - process.env.HACK_LOGGER = originalLogger; + restoreEnv("HOME", originalHome); + restoreEnv("USER", originalUser); + restoreEnv("HACK_LOGGER", originalLogger); if (originalNoInteractive === undefined) { Reflect.deleteProperty(process.env, "HACK_NO_INTERACTIVE"); } else { @@ -1660,8 +1661,8 @@ test("native global install refuses before touching the Docker global stack", as expect(runCalls).toHaveLength(0); expect(execCalls).toHaveLength(0); } finally { - process.env.HACK_RUNTIME_BACKEND = previousBackend; - process.env.HACK_NATIVE_BINARY = previousBinary; - process.env.HACK_NATIVE_HOME = previousNativeHome; + restoreEnv("HACK_RUNTIME_BACKEND", previousBackend); + restoreEnv("HACK_NATIVE_BINARY", previousBinary); + restoreEnv("HACK_NATIVE_HOME", previousNativeHome); } }); diff --git a/tests/global-command.test.ts b/tests/global-command.test.ts index 51ceea3cd..edfb9ee93 100644 --- a/tests/global-command.test.ts +++ b/tests/global-command.test.ts @@ -24,6 +24,7 @@ import { GLOBAL_SCHEMAS_DIR_NAME, } from "../src/constants.ts"; import { resetGumPathCacheForTests } from "../src/ui/gum.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; let tempDir: string | null = null; @@ -93,8 +94,8 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; - process.env.HACK_LOGGER = originalLogger; + restoreEnv("HOME", originalHome); + restoreEnv("HACK_LOGGER", originalLogger); resetGumPathCacheForTests(); }); diff --git a/tests/hack-env.test.ts b/tests/hack-env.test.ts index 77889272b..be455a8a6 100644 --- a/tests/hack-env.test.ts +++ b/tests/hack-env.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from "node:path"; import { PROJECT_CONFIG_FILENAME } from "../src/constants.ts"; import { resolveHackEnv } from "../src/lib/hack-env.ts"; +import { restoreEnv } from "./helpers/env.ts"; const tempDirs = new Set(); const originalHome = process.env.HOME; @@ -14,8 +15,8 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); - process.env.HOME = originalHome; - process.env.HACK_SECRETS_FILE_KEY = undefined; + restoreEnv("HOME", originalHome); + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); }); test("resolveHackEnv skips encrypted backend access when no legacy env contract exists", async () => { diff --git a/tests/helpers/env.ts b/tests/helpers/env.ts new file mode 100644 index 000000000..9421edf6f --- /dev/null +++ b/tests/helpers/env.ts @@ -0,0 +1,12 @@ +/** + * Sets one environment variable, or removes it when `value` is undefined. Assigning `undefined` + * to a `process.env` key stores the string "undefined" in Bun 1.4 (as in Node), which then leaks + * into later tests in the same process, so a missing value must be deleted instead. + */ +export function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } +} diff --git a/tests/internal-extra-hosts-worktree.test.ts b/tests/internal-extra-hosts-worktree.test.ts index c0098cfed..96dd82963 100644 --- a/tests/internal-extra-hosts-worktree.test.ts +++ b/tests/internal-extra-hosts-worktree.test.ts @@ -11,17 +11,17 @@ let savedMode: string | undefined; beforeEach(() => { savedCi = process.env.CI; savedMode = process.env.HACK_EXECUTION_MODE; - process.env.CI = undefined; - process.env.HACK_EXECUTION_MODE = undefined; + Reflect.deleteProperty(process.env, "CI"); + Reflect.deleteProperty(process.env, "HACK_EXECUTION_MODE"); }); afterEach(() => { if (savedCi === undefined) { - process.env.CI = undefined; + Reflect.deleteProperty(process.env, "CI"); } else { process.env.CI = savedCi; } if (savedMode === undefined) { - process.env.HACK_EXECUTION_MODE = undefined; + Reflect.deleteProperty(process.env, "HACK_EXECUTION_MODE"); } else { process.env.HACK_EXECUTION_MODE = savedMode; } @@ -216,7 +216,7 @@ test("CI and slim runners do not read primary aliases", async () => { await f.write(f.primary, { "search.test": "host-gateway" }); process.env.CI = "true"; expect((await resolveInternalExtraHosts(f.context)).hosts).toEqual({}); - process.env.CI = undefined; + Reflect.deleteProperty(process.env, "CI"); process.env.HACK_EXECUTION_MODE = "slim"; expect((await resolveInternalExtraHosts(f.context)).hosts).toEqual({}); }); diff --git a/tests/lifecycle-json.test.ts b/tests/lifecycle-json.test.ts index 4e1a5b48b..795a01445 100644 --- a/tests/lifecycle-json.test.ts +++ b/tests/lifecycle-json.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { buildLifecycleJsonData } from "../src/commands/project.ts"; import { setLoggerBackendOverride } from "../src/ui/logger.ts"; +import { restoreEnv } from "./helpers/env.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -27,7 +28,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); }); test("buildLifecycleJsonData shapes the envelope payload with sorted services", () => { diff --git a/tests/mcp-install.test.ts b/tests/mcp-install.test.ts index 2733c1e72..64b86a8f2 100644 --- a/tests/mcp-install.test.ts +++ b/tests/mcp-install.test.ts @@ -9,12 +9,13 @@ import { removeMcpConfig, renderMcpConfigSnippet, } from "../src/mcp/install.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; const originalHome = process.env.HOME; afterEach(async () => { - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); if (tempDir) { await rm(tempDir, { recursive: true, force: true }); tempDir = null; diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index 3e8a70468..3714e8ce5 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { prepareNativeProjectInput } from "../src/backends/native-project-input.ts"; +import { restoreEnv } from "./helpers/env.ts"; const roots: string[] = []; afterEach(async () => { @@ -152,8 +153,8 @@ test("linked worktree inherits primary local environment and observes checkout o const linked = `${opts.projectRoot}-linked`; roots.push(linked); try { - process.env.CI = undefined; - process.env.HACK_EXECUTION_MODE = undefined; + Reflect.deleteProperty(process.env, "CI"); + Reflect.deleteProperty(process.env, "HACK_EXECUTION_MODE"); await writeFile( join(opts.projectDir, "hack.config.json"), '{"name":"native-test"}' @@ -198,7 +199,7 @@ test("linked worktree inherits primary local environment and observes checkout o ); expect(result.normalizedComposeJson).not.toContain("private"); } finally { - process.env.CI = oldCI; - process.env.HACK_EXECUTION_MODE = oldMode; + restoreEnv("CI", oldCI); + restoreEnv("HACK_EXECUTION_MODE", oldMode); } }); diff --git a/tests/node-pairings-registry.test.ts b/tests/node-pairings-registry.test.ts index c68f98a89..01d940d1b 100644 --- a/tests/node-pairings-registry.test.ts +++ b/tests/node-pairings-registry.test.ts @@ -10,6 +10,7 @@ import { listNodePairingSessions, readNodePairingsRegistry, } from "../src/lib/node-pairings-registry.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -20,7 +21,7 @@ beforeEach(async () => { originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; tempDir = await mkdtemp(join(tmpdir(), "hack-node-pairings-")); process.env.HOME = tempDir; - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); }); afterEach(async () => { @@ -28,11 +29,11 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); if (originalGlobalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalGlobalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } }); diff --git a/tests/node-workspace-bootstrap.test.ts b/tests/node-workspace-bootstrap.test.ts index 3e18ad199..a86c95a9e 100644 --- a/tests/node-workspace-bootstrap.test.ts +++ b/tests/node-workspace-bootstrap.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { handleNodeRoutes } from "../src/daemon/routes/node.ts"; import { exec } from "../src/lib/shell.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -14,7 +15,7 @@ beforeEach(async () => { originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; tempDir = await mkdtemp(join(tmpdir(), "hack-node-bootstrap-")); process.env.HOME = tempDir; - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); }); afterEach(async () => { @@ -22,11 +23,11 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); if (originalGlobalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalGlobalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } }); diff --git a/tests/node-workspace-command.test.ts b/tests/node-workspace-command.test.ts index fa0279d69..5c5170913 100644 --- a/tests/node-workspace-command.test.ts +++ b/tests/node-workspace-command.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { __testOnlyNodeWorkspace } from "../src/commands/node.ts"; +import { restoreEnv } from "./helpers/env.ts"; let previousHome: string | undefined; let previousGlobalConfigPath: string | undefined; @@ -10,12 +11,12 @@ beforeEach(() => { }); afterEach(() => { - process.env.HOME = previousHome; + restoreEnv("HOME", previousHome); if (previousGlobalConfigPath === undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); return; } - process.env.HACK_GLOBAL_CONFIG_PATH = previousGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", previousGlobalConfigPath); }); test("parseWorkspaceMapSelector detects id-like selectors", () => { diff --git a/tests/node-workspace-map.test.ts b/tests/node-workspace-map.test.ts index f2bd243ba..aebb76618 100644 --- a/tests/node-workspace-map.test.ts +++ b/tests/node-workspace-map.test.ts @@ -10,6 +10,7 @@ import { resolveNodeWorkspaceMapPath, upsertNodeWorkspaceMapEntry, } from "../src/lib/node-workspace-map.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -20,7 +21,7 @@ beforeEach(async () => { originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; tempDir = await mkdtemp(join(tmpdir(), "hack-node-workspace-map-")); process.env.HOME = tempDir; - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); }); afterEach(async () => { @@ -28,11 +29,11 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); if (originalGlobalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalGlobalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } }); diff --git a/tests/nodes-registry.test.ts b/tests/nodes-registry.test.ts index 13a8ae64f..ba958583a 100644 --- a/tests/nodes-registry.test.ts +++ b/tests/nodes-registry.test.ts @@ -21,6 +21,7 @@ import { touchNode, upsertNodeRecord, } from "../src/lib/nodes-registry.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -33,7 +34,7 @@ beforeEach(async () => { originalSecretsFileKey = process.env.HACK_SECRETS_FILE_KEY; tempDir = await mkdtemp(join(tmpdir(), "hack-nodes-registry-")); process.env.HOME = tempDir; - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); }); afterEach(async () => { @@ -41,16 +42,16 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); if (originalGlobalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", originalGlobalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } if (originalSecretsFileKey !== undefined) { process.env.HACK_SECRETS_FILE_KEY = originalSecretsFileKey; } else { - process.env.HACK_SECRETS_FILE_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); } }); diff --git a/tests/policy-audit.test.ts b/tests/policy-audit.test.ts index 2baac7db2..18a5a5e6e 100644 --- a/tests/policy-audit.test.ts +++ b/tests/policy-audit.test.ts @@ -40,7 +40,7 @@ test("appendPolicyAuditEvent persists JSONL event", async () => { expect(parsed.runId).toBe("run-123"); } finally { if (previous === undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } else { process.env.HACK_GLOBAL_CONFIG_PATH = previous; } diff --git a/tests/project-env-config.test.ts b/tests/project-env-config.test.ts index 43a15d205..1ee539fa7 100644 --- a/tests/project-env-config.test.ts +++ b/tests/project-env-config.test.ts @@ -38,6 +38,7 @@ import { unsetProjectEnvValue, } from "../src/lib/project-env-config.ts"; import { resolveSecretStore } from "../src/lib/secret-store.ts"; +import { restoreEnv } from "./helpers/env.ts"; const tempDirs = new Set(); @@ -46,8 +47,8 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); - process.env.HACK_SECRETS_FILE_KEY = undefined; - process.env.HACK_ENV_SECRET_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); + Reflect.deleteProperty(process.env, "HACK_ENV_SECRET_KEY"); }); async function createRepo(): Promise<{ @@ -662,7 +663,7 @@ test("legacy local overlays stay out of default resolution when git is unavailab resolve(repo.projectDir, "hack.env.default.yaml"), ]); } finally { - process.env.PATH = originalPath; + restoreEnv("PATH", originalPath); } }); @@ -713,7 +714,7 @@ test("default local overrides still resolve when git is unavailable", async () = resolve(repo.projectDir, "hack.env.local.yaml"), ]); } finally { - process.env.PATH = originalPath; + restoreEnv("PATH", originalPath); } }); @@ -967,7 +968,7 @@ test("linked worktrees fall back to the shared git-common-dir env key", async () const sharedKeyText = (await readFile(sharedKeyPath, "utf8")).trim(); expect(sharedKeyText.length).toBeGreaterThan(10); - process.env.HACK_ENV_SECRET_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_ENV_SECRET_KEY"); const resolved = await resolveProjectEnvConfig({ projectRoot: linkedRoot, projectDir, @@ -991,7 +992,7 @@ test("resolveProjectEnvSharedKeyPath returns null when git is unavailable", asyn }) ).resolves.toBeNull(); } finally { - process.env.PATH = originalPath; + restoreEnv("PATH", originalPath); } }); diff --git a/tests/project-env-local-inheritance.test.ts b/tests/project-env-local-inheritance.test.ts index 291ec93de..5bb92c339 100644 --- a/tests/project-env-local-inheritance.test.ts +++ b/tests/project-env-local-inheritance.test.ts @@ -16,14 +16,15 @@ import { setProjectEnvValue, unsetProjectEnvValue, } from "../src/lib/project-env-config.ts"; +import { restoreEnv } from "./helpers/env.ts"; const roots: string[] = []; const originalCI = process.env.CI; const originalMode = process.env.HACK_EXECUTION_MODE; afterEach(async () => { - process.env.CI = originalCI; - process.env.HACK_EXECUTION_MODE = originalMode; + restoreEnv("CI", originalCI); + restoreEnv("HACK_EXECUTION_MODE", originalMode); for (const root of roots.splice(0)) { await rm(root, { recursive: true, force: true }); } @@ -61,8 +62,8 @@ async function config( } async function fixture() { - process.env.CI = undefined; - process.env.HACK_EXECUTION_MODE = undefined; + Reflect.deleteProperty(process.env, "CI"); + Reflect.deleteProperty(process.env, "HACK_EXECUTION_MODE"); const root = await realpath(await mkdtemp(join(tmpdir(), "hack-local-env-"))); roots.push(root); const primaryRoot = join(root, "primary"); @@ -236,7 +237,7 @@ test("CI and slim execution do not inherit primary local environment", async () }); process.env.CI = "true"; expect((await f.resolve())?.globalEnv.PRIMARY).toBeUndefined(); - process.env.CI = undefined; + Reflect.deleteProperty(process.env, "CI"); process.env.HACK_EXECUTION_MODE = "slim"; expect((await f.resolve())?.globalEnv.PRIMARY).toBeUndefined(); }); diff --git a/tests/project-env-worktree-key.test.ts b/tests/project-env-worktree-key.test.ts index 8d130781e..cc7233384 100644 --- a/tests/project-env-worktree-key.test.ts +++ b/tests/project-env-worktree-key.test.ts @@ -34,7 +34,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); - process.env.HACK_ENV_SECRET_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_ENV_SECRET_KEY"); }); async function runGit(args: readonly string[], cwd: string): Promise { diff --git a/tests/project-lifecycle-processes.test.ts b/tests/project-lifecycle-processes.test.ts index 4b93c5465..a1a2986f2 100644 --- a/tests/project-lifecycle-processes.test.ts +++ b/tests/project-lifecycle-processes.test.ts @@ -15,6 +15,7 @@ import { resolveLifecycleStopProcessGroupIds, resolvePersistedLifecycleProcessGroupIds, } from "../src/lib/project-lifecycle-processes.ts"; +import { restoreEnv } from "./helpers/env.ts"; const tempDirs = new Set(); const originalHome = process.env.HOME; @@ -41,7 +42,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); for (const key of trustKeys) { const value = originalTrust[key]; if (value === undefined) { diff --git a/tests/project-owner-command.test.ts b/tests/project-owner-command.test.ts index d2b18d4ec..ee6b86886 100644 --- a/tests/project-owner-command.test.ts +++ b/tests/project-owner-command.test.ts @@ -7,6 +7,7 @@ import { PROJECT_CONFIG_FILENAME, PROJECT_ENV_FILENAME, } from "../src/constants.ts"; +import { restoreEnv } from "./helpers/env.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -29,9 +30,9 @@ afterEach(async () => { tempDir = null; } if (originalLogger !== undefined) { - process.env.HACK_LOGGER = originalLogger; + restoreEnv("HACK_LOGGER", originalLogger); } else { - process.env.HACK_LOGGER = undefined; + Reflect.deleteProperty(process.env, "HACK_LOGGER"); } }); diff --git a/tests/project-up-command.test.ts b/tests/project-up-command.test.ts index 80fada229..41bc8804a 100644 --- a/tests/project-up-command.test.ts +++ b/tests/project-up-command.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { restoreEnv } from "./helpers/env.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -24,9 +25,9 @@ afterEach(async () => { tempDir = null; } if (originalLogger !== undefined) { - process.env.HACK_LOGGER = originalLogger; + restoreEnv("HACK_LOGGER", originalLogger); } else { - process.env.HACK_LOGGER = undefined; + Reflect.deleteProperty(process.env, "HACK_LOGGER"); } }); diff --git a/tests/project-up-startup-state.test.ts b/tests/project-up-startup-state.test.ts index 964a5a1df..4a0ad93cb 100644 --- a/tests/project-up-startup-state.test.ts +++ b/tests/project-up-startup-state.test.ts @@ -19,6 +19,7 @@ import { PROJECT_COMPOSE_FILENAME, PROJECT_CONFIG_FILENAME, } from "../src/constants.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const psRows: string[] = []; @@ -182,7 +183,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); - process.env.HACK_HOME = originalHackHome; + restoreEnv("HACK_HOME", originalHackHome); if (originalComposeProfiles === undefined) { Reflect.deleteProperty(process.env, "COMPOSE_PROFILES"); } else { @@ -839,8 +840,8 @@ async function createProject(opts?: { test("linked startup combines inherited aliases, local static precedence and same-start hook output", async () => { const savedCi = process.env.CI; const savedMode = process.env.HACK_EXECUTION_MODE; - process.env.CI = undefined; - process.env.HACK_EXECUTION_MODE = undefined; + Reflect.deleteProperty(process.env, "CI"); + Reflect.deleteProperty(process.env, "HACK_EXECUTION_MODE"); try { const primary = await createProject({ lifecycleCommand: @@ -894,8 +895,8 @@ test("linked startup combines inherited aliases, local static precedence and sam expect(upComposeContents.at(-1)).toContain("hook.test: 192.0.2.30"); expect(upComposeContents.at(-1)).not.toContain("192.0.2.10"); } finally { - process.env.CI = savedCi; - process.env.HACK_EXECUTION_MODE = savedMode; + restoreEnv("CI", savedCi); + restoreEnv("HACK_EXECUTION_MODE", savedMode); } }); diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index 67572fa66..8f8310f5d 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -17,6 +17,7 @@ import type { RuntimeProject, RuntimeService, } from "../src/lib/runtime-projects.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalGlobalConfigPath: string | undefined; @@ -622,7 +623,7 @@ test("buildProjectViews tolerates missing optional mux binaries", async () => { }); expect(views).toEqual([]); } finally { - process.env.PATH = previousPath; + restoreEnv("PATH", previousPath); await rm(emptyPathDir, { recursive: true, force: true }); } }); diff --git a/tests/projects-registry-worktrees.test.ts b/tests/projects-registry-worktrees.test.ts index 0f38f14c2..b5254cf9d 100644 --- a/tests/projects-registry-worktrees.test.ts +++ b/tests/projects-registry-worktrees.test.ts @@ -13,6 +13,7 @@ import { readProjectsRegistry, upsertProjectRegistration, } from "../src/lib/projects-registry.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -28,7 +29,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); }); function runGit(opts: { diff --git a/tests/projects-registry.test.ts b/tests/projects-registry.test.ts index 7bdc7dd1d..b53c5b35e 100644 --- a/tests/projects-registry.test.ts +++ b/tests/projects-registry.test.ts @@ -20,6 +20,7 @@ import { resolveRegisteredProjectByName, upsertProjectRegistration, } from "../src/lib/projects-registry.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let originalHome: string | undefined; @@ -35,7 +36,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); }); async function writeJson(path: string, value: unknown): Promise { diff --git a/tests/run-exec-branch-default.test.ts b/tests/run-exec-branch-default.test.ts index 589dc3f29..3fb4daa13 100644 --- a/tests/run-exec-branch-default.test.ts +++ b/tests/run-exec-branch-default.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { restoreEnv } from "./helpers/env.ts"; type CapturedRunResult = { readonly exitCode: number; @@ -35,8 +36,8 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = originalHome; - process.env.PATH = originalPath; + restoreEnv("HOME", originalHome); + restoreEnv("PATH", originalPath); if (originalLogger === undefined) { Reflect.deleteProperty(process.env, "HACK_LOGGER"); } else { diff --git a/tests/secret-store.test.ts b/tests/secret-store.test.ts index f25509ced..0a41f02c0 100644 --- a/tests/secret-store.test.ts +++ b/tests/secret-store.test.ts @@ -13,6 +13,7 @@ import { provisionEncryptedFileKey, resolveSecretStore, } from "../src/lib/secret-store.ts"; +import { restoreEnv } from "./helpers/env.ts"; let tempDir: string | null = null; let previousHome: string | undefined; @@ -36,22 +37,25 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); tempDir = null; } - process.env.HOME = previousHome; + restoreEnv("HOME", previousHome); if (previousGlobalConfigPath !== undefined) { - process.env.HACK_GLOBAL_CONFIG_PATH = previousGlobalConfigPath; + restoreEnv("HACK_GLOBAL_CONFIG_PATH", previousGlobalConfigPath); } else { - process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); } if (previousSecretsKey !== undefined) { process.env.HACK_SECRETS_FILE_KEY = previousSecretsKey; } else { - process.env.HACK_SECRETS_FILE_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); } if (previousDisableKeychainFallback !== undefined) { process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = previousDisableKeychainFallback; } else { - process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = undefined; + Reflect.deleteProperty( + process.env, + "HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK" + ); } }); @@ -153,7 +157,7 @@ test("encrypted_file backend reads key material from configured keyPath when env if (!tempDir) { throw new Error("Missing temp dir"); } - process.env.HACK_SECRETS_FILE_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); const storePath = join(tempDir, "secrets.enc.json"); const keyPath = join(tempDir, "secrets-file.key"); await writeFile(keyPath, "stable-file-key\n"); @@ -189,7 +193,7 @@ test("project config resolves encrypted_file paths relative to the repo root", a if (!tempDir) { throw new Error("Missing temp dir"); } - process.env.HACK_SECRETS_FILE_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); const projectRoot = join(tempDir, "repo"); const projectDir = join(projectRoot, ".hack"); const storePath = join(projectRoot, ".hack-secrets.enc.json"); @@ -247,7 +251,7 @@ test("encrypted_file backend returns recovery guidance when key material is miss if (!tempDir) { throw new Error("Missing temp dir"); } - process.env.HACK_SECRETS_FILE_KEY = undefined; + Reflect.deleteProperty(process.env, "HACK_SECRETS_FILE_KEY"); process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "true"; const projectRoot = join(tempDir, "repo-missing-key"); const projectDir = join(projectRoot, ".hack"); diff --git a/tests/session-env-command.test.ts b/tests/session-env-command.test.ts index b3db88051..4f4aa3703 100644 --- a/tests/session-env-command.test.ts +++ b/tests/session-env-command.test.ts @@ -11,6 +11,7 @@ import { } from "../src/constants.ts"; import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import type { MuxBackend } from "../src/mux/mux-backend.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const createSessionCalls: Array<{ @@ -160,7 +161,7 @@ afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); } tempDirs.clear(); - process.env.HOME = originalHome; + restoreEnv("HOME", originalHome); }); afterAll(() => { diff --git a/tests/unix-socket-publish.test.ts b/tests/unix-socket-publish.test.ts new file mode 100644 index 000000000..db44202a2 --- /dev/null +++ b/tests/unix-socket-publish.test.ts @@ -0,0 +1,194 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdtemp, + readdir, + readFile, + realpath, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import { connect, createServer, type Server, type Socket } from "node:net"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { + listenPublishedUnixSocket, + UnixSocketEndpointExists, + type UnixSocketIdentity, +} from "../src/lib/unix-socket-publish.ts"; + +const directories: string[] = []; +const servers: Server[] = []; + +afterEach(async () => { + for (const server of servers.splice(0)) { + if (server.listening) { + await new Promise((resolve) => server.close(() => resolve())); + } + } + for (const directory of directories.splice(0)) { + await rm(directory, { recursive: true, force: true }); + } +}); + +async function privateDirectory(): Promise { + const directory = await mkdtemp(join(await realpath(tmpdir()), "hk-usp-")); + await chmod(directory, 0o700); + directories.push(directory); + return directory; +} + +function echoServer(clients?: Set): Server { + const server = createServer((socket) => { + clients?.add(socket); + socket.on("close", () => clients?.delete(socket)); + socket.on("error", () => undefined); + socket.end("ok"); + }); + servers.push(server); + return server; +} + +async function reply(path: string): Promise { + return await new Promise((resolve, reject) => { + const socket = connect(path); + let text = ""; + socket.on("data", (chunk) => { + text += String(chunk); + }); + socket.on("end", () => resolve(text)); + socket.on("error", reject); + }); +} + +async function close(server: Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +/** The caller's cleanup: remove the endpoint only while it is still this socket. */ +async function removeOwned(path: string, identity: UnixSocketIdentity) { + const current = await lstat(path).catch(() => null); + if (current?.dev === identity.dev && current.ino === identity.ino) { + await unlink(path); + } +} + +test("a published endpoint serves clients, survives close and is removed only by identity", async () => { + const directory = await privateDirectory(); + const path = join(directory, "mcp.sock"); + const server = echoServer(); + const bound: string[] = []; + const listen = server.listen.bind(server) as (...args: unknown[]) => Server; + server.listen = ((...args: unknown[]) => { + bound.push(String(args[0])); + return listen(...args); + }) as typeof server.listen; + const identity = await listenPublishedUnixSocket(server, path); + // The runtime binds a staging name in the same directory, never longer than the + // endpoint's own name; only the published endpoint remains. + expect(bound).toHaveLength(1); + const staging = bound[0] ?? ""; + expect(dirname(staging)).toBe(directory); + expect(basename(staging)).not.toBe("mcp.sock"); + expect(basename(staging).length).toBeLessThanOrEqual("mcp.sock".length); + expect(await readdir(directory)).toEqual(["mcp.sock"]); + const stat = await lstat(path); + expect(stat.isSocket()).toBe(true); + expect(stat.mode & 0o777 & 0o077).toBe(0); + expect({ dev: stat.dev, ino: stat.ino }).toEqual(identity); + expect(await reply(path)).toBe("ok"); + await close(server); + // Closing never removes the endpoint; the owner's identity-checked cleanup does. + const after = await lstat(path); + expect({ dev: after.dev, ino: after.ino }).toEqual(identity); + await removeOwned(path, identity); + expect(await readdir(directory)).toEqual([]); +}); + +test("a replacement at the endpoint survives normal and forced close", async () => { + for (const forced of [false, true]) { + const directory = await privateDirectory(); + const path = join(directory, "owner.sock"); + const clients = new Set(); + const server = echoServer(clients); + const identity = await listenPublishedUnixSocket(server, path); + const held = connect(path); + held.on("error", () => undefined); + await new Promise((resolve) => held.once("connect", () => resolve())); + await unlink(path); + await writeFile(path, "foreign replacement", { mode: 0o600 }); + if (forced) { + for (const client of clients) { + client.destroy(); + } + } + held.destroy(); + await close(server); + expect(await readFile(path, "utf8")).toBe("foreign replacement"); + await removeOwned(path, identity); + expect(await readFile(path, "utf8")).toBe("foreign replacement"); + expect(await readdir(directory)).toEqual(["owner.sock"]); + } +}); + +test("an existing endpoint refuses startup and is never replaced", async () => { + const directory = await privateDirectory(); + const path = join(directory, "control.sock"); + await writeFile(path, "existing endpoint", { mode: 0o600 }); + const server = echoServer(); + await expect(listenPublishedUnixSocket(server, path)).rejects.toBeInstanceOf( + UnixSocketEndpointExists + ); + expect(server.listening).toBe(false); + expect(await readFile(path, "utf8")).toBe("existing endpoint"); + expect(await readdir(directory)).toEqual(["control.sock"]); +}); + +test("concurrent owners publish exactly one endpoint and the other leaves it intact", async () => { + const directory = await privateDirectory(); + const path = join(directory, "mcp.sock"); + const first = echoServer(); + const second = echoServer(); + const results = await Promise.allSettled([ + listenPublishedUnixSocket(first, path), + listenPublishedUnixSocket(second, path), + ]); + const won = results.filter((result) => result.status === "fulfilled"); + const lost = results.filter((result) => result.status === "rejected"); + expect(won).toHaveLength(1); + expect(lost).toHaveLength(1); + expect((lost[0] as PromiseRejectedResult).reason).toBeInstanceOf( + UnixSocketEndpointExists + ); + expect([first.listening, second.listening].filter(Boolean)).toHaveLength(1); + const identity = (won[0] as PromiseFulfilledResult).value; + const stat = await lstat(path); + expect({ dev: stat.dev, ino: stat.ino }).toEqual(identity); + expect(await reply(path)).toBe("ok"); + expect(await readdir(directory)).toEqual(["mcp.sock"]); +}); + +test("a restart after owned cleanup publishes a fresh endpoint at the same path", async () => { + const directory = await privateDirectory(); + const path = join(directory, "mcp.sock"); + const first = echoServer(); + const initial = await listenPublishedUnixSocket(first, path); + await close(first); + await removeOwned(path, initial); + const second = echoServer(); + const restarted = await listenPublishedUnixSocket(second, path); + expect(restarted).not.toEqual(initial); + expect(await reply(path)).toBe("ok"); + expect(await readdir(directory)).toEqual(["mcp.sock"]); +}); + +test("a startup that cannot bind creates nothing", async () => { + const directory = await privateDirectory(); + const path = join(directory, "absent", "mcp.sock"); + const server = echoServer(); + await expect(listenPublishedUnixSocket(server, path)).rejects.toThrow(); + expect(server.listening).toBe(false); + expect(await readdir(directory)).toEqual([]); +});