From 8bad2f5a014223f303fc056fe025c8b573338afb Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Wed, 21 Oct 2026 15:57:54 +0800 Subject: [PATCH] fix(test): isolate provider homes outside native Codex e2e files (#1586) The test setup left `CLAUDE_CONFIG_DIR` and the passwd home outside the fixture, and exporting `OPENRIG_E2E_REAL_CODEX=1` turned off home isolation for the whole run, so ordinary suites could read or write a developer's real provider homes. Ordinary suites now stay fixture-scoped even with the real-Codex opt-in exported; the exception is limited to the native Codex end-to-end files. Inherited provider roots (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`) are isolated, while a provider home a test passes explicitly in its `baseEnv` is kept, so scenario environments that set one on purpose still work. Default Codex thread discovery now follows `HOME` (`os.homedir()`) instead of the passwd entry, so it stays inside the fixture too. `docs/as-built/test-layers.md` describes the layers. Thanks to @AdamWhitehurst for the report and to @Aschenbath for the fix. --- docs/as-built/test-layers.md | 9 +++- packages/daemon/src/domain/codex-thread-id.ts | 14 ++----- packages/daemon/test/codex-thread-id.test.ts | 42 +++++++++++++++++-- .../daemon/test/fixture-home-cleanup.test.ts | 22 +++++++++- .../test/fixtures/fixture-home-child.ts | 8 +++- packages/daemon/test/helpers/hermetic-env.ts | 13 ++++-- .../daemon/test/hermetic-scaffold.test.ts | 32 +++++++++++++- packages/daemon/test/startup.test.ts | 12 +++--- .../daemon/test/stub-runtimes-on-path.test.ts | 24 +++++++++-- test/hermetic-env.setup.ts | 14 ++++++- test/stub-runtimes.ts | 16 +++++-- 11 files changed, 168 insertions(+), 38 deletions(-) diff --git a/docs/as-built/test-layers.md b/docs/as-built/test-layers.md index 0850b1610..799fd342e 100644 --- a/docs/as-built/test-layers.md +++ b/docs/as-built/test-layers.md @@ -71,8 +71,13 @@ are not yet runnable checks. database, workspace and topology roots, seat identity, bearer tokens), forces a fixture `OPENRIG_HOME` and fails if the home is not fixture-scoped. It also wraps `fetch`, so a real request to anything other than a loopback ephemeral-port fixture or a registered target fails. - Unless `OPENRIG_E2E_REAL_CODEX=1`, it points `HOME` and the XDG directories at a fixture user - home and clears `CODEX_HOME`; that opt-in keeps the real homes. A test can still set its own + It points `HOME` and the XDG directories at a fixture user home and clears `CODEX_HOME` and + `CLAUDE_CONFIG_DIR`. `OPENRIG_E2E_REAL_CODEX=1` keeps native homes and binaries only for + `seat-handover-model-fidelity-e2e.test.ts` and `seat-lifecycle-set-model-resume-e2e.test.ts`; + all other files remain isolated. The scenario scaffold always uses fixture homes and runtime stubs. + The two native files rely on Vitest's default per-file isolation; runs without it are unsupported. + Scenario scaffolds scrub inherited provider roots but preserve those passed in an explicit `baseEnv`. + A test can still set its own overrides after setup. The UI suite (jsdom) uses its own `packages/ui/test/setup.ts`. - **Stale vendored daemon.** `npm run build:package` leaves an assembled copy of the daemon at diff --git a/packages/daemon/src/domain/codex-thread-id.ts b/packages/daemon/src/domain/codex-thread-id.ts index e1f493b6c..328709d92 100644 --- a/packages/daemon/src/domain/codex-thread-id.ts +++ b/packages/daemon/src/domain/codex-thread-id.ts @@ -203,7 +203,7 @@ export class CodexThreadIdResolver { const readFromLogs = injected ? (p: number, home: string, report?: CodexCaptureReporter) => injected(p, home, report, startSecond) : (p: number, home: string, report?: CodexCaptureReporter) => readCodexThreadIdFromLogs(p, home, undefined, minTs, undefined, report, startSecond); - const defaultHome = this.opts.defaultHome ?? safeUserHomeDir() ?? os.homedir(); + const defaultHome = this.opts.defaultHome ?? os.homedir(); const now = this.opts.now ?? Date.now; const ttl = this.opts.homeTtlMs ?? 60_000; @@ -285,19 +285,11 @@ void _resolveRequiresIdentityPin; function uniqueHomes(candidateHomes: Array): string[] { const homes = candidateHomes.filter((home): home is string => Boolean(home)); - const userHome = safeUserHomeDir(); - if (userHome) homes.push(userHome); + // os.homedir() honors HOME. The passwd home must not bypass an explicit provider/test home. + homes.push(os.homedir()); return [...new Set(homes)]; } -function safeUserHomeDir(): string | undefined { - try { - return os.userInfo().homedir; - } catch { - return undefined; - } -} - /** How a gated read treats rows logged inside the identity's own start second (see {@link lstartToMinTs}). */ export type StartSecondRows = /** Exclude them: the default, for every caller that can wait for a later row. */ diff --git a/packages/daemon/test/codex-thread-id.test.ts b/packages/daemon/test/codex-thread-id.test.ts index 343278437..e2e146557 100644 --- a/packages/daemon/test/codex-thread-id.test.ts +++ b/packages/daemon/test/codex-thread-id.test.ts @@ -2,11 +2,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import Database from "better-sqlite3"; -import { afterEach, describe, expect, it } from "vitest"; -import { CodexThreadIdResolver, lstartToMinTs } from "../src/domain/codex-thread-id.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CodexThreadIdResolver, lstartToMinTs, readCodexThreadIdFromCandidateHomes } from "../src/domain/codex-thread-id.js"; const roots: string[] = []; -afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); const identity = "Sun Sep 6 23:52:32 2026"; const start = lstartToMinTs(identity)!; function fixture() { @@ -24,6 +28,38 @@ function fixture() { }; } +describe("Codex home discovery respects an explicit HOME", () => { + it("reads the default home before resolving a process home and never consults passwd", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-default-home-")); roots.push(home); + vi.stubEnv("HOME", home); + const passwd = vi.spyOn(os, "userInfo").mockImplementation(() => { throw new Error("passwd home must not be read"); }); + const processHome = vi.fn(() => "/synthetic-other-home"); + const read = vi.fn((_pid: number, candidate: string) => candidate === home ? "conversation" : undefined); + const resolver = new CodexThreadIdResolver({ readFromLogs: read, resolveHomeDirByPid: processHome }); + expect(await resolver.resolve(60422, identity)).toBe("conversation"); + expect(read).toHaveBeenCalledExactlyOnceWith(60422, home, expect.any(Function), "exclude"); + expect(processHome).not.toHaveBeenCalled(); + expect(passwd).not.toHaveBeenCalled(); + }); + + it("candidate-home fallback finds fixture logs without searching the passwd home", () => { + const f = fixture(); f.thread("conversation"); f.log("conversation"); f.finish(); + vi.stubEnv("HOME", f.home); + const passwd = vi.spyOn(os, "userInfo").mockImplementation(() => { throw new Error("passwd home must not be read"); }); + expect(readCodexThreadIdFromCandidateHomes(60422, [])).toBe("conversation"); + expect(passwd).not.toHaveBeenCalled(); + }); + + it("an empty fixture home never resolves another home's conversation", () => { + const foreign = fixture(); foreign.thread("foreign-conversation"); foreign.log("foreign-conversation"); foreign.finish(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-empty-home-")); roots.push(home); + vi.stubEnv("HOME", home); + const passwd = vi.spyOn(os, "userInfo").mockReturnValue({ homedir: foreign.home } as ReturnType); + expect(readCodexThreadIdFromCandidateHomes(60422, [])).toBeUndefined(); + expect(passwd).not.toHaveBeenCalled(); + }); +}); + describe("native conversation identity excludes same-process auxiliary threads", () => { it("resolves the first native conversation while title generation is the newest log activity", async () => { const f = fixture(); f.thread("conversation"); f.log("conversation"); f.log("title", start + 2); diff --git a/packages/daemon/test/fixture-home-cleanup.test.ts b/packages/daemon/test/fixture-home-cleanup.test.ts index 913dd8f2b..3669f1ff0 100644 --- a/packages/daemon/test/fixture-home-cleanup.test.ts +++ b/packages/daemon/test/fixture-home-cleanup.test.ts @@ -21,7 +21,10 @@ function runSetup(env: Record = {}) { env: { PATH: process.env.PATH, TMPDIR: tmp, ...env }, }); expect(child.status, child.stderr).toBe(0); - const homes = JSON.parse(child.stdout.trim().split("\n").pop()!) as { openrigHome: string; home: string; tmpdir: string; scratch: string }; + const homes = JSON.parse(child.stdout.trim().split("\n").pop()!) as { + openrigHome: string; home: string; tmpdir: string; scratch: string; + osHome: string; codexHome?: string; claudeConfigDir?: string; xdgConfig: string; codexVersion: string; + }; return { tmp, ...homes }; } const fixtureEntries = (tmp: string) => fs.readdirSync(tmp).filter((name) => name.startsWith(".openrig-test-fixture-home-")); @@ -29,7 +32,22 @@ const fixtureEntries = (tmp: string) => fs.readdirSync(tmp).filter((name) => nam * Not tsx's loader cache, which the child's launcher makes before the setup runs. */ const leftovers = (tmp: string) => fs.readdirSync(tmp).filter((name) => !name.startsWith("tsx-")); -describe.skipIf(process.env["OPENRIG_E2E_REAL_CODEX"] === "1")("fixture homes are removed when the worker exits", () => { +describe("fixture homes are removed when the worker exits", () => { + it.each([undefined, "1"])("isolates provider homes and runtime lookup with native opt-in=%s", optIn => { + const { tmp, openrigHome, home, osHome, codexHome, claudeConfigDir, xdgConfig, codexVersion } = runSetup({ + HOME: "/synthetic-host-home", CODEX_HOME: "/synthetic-host-codex", + CLAUDE_CONFIG_DIR: "/synthetic-host-claude", XDG_CONFIG_HOME: "/synthetic-host-xdg", + OPENRIG_E2E_REAL_CODEX: optIn, + }); + expect(home).toBe(`${openrigHome}-user-home`); + expect(osHome).toBe(home); + expect(codexHome).toBeUndefined(); + expect(claudeConfigDir).toBeUndefined(); + expect(xdgConfig).toBe(path.join(home, ".config")); + expect(codexVersion).toBe("codex-cli 0.139.0"); + expect(leftovers(tmp)).toEqual([]); + }); + it("removes the fixture home and the user home", () => { const { tmp, openrigHome, home } = runSetup(); expect(path.dirname(openrigHome)).toBe(tmp); diff --git a/packages/daemon/test/fixtures/fixture-home-child.ts b/packages/daemon/test/fixtures/fixture-home-child.ts index c4c430194..8b415ebcc 100644 --- a/packages/daemon/test/fixtures/fixture-home-child.ts +++ b/packages/daemon/test/fixtures/fixture-home-child.ts @@ -3,11 +3,17 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; import "../../../../test/hermetic-env.setup.js"; // What a test does: make a temp dir the usual way, with os.tmpdir(). const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "test-scratch-")); fs.writeFileSync(path.join(scratch, "file"), "x"); -console.log(JSON.stringify({ openrigHome: process.env["OPENRIG_HOME"], home: process.env["HOME"], tmpdir: os.tmpdir(), scratch })); +console.log(JSON.stringify({ + openrigHome: process.env["OPENRIG_HOME"], home: process.env["HOME"], tmpdir: os.tmpdir(), scratch, + osHome: os.homedir(), codexHome: process.env["CODEX_HOME"], claudeConfigDir: process.env["CLAUDE_CONFIG_DIR"], + xdgConfig: process.env["XDG_CONFIG_HOME"], + codexVersion: execFileSync("codex", ["--version"], { encoding: "utf-8", timeout: 5000 }).trim(), +})); // With "wait", stay alive for the test to signal: a vitest worker is ended with SIGTERM while it is running. if (process.argv[2] === "wait") setInterval(() => {}, 1000); diff --git a/packages/daemon/test/helpers/hermetic-env.ts b/packages/daemon/test/helpers/hermetic-env.ts index e49411100..cedbf8d70 100644 --- a/packages/daemon/test/helpers/hermetic-env.ts +++ b/packages/daemon/test/helpers/hermetic-env.ts @@ -26,7 +26,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; -import { realRuntimesRequested, writeRuntimeStubs } from "../../../../test/stub-runtimes.js"; +import { writeRuntimeStubs } from "../../../../test/stub-runtimes.js"; /** * Inherited environment variables that redirect the CLI/daemon client at a @@ -280,7 +280,7 @@ export interface HermeticScaffold { } export interface PrepareHermeticEnvOptions { - /** Base environment to derive the clean child env from. Defaults to process.env. */ + /** Base environment to derive the clean child env from. Explicit provider roots are preserved; defaults to process.env. */ baseEnv?: EnvLike; /** * The A3-R3 injected clock. When set, the scaffold's child env carries @@ -331,8 +331,8 @@ export function prepareHermeticEnv(opts: PrepareHermeticEnvOptions = {}): Hermet mkdirSync(dir, { recursive: true }); } // codex and claude resolve to stubs inside the scaffold, so a scenario daemon never runs the host's - // real binaries (they can hang under a scratch HOME). Tests that opt into real runtimes keep them. - const runtimeStubDir = realRuntimesRequested(baseEnv) ? undefined : writeRuntimeStubs(root); + // real binaries (they can hang under a scratch HOME). Native Codex e2e tests use their own harness. + const runtimeStubDir = writeRuntimeStubs(root); // Test-only command seam: every child `tmux` call gets one explicit private // socket. TMUX_TMPDIR remains for compatibility, but is never the selector. @@ -358,6 +358,11 @@ process.exit(result.status ?? 1); const env: Record = { ...baseEnv }; for (const name of DAEMON_TARGET_ENV_VARS) delete env[name]; for (const name of CREDENTIAL_ENV_VARS) delete env[name]; + // Inherited provider roots belong to the runner; explicit ones are test inputs. + if (opts.baseEnv === undefined) { + for (const name of ["CODEX_HOME", "CLAUDE_CONFIG_DIR"]) delete env[name]; + } + delete env.OPENRIG_E2E_REAL_CODEX; // Scrub any injected-clock var (defense in depth behind the fail-closed guard) so // only an explicit injectClockNow can set it below — never an inherited value. for (const name of TEST_CLOCK_ENV_VARS) delete env[name]; diff --git a/packages/daemon/test/hermetic-scaffold.test.ts b/packages/daemon/test/hermetic-scaffold.test.ts index f6a98c8dd..7c26cb707 100644 --- a/packages/daemon/test/hermetic-scaffold.test.ts +++ b/packages/daemon/test/hermetic-scaffold.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { existsSync, statSync } from "node:fs"; import { delimiter, join } from "node:path"; import { @@ -16,6 +16,7 @@ import { describe("prepareHermeticEnv scaffold", () => { const scaffolds: HermeticScaffold[] = []; afterEach(() => { + vi.unstubAllEnvs(); for (const s of scaffolds.splice(0)) s.cleanup(); }); const make = (baseEnv?: Record) => { @@ -88,6 +89,35 @@ describe("prepareHermeticEnv scaffold", () => { expect(s.env).not.toBe(base); }); + it("scrubs inherited provider roots and the native opt-in from scenario children", () => { + vi.stubEnv("CODEX_HOME", "/synthetic-host-codex"); + vi.stubEnv("CLAUDE_CONFIG_DIR", "/synthetic-host-claude"); + vi.stubEnv("OPENRIG_E2E_REAL_CODEX", "1"); + const s = make(); + expect(s.env.HOME).toBe(s.home); + for (const key of ["CODEX_HOME", "CLAUDE_CONFIG_DIR", "OPENRIG_E2E_REAL_CODEX"]) { + expect(s.env[key]).toBeUndefined(); + } + expect(process.env.CODEX_HOME).toBe("/synthetic-host-codex"); + expect(process.env.CLAUDE_CONFIG_DIR).toBe("/synthetic-host-claude"); + expect(process.env.OPENRIG_E2E_REAL_CODEX).toBe("1"); + }); + + it("preserves explicit provider roots but still scrubs the native opt-in", () => { + const base = { + HOME: "/synthetic-host-home", CODEX_HOME: "/synthetic-host-codex", + CLAUDE_CONFIG_DIR: "/synthetic-host-claude", OPENRIG_E2E_REAL_CODEX: "1", + }; + const s = make(base); + expect(s.env.HOME).toBe(s.home); + expect(s.env.CODEX_HOME).toBe(base.CODEX_HOME); + expect(s.env.CLAUDE_CONFIG_DIR).toBe(base.CLAUDE_CONFIG_DIR); + expect(s.env.OPENRIG_E2E_REAL_CODEX).toBeUndefined(); + expect(base.CODEX_HOME).toBe("/synthetic-host-codex"); + expect(base.CLAUDE_CONFIG_DIR).toBe("/synthetic-host-claude"); + expect(base.OPENRIG_E2E_REAL_CODEX).toBe("1"); + }); + it("cleanup removes the scaffold root", () => { const s = prepareHermeticEnv({ baseEnv: { HOME: "/real-home" } }); const root = s.root; diff --git a/packages/daemon/test/startup.test.ts b/packages/daemon/test/startup.test.ts index 1855d1788..1f9dbc174 100644 --- a/packages/daemon/test/startup.test.ts +++ b/packages/daemon/test/startup.test.ts @@ -428,7 +428,8 @@ describe("createDaemon startup composition", () => { }); it("passes daemon CLI reachability env and PATH into launched tmux sessions", async () => { - vi.stubEnv("PATH", "/proof/openrig/bin:/usr/bin:/bin"); + const launchPath = `/proof/openrig/bin${path.delimiter}${process.env.PATH}`; + vi.stubEnv("PATH", launchPath); vi.stubEnv("OPENRIG_PORT", "17433"); vi.stubEnv("OPENRIG_HOST", "127.0.0.1"); const cmuxFactory: CmuxTransportFactory = async () => { @@ -448,7 +449,7 @@ describe("createDaemon startup composition", () => { .map((call) => call[0]) .find((cmd) => cmd.includes("tmux new-session")); expect(newSessionCmd).toBeDefined(); - expect(newSessionCmd).toContain("-e 'PATH=/proof/openrig/bin:/usr/bin:/bin'"); + expect(newSessionCmd).toContain(`-e 'PATH=${launchPath}'`); expect(newSessionCmd).toContain("-e 'OPENRIG_PORT=17433'"); expect(newSessionCmd).toContain("-e 'OPENRIG_HOST=127.0.0.1'"); @@ -465,7 +466,8 @@ describe("createDaemon startup composition", () => { const saved = saveEnv("HOME", "CODEX_HOME", "PATH", "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST", "OPENAI_API_KEY"); process.env.HOME = daemonHome; delete process.env.CODEX_HOME; - process.env.PATH = "/proof/openrig/bin:/usr/bin:/bin"; + const launchPath = `/proof/openrig/bin${path.delimiter}${process.env.PATH}`; + process.env.PATH = launchPath; process.env.OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST = "OPENAI_API_KEY"; process.env.OPENAI_API_KEY = "gap7-openai-key"; const tmuxExec = vi.fn(async () => ""); @@ -479,7 +481,7 @@ describe("createDaemon startup composition", () => { expect(result.deps.sessionEnv).toMatchObject({ HOME: daemonHome, CODEX_HOME: path.join(daemonHome, ".codex"), - PATH: "/proof/openrig/bin:/usr/bin:/bin", + PATH: launchPath, OPENAI_API_KEY: "gap7-openai-key", }); @@ -489,7 +491,7 @@ describe("createDaemon startup composition", () => { const command = tmuxExec.mock.calls.map((call) => call[0]).find((line) => line.includes("tmux new-session")); expect(command).toContain(`-e 'HOME=${daemonHome}'`); expect(command).toContain(`-e 'CODEX_HOME=${path.join(daemonHome, ".codex")}'`); - expect(command).toContain("-e 'PATH=/proof/openrig/bin:/usr/bin:/bin'"); + expect(command).toContain(`-e 'PATH=${launchPath}'`); expect(command).toContain("-e 'OPENAI_API_KEY=gap7-openai-key'"); } finally { result?.db.close(); diff --git a/packages/daemon/test/stub-runtimes-on-path.test.ts b/packages/daemon/test/stub-runtimes-on-path.test.ts index 4330ffbc3..72bc2d4e9 100644 --- a/packages/daemon/test/stub-runtimes-on-path.test.ts +++ b/packages/daemon/test/stub-runtimes-on-path.test.ts @@ -5,11 +5,11 @@ import { tmpdir } from "node:os"; import { delimiter, join, sep } from "node:path"; import { probeCodexDaemonSupport } from "../src/domain/codex-daemon-support.js"; import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js"; +import { realRuntimesRequested } from "../../../test/stub-runtimes.js"; // Daemon tests must never run the host's real `codex` or `claude`. Under a fixture HOME the host's // ~/.local/bin/codex wrapper can hang (mise use -g), and every daemon start probes `codex --version`. // The shared vitest setup and prepareHermeticEnv put stubs first on PATH instead. -const realRuntimes = process.env["OPENRIG_E2E_REAL_CODEX"] === "1"; function resolveOnPath(binary: string, pathVar: string): string | undefined { for (const dir of pathVar.split(delimiter)) { @@ -19,7 +19,22 @@ function resolveOnPath(binary: string, pathVar: string): string | undefined { return undefined; } -describe.skipIf(realRuntimes)("stub codex and claude on the test PATH", () => { +describe("stub codex and claude on the test PATH", () => { + it.each(["seat-handover-model-fidelity-e2e.test.ts", "seat-lifecycle-set-model-resume-e2e.test.ts"])( + "allows only the opted-in native e2e file %s", name => { + const testPath = join(import.meta.dirname, name); + expect(realRuntimesRequested({ OPENRIG_E2E_REAL_CODEX: "1" }, testPath)).toBe(true); + expect(realRuntimesRequested({}, testPath)).toBe(false); + expect(realRuntimesRequested({ OPENRIG_E2E_REAL_CODEX: "1" }, join(tmpdir(), name))).toBe(false); + }, + ); + + it("does not grant the native exception to ordinary tests or plain Node setup consumers", () => { + const env = { OPENRIG_E2E_REAL_CODEX: "1" }; + expect(realRuntimesRequested(env, import.meta.filename)).toBe(false); + expect(realRuntimesRequested(env)).toBe(false); + }); + it("resolves codex and claude inside the fixture root under the vitest setup", () => { const root = realpathSync(process.env["OPENRIG_HOME"]!); for (const binary of ["codex", "claude"]) { @@ -86,10 +101,11 @@ describe.skipIf(realRuntimes)("stub codex and claude on the test PATH", () => { } }); - it("leaves the host's binaries alone when real runtimes are opted in", () => { + it("keeps scenario runtimes stubbed even when the native e2e opt-in is inherited", () => { const s = prepareHermeticEnv({ baseEnv: { HOME: "/real-home", PATH: "/usr/bin", OPENRIG_E2E_REAL_CODEX: "1" } }); scaffolds.push(s); - expect(s.env["PATH"]?.split(delimiter).some((dir) => dir.startsWith(s.root) && existsSync(join(dir, "codex")))).toBe(false); + expect(s.env["PATH"]?.split(delimiter).some((dir) => dir.startsWith(s.root) && existsSync(join(dir, "codex")))).toBe(true); + expect(s.env["OPENRIG_E2E_REAL_CODEX"]).toBeUndefined(); }); }); }); diff --git a/test/hermetic-env.setup.ts b/test/hermetic-env.setup.ts index ba7937e17..8ad493fc1 100644 --- a/test/hermetic-env.setup.ts +++ b/test/hermetic-env.setup.ts @@ -29,7 +29,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { writeRuntimeStubs } from "./stub-runtimes.js"; +import { realRuntimesRequested, writeRuntimeStubs } from "./stub-runtimes.js"; // Scrub inherited instance selectors before product modules capture their defaults. // Tests can still set explicit overrides after setup; test/provider opt-in flags @@ -115,7 +115,12 @@ process.env["OPENRIG_COMPACTION_OUT_ROOT"] = path.join(fixtureHome, "compaction- // to this fixture. Merely changing HOME would not override OPENRIG_HOME or DB. // Keep ordinary user files outside the state root, as in a real installation. // Otherwise file-transfer correctly rejects every ~/ path as OpenRig state. -if (process.env["OPENRIG_E2E_REAL_CODEX"] !== "1") { +// Vitest sets testPath before setupFiles. Plain Node setup consumers have no native-runtime exception. +const testPath = process.env["VITEST"] ? (await import("vitest")).expect.getState().testPath : undefined; +const realCodexTest = realRuntimesRequested(process.env, testPath); +// These files exercise Codex only; an inherited Claude override never belongs to their fixture. +delete process.env["CLAUDE_CONFIG_DIR"]; +if (!realCodexTest) { const userHome = `${fixtureHome}-user-home`; fs.mkdirSync(userHome, { recursive: true }); process.env["HOME"] = userHome; @@ -125,6 +130,11 @@ if (process.env["OPENRIG_E2E_REAL_CODEX"] !== "1") { XDG_DATA_HOME: ".local/share", XDG_CACHE_HOME: ".cache", })) process.env[key] = path.join(userHome, suffix); + if (os.homedir() !== userHome || process.env["CODEX_HOME"] !== undefined + || process.env["CLAUDE_CONFIG_DIR"] !== undefined) { + throw new Error("test provider homes must stay inside the fixture"); + } + // codex and claude resolve to stubs that answer at once, so no test runs the host's real // binaries (a codex wrapper that hangs under a fixture HOME leaves its probes running). // The stubs live beside the fixture home and go away with it. One PATH entry per worker. diff --git a/test/stub-runtimes.ts b/test/stub-runtimes.ts index 2fdc8f3cb..7645d9006 100644 --- a/test/stub-runtimes.ts +++ b/test/stub-runtimes.ts @@ -10,6 +10,7 @@ // do. The Codex version is >= 0.130 so startup skips the `codex_hooks` feature-flag write. import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; const STUBS = { codex: { @@ -26,9 +27,18 @@ const STUBS = { const STUB_NAME = ".openrig-test-runtime-stubs"; -/** True when a test run opted into the host's real runtimes (the existing E2E opt-in). */ -export function realRuntimesRequested(env: Record = process.env): boolean { - return env["OPENRIG_E2E_REAL_CODEX"] === "1"; +const REAL_CODEX_TESTS = [ + "seat-handover-model-fidelity-e2e.test.ts", + "seat-lifecycle-set-model-resume-e2e.test.ts", +].map(name => fileURLToPath(new URL(`../packages/daemon/test/${name}`, import.meta.url))); + +/** The opt-in permits native runtimes only in the two tests that exercise real Codex. */ +export function realRuntimesRequested( + env: Record = process.env, + testPath?: string, +): boolean { + return env["OPENRIG_E2E_REAL_CODEX"] === "1" && testPath !== undefined + && REAL_CODEX_TESTS.includes(path.resolve(testPath)); } /**