fix: use process.env.HOME instead of os.homedir() for test sandboxing (#2417)

Bun's os.homedir() reads from getpwuid() and ignores runtime changes to
process.env.HOME. Named imports capture the native function binding, so
patching os.homedir on the default export doesn't propagate. This caused
all test files using homedir() to write .spawn-test-* dirs to the real
home directory instead of the preload sandbox.

Add getUserHome() helper to shared/ui.ts that prefers process.env.HOME,
replace all direct homedir() calls in production and test code.

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-03-10 00:20:19 -07:00 committed by GitHub
parent b1afa4615f
commit 486aba49f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 72 additions and 58 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.15.33",
"version": "0.15.34",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { clearHistory, filterHistory, getHistoryPath, loadHistory, saveSpawnRecord } from "../history.js";
import { mockClackPrompts } from "./test-helpers";
@ -21,7 +20,7 @@ describe("clearHistory", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
@ -294,7 +293,7 @@ describe("cmdListClear", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});

View file

@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { homedir } from "node:os";
import { loadManifest } from "../manifest";
import { isString } from "../shared/type-guards";
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
@ -65,7 +64,7 @@ describe("cmdInteractive", () => {
// Isolate from host history so getActiveServers() returns []
originalSpawnHome = process.env.SPAWN_HOME;
process.env.SPAWN_HOME = `${homedir()}/.spawn-test-${Date.now()}`;
process.env.SPAWN_HOME = `${process.env.HOME ?? ""}/.spawn-test-${Date.now()}`;
mockLogError.mockClear();
mockLogInfo.mockClear();
mockLogStep.mockClear();

View file

@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history";
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
@ -54,7 +53,7 @@ describe("cmdLast", () => {
}
beforeEach(async () => {
testDir = join(homedir(), `spawn-cmdlast-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `spawn-cmdlast-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});

View file

@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history";
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
@ -63,7 +62,7 @@ describe("cmdList integration", () => {
}
beforeEach(async () => {
testDir = join(homedir(), `spawn-cmdlist-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `spawn-cmdlist-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});

View file

@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadManifest } from "../manifest";
import { isString } from "../shared/type-guards";
@ -100,7 +99,7 @@ describe("cmdRun --name duplicate detection", () => {
originalSpawnHome = process.env.SPAWN_HOME;
originalSpawnName = process.env.SPAWN_NAME;
historyDir = join(homedir(), `spawn-dup-test-${Date.now()}-${Math.random()}`);
historyDir = join(process.env.HOME ?? "", `spawn-dup-test-${Date.now()}-${Math.random()}`);
mkdirSync(historyDir, {
recursive: true,
});

View file

@ -1,6 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { HISTORY_SCHEMA_VERSION } from "../history.js";
import { loadManifest } from "../manifest";
@ -134,7 +133,7 @@ describe("cmdRun happy-path pipeline", () => {
originalFetch = global.fetch;
// Set up isolated history directory
historyDir = join(homedir(), `spawn-test-history-${Date.now()}-${Math.random()}`);
historyDir = join(process.env.HOME ?? "", `spawn-test-history-${Date.now()}-${Math.random()}`);
mkdirSync(historyDir, {
recursive: true,
});
@ -340,7 +339,7 @@ describe("cmdRun happy-path pipeline", () => {
it("should still execute script when history save fails", async () => {
// Make history dir read-only to force saveSpawnRecord failure
const readOnlyDir = join(homedir(), `spawn-test-readonly-${Date.now()}`);
const readOnlyDir = join(process.env.HOME ?? "", `spawn-test-readonly-${Date.now()}`);
mkdirSync(readOnlyDir, {
recursive: true,
});

View file

@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { loadHistory, saveSpawnRecord } from "../history.js";
@ -12,7 +11,7 @@ describe("history corruption recovery", () => {
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
testDir = join(homedir(), `.spawn-test-corrupt-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-corrupt-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});

View file

@ -13,7 +13,6 @@ import type { SpawnRecord } from "../history.js";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
generateSpawnId,
@ -30,7 +29,7 @@ describe("history spawn IDs", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});

View file

@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { filterHistory, HISTORY_SCHEMA_VERSION, loadHistory, saveSpawnRecord } from "../history.js";
@ -31,7 +30,7 @@ describe("History Trimming and Boundaries", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
testDir = join(homedir(), `spawn-history-trim-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `spawn-history-trim-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});

View file

@ -2,7 +2,6 @@ import type { SpawnRecord } from "../history.js";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
filterHistory,
@ -19,7 +18,7 @@ describe("history", () => {
beforeEach(() => {
// Use a directory within home directory for testing (required by security validation)
testDir = join(homedir(), `.spawn-test-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});
@ -43,14 +42,14 @@ describe("history", () => {
describe("getSpawnDir", () => {
it("returns SPAWN_HOME when set to valid path within home", () => {
const validPath = join(homedir(), "custom", "spawn", "dir");
const validPath = join(process.env.HOME ?? "", "custom", "spawn", "dir");
process.env.SPAWN_HOME = validPath;
expect(getSpawnDir()).toBe(validPath);
});
it("falls back to ~/.spawn when SPAWN_HOME is not set", () => {
delete process.env.SPAWN_HOME;
expect(getSpawnDir()).toBe(join(homedir(), ".spawn"));
expect(getSpawnDir()).toBe(join(process.env.HOME ?? "", ".spawn"));
});
it("throws for relative SPAWN_HOME path", () => {
@ -64,13 +63,13 @@ describe("history", () => {
});
it("resolves .. segments in absolute SPAWN_HOME within home", () => {
const pathWithDots = join(homedir(), "foo", "..", "bar");
const pathWithDots = join(process.env.HOME ?? "", "foo", "..", "bar");
process.env.SPAWN_HOME = pathWithDots;
expect(getSpawnDir()).toBe(join(homedir(), "bar"));
expect(getSpawnDir()).toBe(join(process.env.HOME ?? "", "bar"));
});
it("accepts normal absolute SPAWN_HOME within home", () => {
const validPath = join(homedir(), ".spawn");
const validPath = join(process.env.HOME ?? "", ".spawn");
process.env.SPAWN_HOME = validPath;
expect(getSpawnDir()).toBe(validPath);
});
@ -83,14 +82,14 @@ describe("history", () => {
it("throws for path traversal attempt to escape home directory", () => {
// Attempt to traverse outside home using .. segments
// e.g., /home/user/../../etc/.spawn
const traversalPath = join(homedir(), "..", "..", "etc", ".spawn");
const traversalPath = join(process.env.HOME ?? "", "..", "..", "etc", ".spawn");
process.env.SPAWN_HOME = traversalPath;
expect(() => getSpawnDir()).toThrow("must be within your home directory");
});
it("accepts home directory itself as SPAWN_HOME", () => {
process.env.SPAWN_HOME = homedir();
expect(getSpawnDir()).toBe(homedir());
process.env.SPAWN_HOME = process.env.HOME ?? "";
expect(getSpawnDir()).toBe(process.env.HOME ?? "");
});
});
@ -247,7 +246,7 @@ describe("history", () => {
describe("saveSpawnRecord", () => {
it("creates directory and file when neither exist", () => {
const nestedDir = join(homedir(), ".spawn-test", "nested", "spawn");
const nestedDir = join(process.env.HOME ?? "", ".spawn-test", "nested", "spawn");
process.env.SPAWN_HOME = nestedDir;
saveSpawnRecord({
@ -263,7 +262,7 @@ describe("history", () => {
expect(data.records[0].agent).toBe("claude");
// Clean up
rmSync(join(homedir(), ".spawn-test"), {
rmSync(join(process.env.HOME ?? "", ".spawn-test"), {
recursive: true,
force: true,
});

View file

@ -12,7 +12,6 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { mkdirSync, rmSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { isNumber } from "../shared/type-guards.js";
@ -110,7 +109,7 @@ describe("runOrchestration", () => {
beforeEach(() => {
capturedExitCode = undefined;
// Isolate history writes to a temp directory so tests never pollute ~/.spawn
testDir = join(homedir(), `.spawn-test-orch-${Date.now()}-${Math.random()}`);
testDir = join(process.env.HOME ?? "", `.spawn-test-orch-${Date.now()}-${Math.random()}`);
mkdirSync(testDir, {
recursive: true,
});

View file

@ -24,7 +24,7 @@
*/
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import os, { tmpdir } from "node:os";
import { join } from "node:path";
// ── Stray test file cleanup ──────────────────────────────────────────────────
@ -67,6 +67,22 @@ process.env.XDG_CACHE_HOME = join(TEST_HOME, ".cache");
process.env.XDG_CONFIG_HOME = join(TEST_HOME, ".config");
process.env.XDG_DATA_HOME = join(TEST_HOME, ".local", "share");
// ── IMPORTANT: Bun's os.homedir() ignores process.env.HOME ──────────────
//
// Bun's os.homedir() reads from getpwuid() and never re-checks env vars.
// Named imports (`import { homedir } from "node:os"`) capture a binding to
// the native function, so patching `os.homedir` on the default export does
// NOT propagate to other modules' destructured imports.
//
// The ONLY reliable way to sandbox homedir in tests is to ensure all code
// uses `process.env.HOME` (which the preload controls) rather than calling
// `homedir()` directly. Production code uses `getUserHome()` from
// shared/ui.ts; test files should use `process.env.HOME ?? ""`.
//
// This default-export patch catches direct `os.homedir()` calls (rare) but
// cannot fix `import { homedir } from "node:os"` in other modules.
os.homedir = () => TEST_HOME;
// Pre-create common directories tests might expect
mkdirSync(join(TEST_HOME, ".cache"), {
recursive: true,

View file

@ -4,7 +4,6 @@ import type { VMConnection } from "../history.js";
import type { CloudInitTier } from "../shared/agents";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { handleBillingError, isBillingError, showNonBillingError } from "../shared/billing-guidance";
import { getPackagesForTier, NODE_INSTALL_CMD, needsBun, needsNode } from "../shared/cloud-init";
@ -19,6 +18,7 @@ import {
import { ensureSshKeys, getSshKeyOpts } from "../shared/ssh-keys";
import {
getServerNameFromEnv,
getUserHome,
logError,
logInfo,
logStep,
@ -177,7 +177,7 @@ function getGcloudCmd(): string | null {
}
// Check common install locations
const paths = [
join(process.env.HOME || homedir(), "google-cloud-sdk/bin/gcloud"),
join(getUserHome(), "google-cloud-sdk/bin/gcloud"),
"/usr/lib/google-cloud-sdk/bin/gcloud",
"/snap/bin/gcloud",
];
@ -389,7 +389,7 @@ export async function ensureGcloudCli(): Promise<void> {
}
// Add to PATH
const sdkBin = join(process.env.HOME || homedir(), "google-cloud-sdk/bin");
const sdkBin = join(getUserHome(), "google-cloud-sdk/bin");
if (!process.env.PATH?.includes(sdkBin)) {
process.env.PATH = `${sdkBin}:${process.env.PATH}`;
}

View file

@ -9,12 +9,11 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import * as v from "valibot";
import { tryCatch } from "./shared/result.js";
import { getErrorMessage } from "./shared/type-guards.js";
import { logDebug, logWarn } from "./shared/ui.js";
import { getUserHome, logDebug, logWarn } from "./shared/ui.js";
export interface VMConnection {
ip: string;
@ -87,7 +86,7 @@ export function generateSpawnId(): string {
export function getSpawnDir(): string {
const spawnHome = process.env.SPAWN_HOME;
if (!spawnHome) {
return join(homedir(), ".spawn");
return join(getUserHome(), ".spawn");
}
// Require absolute path to prevent path traversal via relative paths
if (!isAbsolute(spawnHome)) {
@ -102,7 +101,7 @@ export function getSpawnDir(): string {
// Even though the path is absolute, resolve() can normalize paths like
// /tmp/../../root/.spawn to /root/.spawn, potentially allowing unauthorized
// file writes to sensitive directories.
const userHome = homedir();
const userHome = getUserHome();
if (!resolved.startsWith(userHome + "/") && resolved !== userHome) {
throw new Error("SPAWN_HOME must be within your home directory.\n" + `Got: ${resolved}\n` + `Home: ${userHome}`);
}

View file

@ -1,9 +1,9 @@
// local/local.ts — Core local provider: runs commands on the user's machine
import { copyFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname } from "node:path";
import { spawnInteractive } from "../shared/ssh";
import { getUserHome } from "../shared/ui";
// ─── Execution ───────────────────────────────────────────────────────────────
@ -34,7 +34,7 @@ export async function runLocal(cmd: string): Promise<void> {
/** Copy a file locally, expanding ~ in the destination path. */
export function uploadFile(localPath: string, remotePath: string): void {
const expanded = remotePath.replace(/^~/, process.env.HOME || homedir());
const expanded = remotePath.replace(/^~/, getUserHome());
mkdirSync(dirname(expanded), {
recursive: true,
});

View file

@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { getErrorMessage } from "./shared/type-guards.js";
import { getUserHome } from "./shared/ui.js";
// ── Types ──────────────────────────────────────────────────────────────────────
@ -74,7 +74,7 @@ const SPAWN_CDN = "https://openrouter.ai/labs/spawn" as const;
const VERSION_URL = `https://github.com/${REPO}/releases/download/cli-latest/version` as const;
// Dynamic getters so tests can override XDG_CACHE_HOME at runtime
function getCacheDir(): string {
return join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "spawn");
return join(process.env.XDG_CACHE_HOME || join(getUserHome(), ".cache"), "spawn");
}
function getCacheFile(): string {
return join(getCacheDir(), "manifest.json");

View file

@ -1,9 +1,8 @@
// shared/ssh-keys.ts — SSH key discovery, selection, and generation
import { existsSync, mkdirSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { logInfo, logStep } from "./ui";
import { getUserHome, logInfo, logStep } from "./ui";
// ─── Types ──────────────────────────────────────────────────────────────────
@ -29,7 +28,7 @@ export function _resetCache(): void {
/** Scan ~/.ssh/ for valid key pairs and extract key types. */
export function discoverSshKeys(): SshKeyPair[] {
const sshDir = join(process.env.HOME || homedir(), ".ssh");
const sshDir = join(getUserHome(), ".ssh");
if (!existsSync(sshDir)) {
return [];
}
@ -115,7 +114,7 @@ function getKeyType(pubPath: string): string {
/** Generate a new ed25519 key at ~/.ssh/id_ed25519. Returns the pair. */
export function generateSshKey(): SshKeyPair {
const sshDir = join(process.env.HOME || homedir(), ".ssh");
const sshDir = join(getUserHome(), ".ssh");
const privPath = `${sshDir}/id_ed25519`;
const pubPath = `${privPath}.pub`;

View file

@ -7,6 +7,18 @@ import { join } from "node:path";
import * as p from "@clack/prompts";
import { isString } from "./type-guards";
/**
* Return the user's home directory, preferring process.env.HOME.
*
* Bun's os.homedir() reads from getpwuid() and ignores runtime changes to
* process.env.HOME. Named imports (`import { homedir } from "node:os"`)
* capture a binding to the native function that cannot be patched by test
* preloads. Using process.env.HOME first ensures the test sandbox is respected.
*/
export function getUserHome(): string {
return process.env.HOME || homedir();
}
const RED = "\x1b[0;31m";
const GREEN = "\x1b[0;32m";
const YELLOW = "\x1b[1;33m";
@ -232,7 +244,7 @@ export async function withRetry<T>(
* Shared by all cloud modules to avoid repeating the same path construction.
*/
export function getSpawnCloudConfigPath(cloud: string): string {
return join(process.env.HOME || homedir(), ".config", "spawn", `${cloud}.json`);
return join(getUserHome(), ".config", "spawn", `${cloud}.json`);
}
/**

View file

@ -3,12 +3,12 @@
import type { VMConnection } from "../history.js";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { killWithTimeout, sleep, spawnInteractive } from "../shared/ssh";
import { getErrorMessage } from "../shared/type-guards";
import {
getServerNameFromEnv,
getUserHome,
logError,
logInfo,
logStep,
@ -112,7 +112,7 @@ function getSpriteCmd(): string | null {
return "sprite";
}
const commonPaths = [
join(process.env.HOME || homedir(), ".local/bin/sprite"),
join(getUserHome(), ".local/bin/sprite"),
"/data/data/com.termux/files/usr/bin/sprite",
"/usr/local/bin/sprite",
"/usr/bin/sprite",
@ -168,7 +168,7 @@ export async function ensureSpriteCli(): Promise<void> {
}
// Add to PATH
const localBin = join(process.env.HOME || homedir(), ".local/bin");
const localBin = join(getUserHome(), ".local/bin");
if (!process.env.PATH?.includes(localBin)) {
process.env.PATH = `${localBin}:${process.env.PATH}`;
}

View file

@ -3,14 +3,13 @@ import type { ExecFileSyncOptions } from "node:child_process";
import { execFileSync as nodeExecFileSync } from "node:child_process";
import fs from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import pc from "picocolors";
import pkg from "../package.json" with { type: "json" };
import { RAW_BASE, SPAWN_CDN, VERSION_URL } from "./manifest.js";
import { PkgVersionSchema, parseJsonWith } from "./shared/parse";
import { getErrorMessage, hasStatus } from "./shared/type-guards";
import { logDebug, logWarn } from "./shared/ui";
import { getUserHome, logDebug, logWarn } from "./shared/ui";
const VERSION = pkg.version;
@ -84,7 +83,7 @@ function compareVersions(current: string, latest: string): boolean {
// ── Failure Backoff ──────────────────────────────────────────────────────────
function getUpdateFailedPath(): string {
return path.join(process.env.HOME || homedir(), ".config", "spawn", ".update-failed");
return path.join(getUserHome(), ".config", "spawn", ".update-failed");
}
function isUpdateBackedOff(): boolean {