From 61a8111791d21ac570f8e35185d17419a566dc72 Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Wed, 30 Sep 2026 09:47:02 -0400 Subject: [PATCH 1/2] fix: publish Unix socket endpoints by link so a runtime close never removes a replacement From Bun 1.3.14, closing a node:net Unix-socket server unlinks its bound path by name, whatever that path holds by then, and Bun also replaces an existing file at the bound path on listen. The MCP socket backend, the native HTTPS owner and the project HTTPS owner challenge bound their public endpoint directly, so a close could remove a foreign replacement their identity-checked cleanup was written to preserve. listenPublishedUnixSocket binds a fresh owner-only staging name in the same private directory (never longer than the endpoint's own name), then publishes it with link(2), which refuses an existing endpoint instead of replacing it, verifies the published inode and removes the staging name. A runtime close-time unlink then finds nothing, and the endpoint is removed only by each caller's identity-checked cleanup. The endpoint path, mode and recorded identity are unchanged. Controls cover normal and forced close, replacement, an existing endpoint, concurrent owners, restart and failed startup on Bun 1.3.9 and 1.4.2. --- src/backends/native-https-owner-server.ts | 11 +- src/backends/native-project-https.ts | 21 +-- src/lib/unix-socket-publish.ts | 141 ++++++++++++++++ src/mcp/socket-backend.ts | 29 ++-- tests/unix-socket-publish.test.ts | 194 ++++++++++++++++++++++ 5 files changed, 359 insertions(+), 37 deletions(-) create mode 100644 src/lib/unix-socket-publish.ts create mode 100644 tests/unix-socket-publish.test.ts 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/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([]); +}); From c86576b2a91e0a12b7db1ee0e1b647751304092b Mon Sep 17 00:00:00 2001 From: hack-cli-tests Date: Wed, 30 Sep 2026 09:47:02 -0400 Subject: [PATCH 2/2] test: delete unset environment variables instead of assigning undefined Bun 1.4, like Node, stores the string "undefined" when a process.env key is assigned undefined. Tests that unset variables that way, or restored a captured value that was never set, left "undefined" behind, and it leaked into later files in the same bun test process. Literal unsets now use Reflect.deleteProperty, and restores of captured values go through a shared restoreEnv helper that deletes a missing value. --- tests/cli-command.test.ts | 3 ++- tests/clipboard.test.ts | 3 ++- tests/config-command.test.ts | 7 ++++--- tests/config-paths.test.ts | 12 ++++++++---- tests/config.test.ts | 5 +++-- tests/control-plane-config.test.ts | 2 +- tests/control-plane-extensions.test.ts | 2 +- tests/daemon-autostart-bounded.test.ts | 3 ++- tests/daemon-client.test.ts | 3 ++- tests/daemon-command.test.ts | 5 +++-- tests/daemon-env.test.ts | 3 ++- tests/daemon-paths.test.ts | 3 ++- tests/dependency-cache.test.ts | 5 +++-- tests/dispatch-e2e.test.ts | 7 ++++--- tests/disposable-cache-volumes.test.ts | 3 ++- tests/doctor-fix-noninteractive.test.ts | 5 +++-- tests/env-backend-command.test.ts | 7 ++++--- tests/env-command-modern.test.ts | 6 +++--- tests/env-exec-command.test.ts | 5 +++-- tests/global-command.macos.test.ts | 13 +++++++------ tests/global-command.test.ts | 5 +++-- tests/hack-env.test.ts | 5 +++-- tests/helpers/env.ts | 12 ++++++++++++ tests/internal-extra-hosts-worktree.test.ts | 10 +++++----- tests/lifecycle-json.test.ts | 3 ++- tests/mcp-install.test.ts | 3 ++- tests/native-project-input.test.ts | 9 +++++---- tests/node-pairings-registry.test.ts | 9 +++++---- tests/node-workspace-bootstrap.test.ts | 9 +++++---- tests/node-workspace-command.test.ts | 7 ++++--- tests/node-workspace-map.test.ts | 9 +++++---- tests/nodes-registry.test.ts | 11 ++++++----- tests/policy-audit.test.ts | 2 +- tests/project-env-config.test.ts | 13 +++++++------ tests/project-env-local-inheritance.test.ts | 11 ++++++----- tests/project-env-worktree-key.test.ts | 2 +- tests/project-lifecycle-processes.test.ts | 3 ++- tests/project-owner-command.test.ts | 5 +++-- tests/project-up-command.test.ts | 5 +++-- tests/project-up-startup-state.test.ts | 11 ++++++----- tests/project-views.test.ts | 3 ++- tests/projects-registry-worktrees.test.ts | 3 ++- tests/projects-registry.test.ts | 3 ++- tests/run-exec-branch-default.test.ts | 5 +++-- tests/secret-store.test.ts | 20 ++++++++++++-------- tests/session-env-command.test.ts | 3 ++- 46 files changed, 170 insertions(+), 113 deletions(-) create mode 100644 tests/helpers/env.ts 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(() => {