From 31b3c1a09a3f07eb9b8cd808bc513452b1610bdc Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 17:29:52 +0900 Subject: [PATCH 1/5] fix(test): arm the real-home write guard when bun test skips the preload Bun reads bunfig.toml from the current working directory, so a bun test started outside the repository loads no tests/preload.ts: HOME stays real, OPENCODEX_HOME is unset, and the guard stayed disarmed, letting OAuth persistence write the developer's real ~/.opencodex/auth.json. isTestHomeGuardArmed() now also arms when the entry module (Bun.main) has a Bun test-file name, and publishes OCX_TEST_HOME_GUARD=1 to spawned children. No production entrypoint matches; NODE_ENV is not consulted; an explicit OCX_TEST_HOME_GUARD=0 still disarms. Writers that touched the config dir before any guard (OAuth/Nous refresh intents, Kimi device id) now assert first. Fixes #7407 --- bunfig.toml | 10 +- scripts/test-layout/layout.json | 1 + src/lib/test-home-guard.ts | 80 ++++++- src/oauth/kimi.ts | 2 + src/oauth/nous.ts | 5 +- src/oauth/store.ts | 1 + structure/ops/test-sandbox-cleanup.md | 10 + .../test-home-guard-unpreloaded.test.ts | 214 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- tests/preload.ts | 9 +- .../live-service-manager-guard.test.ts | 4 +- 11 files changed, 317 insertions(+), 22 deletions(-) create mode 100644 tests/ci-workflows/test-home-guard-unpreloaded.test.ts diff --git a/bunfig.toml b/bunfig.toml index 1a6ced2c5dc..7376005a5c5 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -16,8 +16,10 @@ root = "tests" # # That qualifier is load-bearing. Bun resolves bunfig.toml from the CURRENT WORKING # DIRECTORY, so `cd /tmp && bun test /tests/x.test.ts` loads no preload at all: nothing -# is sandboxed, the guard stays disarmed, and every `getConfigDir()` in that process returns -# the developer's real `~/.opencodex`. On 2026-09-15 such a run deleted one. A test that needs -# a config directory pins its own OPENCODEX_HOME instead of trusting this line, and -# `tests/ci-workflows/test-home-guard.test.ts` enforces that for the destructive case. +# is sandboxed, and every `getConfigDir()` in that process returns the developer's real +# `~/.opencodex`. On 2026-09-15 such a run deleted one, and in #7407 another wrote auth.json. +# The write guard now arms itself from the test-file entry in that case (src/lib/test-home-guard.ts), +# so such writes throw instead of landing, but a test that needs a config directory still pins its +# own OPENCODEX_HOME instead of trusting this line, and `tests/ci-workflows/test-home-guard.test.ts` +# enforces that for the destructive case. preload = ["./tests/preload.ts"] diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 60eee142abe..f83b12ebfae 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1821,6 +1821,7 @@ "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", "test-home-guard.test.ts": "ci-workflows", + "test-home-guard-unpreloaded.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", diff --git a/src/lib/test-home-guard.ts b/src/lib/test-home-guard.ts index 252fde3c5d8..394016bb9a8 100644 --- a/src/lib/test-home-guard.ts +++ b/src/lib/test-home-guard.ts @@ -13,14 +13,15 @@ * 1. It must be INERT in production. Guessing "am I a test?" from ecosystem variables * like NODE_ENV would brick `NODE_ENV=test ocx ...` for a user who did nothing * wrong — worse than the bug it prevents. Arming requires OCX_TEST_HOME_GUARD=1, - * which only this repository's test preload sets. + * which this repository's test preload sets, or a process whose entry module is a + * Bun test file (see {@link isBunTestFileProcess}); no production entrypoint is one. * 2. It must fail CLOSED for code nobody has written yet. So it denies ONE path — the * captured production home — instead of allow-listing known-good test directories. * An allowlist would have to be opted into, and the test that forgets is exactly * how this incident happened. */ import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -109,8 +110,62 @@ export function protectedClaudeConfigDirsForTests(): readonly string[] { return PROTECTED_CLAUDE_TREES.map(tree => tree.path); } +/** + * The file names Bun's test runner discovers: `.test`, `_test`, `.spec` or `_spec` before a + * JS/TS extension. Under `bun test`, `Bun.main` names the test file being evaluated, in the + * main runner and in `--parallel` workers alike. Every production entrypoint — the CLI, the + * service daemon, the compiled sidecar (`/$bunfs/...`), a release script — has a different name. + */ +const BUN_TEST_FILE = /[._](?:test|spec)\.[cm]?[jt]sx?$/i; + +/** + * Whether this process runs a Bun test file as its entry module. + * + * This exists for the run that never loads `tests/preload.ts`. Bun resolves `bunfig.toml` from + * the CURRENT WORKING DIRECTORY, so `cd /tmp && bun test /tests/x.test.ts` skips the + * preload: HOME stays real, OPENCODEX_HOME stays unset, and before this check the guard stayed + * down, so an OAuth save landed in the developer's real `~/.opencodex/auth.json` (#7407). + * Bun exposes no runner flag, and NODE_ENV=test is both settable by users and overridable by the + * runner's caller, so the entry file's name is the one signal that production never carries. + * + * A file passed by explicit path under a non-test name (`bun test ./scratch.ts`) is not + * recognised; every repository suite is a `*.test.ts`, the only name `tests/test-layout.test.ts` admits. + */ +export function isBunTestFileProcess(): boolean { + const main = (globalThis as { Bun?: { main?: unknown } }).Bun?.main; + const entry = typeof main === "string" ? main : process.argv[1]; + return typeof entry === "string" && BUN_TEST_FILE.test(basename(entry)); +} + +/** + * Armed when the preload (or a harness) set OCX_TEST_HOME_GUARD=1, or when this process runs a + * Bun test file and nobody set the flag. An explicit OCX_TEST_HOME_GUARD=0 is how a test case + * exercises the production branch of a guarded call, so it disarms even inside a test file. + */ export function isTestHomeGuardArmed(): boolean { - return process.env[GUARD_ENV] === "1"; + const flag = process.env[GUARD_ENV]; + if (flag === "1") return true; + if (flag === "0") return false; + return isBunTestFileProcess(); +} + +/** + * A test process that armed through its entry file publishes the flag, so the CLI and helper + * processes it spawns — whose own entry is not a test file — inherit the arming instead of + * writing the real home they also inherited. Runs at import, which in such a run happens before + * any test body. A production process never matches, so it never reaches the assignment. + */ +if (process.env[GUARD_ENV] === undefined && isBunTestFileProcess()) process.env[GUARD_ENV] = "1"; + +/** + * Appended to a refusal when no preload ran, because the fix there is different: the test did + * not forget OPENCODEX_HOME, the whole run started from a directory without bunfig.toml. + */ +function missingPreloadHint(): string { + if (process.env.OCX_TEST_PRELOAD_PID !== undefined) return ""; + return " This test process did not load tests/preload.ts — Bun reads bunfig.toml from the " + + "current working directory — so nothing was sandboxed. Run bun test from the repository " + + "root, or pass --preload /tests/preload.ts."; } /** @@ -144,7 +199,8 @@ export function assertNotRealHomeUnderTest(dir: string): void { throw new Error( `refusing to write the real OpenCodex home (${PROTECTED_HOME}) from a test process. ` + "Point OPENCODEX_HOME at a temp directory for this test, or inject persistence " - + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).", + + "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070)." + + missingPreloadHint(), ); } @@ -177,7 +233,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void { throw new Error( `refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. ` + "Point CODEX_HOME at a temp directory for this test before writing Codex files " - + "(auth.json, the catalog, models_cache.json, the journal or config.toml).", + + "(auth.json, the catalog, models_cache.json, the journal or config.toml)." + + missingPreloadHint(), ); } @@ -351,12 +408,13 @@ export function protectedRemovalReason(target: string): string | null { /** * Throw before a removal that would reach a protected tree. * - * Deliberately NOT gated on {@link isTestHomeGuardArmed}. Arming happens in `tests/preload.ts`, - * which Bun loads from the `bunfig.toml` it finds in the CURRENT WORKING DIRECTORY — so a run - * started outside the repository arms nothing, leaves OPENCODEX_HOME unset, and resolves the - * developer's real home. That unarmed run is precisely the one that caused the incident, so the - * refusal has to hold without it. Nothing in production calls this; the callers are test - * helpers, where the only cost of an unconditional check is a path comparison. + * Deliberately NOT gated on {@link isTestHomeGuardArmed}. `tests/preload.ts` is loaded from the + * `bunfig.toml` Bun finds in the CURRENT WORKING DIRECTORY, so a run started outside the + * repository leaves OPENCODEX_HOME unset and resolves the developer's real home. Such a run now + * arms through its test-file entry, but a helper launched as a plain script does not, and the + * incident was exactly this unsandboxed shape, so the refusal has to hold without arming. + * Nothing in production calls this; the callers are test helpers, where the only cost of an + * unconditional check is a path comparison. */ export function assertRemovalOutsideProtectedTrees(target: string): void { const reason = protectedRemovalReason(target); diff --git a/src/oauth/kimi.ts b/src/oauth/kimi.ts index 1d2f346e09e..1189e9725c3 100644 --- a/src/oauth/kimi.ts +++ b/src/oauth/kimi.ts @@ -4,6 +4,7 @@ import * as os from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { getConfigDir } from "../config"; import type { OAuthController, OAuthCredentials } from "./types"; @@ -107,6 +108,7 @@ function getDeviceId(): string { if ((e as { code?: string })?.code !== "ENOENT") throw e; } const id = randomUUID().replace(/-/g, ""); + assertNotRealHomeUnderTest(getConfigDir()); recordOwnedConfigPath(getConfigDir(), p); if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); writeFileSync(p, id + "\n", { mode: 0o600 }); diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 21ae5211edf..3a1a222bb8e 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -35,10 +35,11 @@ */ import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { OAuthController, OAuthCredentials } from "./types"; import { getAuthStorePath } from "./store"; import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com"; @@ -220,6 +221,8 @@ function readRefreshIntent(refreshToken: string): RefreshIntent | undefined { } function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void { + // Before the mkdir/chmod below, which would otherwise touch the real home first. + assertNotRealHomeUnderTest(dirname(getAuthStorePath())); const dir = refreshIntentDir(); // Hardened, owner-only directory + atomic (temp+rename) write. Throws on // failure so the caller can fail closed instead of refreshing blind. diff --git a/src/oauth/store.ts b/src/oauth/store.ts index ca84b1f947e..d35c07e5da0 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -203,6 +203,7 @@ export function peekOAuthRefreshIntent(provider: string, accountId: string): OAu } export function writeOAuthRefreshIntent(provider: string, accountId: string, generation: string, createdAt = Date.now(), flightId?: string): OAuthRefreshIntent { const dir = getConfigDir(); + assertNotRealHomeUnderTest(dir); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); hardenConfigDir(); const intent: OAuthRefreshIntent = { diff --git a/structure/ops/test-sandbox-cleanup.md b/structure/ops/test-sandbox-cleanup.md index 42c7025507d..882147d6d1c 100644 --- a/structure/ops/test-sandbox-cleanup.md +++ b/structure/ops/test-sandbox-cleanup.md @@ -52,6 +52,16 @@ protected roots resolve at check time and compare case-insensitively on macOS an directory is also a protected removal tree. `tests/claude-integration/claude-config-home-isolation.test.ts` pins each layer. +The write guard arms without the preload too. Bun reads `bunfig.toml` from the current working +directory, so `bun test` started outside the repository loads no preload and keeps the real HOME. +`isTestHomeGuardArmed()` therefore also arms when the entry module (`Bun.main`) has a Bun +test-file name, and publishes `OCX_TEST_HOME_GUARD=1` so the processes it spawns inherit the arming. No +production entrypoint has such a name, and `NODE_ENV` is never consulted. An explicit +`OCX_TEST_HOME_GUARD=0` disarms, which is how a case exercises a production branch. Such a run +refuses OAuth, Codex account-pool, refresh-intent, `auth.json` and config writes instead of +sandboxing them; `tests/ci-workflows/test-home-guard-unpreloaded.test.ts` drives each one from a +cwd without `bunfig.toml` against a fake real home, and checks that a plain `bun` process still persists (#7407). + `tests/helpers/test-sandbox-cleanup.ts` exposes case-scoped lifecycle ownership: cancellation starts listener stops while owned asynchronous work settles, and repeated close/stop calls share one promise. After teardown starts only the lifecycle's own abort reason is absorbed; any other diff --git a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts new file mode 100644 index 00000000000..c304f2581b1 --- /dev/null +++ b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts @@ -0,0 +1,214 @@ +/** + * #7407: a `bun test` that never loads tests/preload.ts must still refuse credential writes. + * + * Bun reads bunfig.toml from the CURRENT WORKING DIRECTORY, so + * `cd elsewhere && bun test /tests/x.test.ts` runs with the real HOME, no OPENCODEX_HOME, + * and (before this fix) a disarmed guard: one such run added an account to a developer's real + * ~/.opencodex/auth.json and made it active. Every case here spawns exactly that shape, a test + * file outside the repository run from a cwd with no bunfig.toml, against a temp directory + * posing as the real home, so nothing in this suite can reach the developer's own. + * + * The negative case matters as much: the same writes from a plain `bun` process, even with + * NODE_ENV=test, must still land. Production persistence is unchanged. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { repoRoot } from "../helpers/repo-root"; +import { watchdogMs } from "../helpers/ci-watchdog"; + +const REPO_ROOT_URL = pathToFileURL(repoRoot() + "/").href; +const CHILD_MS = watchdogMs(20_000); +const SENTINEL_AUTH = JSON.stringify({ sentinel: { activeAccountId: "real", accounts: [] } }, null, 2) + "\n"; +const SENTINEL_CODEX_AUTH = JSON.stringify({ tokens: { access_token: "real-codex-sentinel" } }) + "\n"; + +const handles: TempHome[] = []; +afterEach(() => { for (const handle of handles.splice(0)) handle.remove(); }); + +/** A temp root this suite owns and removes after each case. */ +function owned(prefix: string): string { + const handle = createTempHome(prefix); + handles.push(handle); + return handle.root; +} + +/** A directory posing as the developer's home, seeded with live-looking credential stores. */ +function fakeRealHome(): string { + const home = owned("ocx-7407-real-home-"); + mkdirSync(join(home, ".opencodex"), { recursive: true, mode: 0o700 }); + mkdirSync(join(home, ".codex"), { recursive: true, mode: 0o700 }); + writeFileSync(join(home, ".opencodex", "auth.json"), SENTINEL_AUTH, { mode: 0o600 }); + writeFileSync(join(home, ".codex", "auth.json"), SENTINEL_CODEX_AUTH, { mode: 0o600 }); + return home; +} + +/** Every entry under the two credential homes, with its mode and content hash. */ +function snapshot(home: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir).sort()) { + const path = join(dir, name); + const stat = lstatSync(path); + const digest = stat.isFile() ? createHash("sha256").update(readFileSync(path)).digest("hex") : ""; + out.push(relative(home, path) + " " + stat.mode.toString(8) + " " + digest); + if (stat.isDirectory()) walk(path); + } + }; + for (const tree of [".opencodex", ".codex"]) { + out.push(tree + " " + lstatSync(join(home, tree)).mode.toString(8)); + walk(join(home, tree)); + } + return out; +} + +/** A developer shell's environment: real HOME, nothing sandboxed, no preload receipts. */ +function unsandboxedEnv(home: string, extra: Record = {}): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value; + for (const key of ["OCX_TEST_HOME_GUARD", "OCX_REAL_HOME", "OCX_TEST_PRELOAD_PID", "OPENCODEX_HOME", "CODEX_HOME", "NODE_ENV"]) { + delete env[key]; + } + return { ...env, HOME: home, USERPROFILE: home, ...extra }; +} + +async function run(args: string[], cwd: string, env: Record) { + const child = Bun.spawn([process.execPath, ...args], { cwd, env, stdout: "pipe", stderr: "pipe" }); + const timer = setTimeout(() => child.kill("SIGKILL"), CHILD_MS); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + return { stdout, stderr, code }; + } finally { + clearTimeout(timer); + } +} + +function report(stdout: string, stderr: string): Record { + const line = (stdout + "\n" + stderr).split("\n").find(entry => entry.startsWith("REPORT ")); + if (!line) throw new Error("child printed no REPORT line:\n" + stdout + "\n" + stderr); + return JSON.parse(line.slice("REPORT ".length)) as Record; +} + +/** + * One call per credential-write entry path. A call counts as REFUSED only when its error chain + * carries the guard's own message, so an unrelated failure cannot pass as protection. + */ +const ATTEMPTS = String.raw` + const repo = ${JSON.stringify(REPO_ROOT_URL)}; + const out: Record = {}; + const guard = await import(repo + "src/lib/test-home-guard.ts"); + out.armed = String(guard.isTestHomeGuardArmed()); + out.flag = String(process.env.OCX_TEST_HOME_GUARD); + out.preload = String(process.env.OCX_TEST_PRELOAD_PID); + globalThis.fetch = (async () => { throw new Error("network disabled in this probe"); }) as never; + const config = await import(repo + "src/config.ts"); + const store = await import(repo + "src/oauth/store.ts"); + const codexStore = await import(repo + "src/codex/account-store.ts"); + const kimi = await import(repo + "src/oauth/kimi.ts"); + const nous = await import(repo + "src/oauth/nous.ts"); + out.configDir = config.getConfigDir(); + const cred = { access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 3_600_000, email: "fixture@example.test" }; + const chain = (error: unknown): string => { + const parts: string[] = []; + for (let e: any = error, i = 0; e && i < 6; e = e.cause, i++) parts.push(String(e?.message ?? e)); + return parts.join(" <- "); + }; + const attempt = async (name: string, fn: () => unknown) => { + try { await fn(); out[name] = "WROTE"; } + catch (error) { + const text = chain(error); + out[name] = /refusing to write the real (OpenCodex|Codex) home/.test(text) ? "REFUSED" : "OTHER: " + text.slice(0, 300); + if (name === "saveCredential") out.hint = String(text.includes("did not load tests/preload.ts")); + } + }; + await attempt("saveCredential", () => store.saveCredential("anthropic", cred)); + await attempt("upsertCredentialByIdentity", () => store.upsertCredentialByIdentity("kimi", cred)); + await attempt("saveAccountCredential", () => store.saveAccountCredential("kimi", "fixture-account", cred)); + await attempt("setActiveAccount", () => store.setActiveAccount("kimi", "fixture-account")); + await attempt("removeAccount", () => store.removeAccount("kimi", "fixture-account")); + await attempt("mergeAccountCredential", () => store.mergeAccountCredential("kimi", "fixture-account", cred)); + await attempt("writeOAuthRefreshIntent", () => store.writeOAuthRefreshIntent("kimi", "fixture-account", "generation")); + await attempt("refreshIntentLock", async () => (await store.createOAuthRefreshIntentLock("kimi", "fixture-account").acquire()).release()); + await attempt("saveCodexAccountCredential", () => codexStore.saveCodexAccountCredential("fixture-codex", { accessToken: "a", refreshToken: "r", accountId: "acct", expiresAt: Date.now() + 3_600_000 } as never)); + await attempt("codexAuthJsonMirror", () => config.atomicWriteFile("__CODEX_AUTH__", "{}\n")); + await attempt("saveConfig", () => config.saveConfig({ providers: {}, defaultProvider: "openai", port: 10100 } as never)); + await attempt("kimiDeviceId", () => kimi.refreshKimiToken("fixture-refresh")); + await attempt("nousRefreshIntent", () => nous.refreshNousToken("fixture-refresh")); + console.log("REPORT " + JSON.stringify(out)); +`; + +const WRITE_PATHS = [ + "saveCredential", "upsertCredentialByIdentity", "saveAccountCredential", "setActiveAccount", "removeAccount", + "mergeAccountCredential", "writeOAuthRefreshIntent", "refreshIntentLock", "saveCodexAccountCredential", + "codexAuthJsonMirror", "saveConfig", "kimiDeviceId", "nousRefreshIntent", +]; + +function writeFixture(dir: string, name: string, home: string, asTest: boolean): string { + // JSON-encoded so a Windows path keeps its backslashes inside the child's string literal. + const body = ATTEMPTS.replace('"__CODEX_AUTH__"', JSON.stringify(join(home, ".codex", "auth.json"))); + const source = asTest + ? 'import { test } from "bun:test";\ntest("unpreloaded writes", async () => {\n' + body + "\n}, 30_000);\n" + : body; + const path = join(dir, name); + writeFileSync(path, source); + return path; +} + +describe("bun test without the preload (#7407)", () => { + test("arms the guard and refuses every credential write to the real home", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-cwd-"); + expect(existsSync(join(outside, "bunfig.toml"))).toBe(false); + const fixture = writeFixture(outside, "unpreloaded-writes.test.ts", home, true); + const before = snapshot(home); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home)); + const seen = report(result.stdout, result.stderr); + + // The incident's shape: no preload ran and the config dir is the real one. + expect(seen.preload).toBe("undefined"); + expect(seen.configDir).toBe(join(home, ".opencodex")); + // Armed anyway, and published so CLI children it spawns inherit the arming. + expect(seen.armed).toBe("true"); + expect(seen.flag).toBe("1"); + for (const path of WRITE_PATHS) expect({ path, outcome: seen[path] }).toEqual({ path, outcome: "REFUSED" }); + expect(seen.hint).toBe("true"); + // Nothing landed, nothing was created, and no mode changed. + expect(snapshot(home)).toEqual(before); + expect(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")).toBe(SENTINEL_AUTH); + }, CHILD_MS + 5_000); + + test("a plain bun process with NODE_ENV=test still persists (production is unchanged)", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-prod-cwd-"); + const script = writeFixture(outside, "write-credentials.ts", home, false); + + const result = await run(["run", script], outside, unsandboxedEnv(home, { NODE_ENV: "test" })); + const seen = report(result.stdout, result.stderr); + + expect(seen.armed).toBe("false"); + expect(seen.flag).toBe("undefined"); + expect(seen.saveCredential).toBe("WROTE"); + expect(seen.saveConfig).toBe("WROTE"); + expect(seen.codexAuthJsonMirror).toBe("WROTE"); + const auth = JSON.parse(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")) as Record; + expect(Object.keys(auth)).toContain("anthropic"); + }, CHILD_MS + 5_000); + + test("an explicit OCX_TEST_HOME_GUARD=0 still disarms a test file", async () => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-optout-cwd-"); + const fixture = join(outside, "optout.test.ts"); + writeFileSync(fixture, 'import { test } from "bun:test";\ntest("x", async () => {\n' + + " const guard = await import(" + JSON.stringify(REPO_ROOT_URL + "src/lib/test-home-guard.ts") + ");\n" + + ' console.log("REPORT " + JSON.stringify({ armed: String(guard.isTestHomeGuardArmed()) }));\n});\n'); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home, { OCX_TEST_HOME_GUARD: "0" })); + + expect(report(result.stdout, result.stderr).armed).toBe("false"); + }, CHILD_MS + 5_000); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 563b2d53550..da880bb6d44 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1232,7 +1232,8 @@ "system-routes.test.ts": "server", "systemd-install-cleanup-hardening.test.ts": "service", "tencent-siliconflow-providers.test.ts": "gui", "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", + "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", diff --git a/tests/preload.ts b/tests/preload.ts index ee4f990913c..0e66597830a 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -10,10 +10,11 @@ * * It does still depend on WHERE the run starts. Bun resolves bunfig.toml from the current * working directory, so a run launched outside the repository never loads this file: no - * sandbox, no arming, and getConfigDir() resolves the real ~/.opencodex. On 2026-09-15 a - * run of that shape deleted a live home. Nothing here can close that hole from inside, so - * a test that needs a config directory pins its own OPENCODEX_HOME rather than inheriting - * one, and tests/ci-workflows/test-home-guard.test.ts enforces it for destructive calls. + * sandbox, and getConfigDir() resolves the real ~/.opencodex. On 2026-09-15 a run of that + * shape deleted a live home. The write guard covers that run by arming from its test-file + * entry (#7407), which turns such writes into refusals, but nothing here can sandbox it, so a + * test that needs a config directory pins its own OPENCODEX_HOME rather than inheriting one, + * and tests/ci-workflows/test-home-guard.test.ts enforces it for destructive calls. * (devlog `_plan/260730_codex_rs_upstream_v2_live_handoff/070`.) * * Import order below is load-bearing: importing the guard captures the real home at diff --git a/tests/service/live-service-manager-guard.test.ts b/tests/service/live-service-manager-guard.test.ts index 31104da2477..60cfc1449cb 100644 --- a/tests/service/live-service-manager-guard.test.ts +++ b/tests/service/live-service-manager-guard.test.ts @@ -65,7 +65,9 @@ describe("live service-manager guard", () => { }); test("is inert in production, where the guard is not armed", () => { - delete process.env[GUARD_ENV]; + // "0", not an unset flag: a process whose entry is a test file arms itself when the flag is + // absent (#7407), so the explicit opt-out is what stands in for a production process here. + process.env[GUARD_ENV] = "0"; expect(() => assertLiveServiceManagerAllowed("systemctl --user stop opencodex-proxy.service"), ).not.toThrow(); From 5cecef66e780272b1d3b4b6edef9a525d6be23d9 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 18:02:38 +0900 Subject: [PATCH 2/5] test(guard): cover spawned-child inheritance and no production refusals (#7407) --- src/lib/test-home-guard.ts | 5 ++++ .../test-home-guard-unpreloaded.test.ts | 27 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/lib/test-home-guard.ts b/src/lib/test-home-guard.ts index 394016bb9a8..1c0f094c688 100644 --- a/src/lib/test-home-guard.ts +++ b/src/lib/test-home-guard.ts @@ -130,6 +130,11 @@ const BUN_TEST_FILE = /[._](?:test|spec)\.[cm]?[jt]sx?$/i; * * A file passed by explicit path under a non-test name (`bun test ./scratch.ts`) is not * recognised; every repository suite is a `*.test.ts`, the only name `tests/test-layout.test.ts` admits. + * + * The converse also holds: `bun run ./something.test.ts` arms, because nothing distinguishes it + * from the runner. That is accepted. Requiring NODE_ENV=test as well would exempt every developer + * whose shell exports another NODE_ENV (`bun test` keeps an existing value), reopening #7407; + * the cost of the false positive is a refused real-home write from a script named like a test. */ export function isBunTestFileProcess(): boolean { const main = (globalThis as { Bun?: { main?: unknown } }).Bun?.main; diff --git a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts index c304f2581b1..d35f87eb2e0 100644 --- a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts +++ b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts @@ -147,9 +147,30 @@ const WRITE_PATHS = [ "codexAuthJsonMirror", "saveConfig", "kimiDeviceId", "nousRefreshIntent", ]; +/** + * A helper whose entry is NOT a test file, spawned from inside the unpreloaded test. It cannot + * arm itself by name, so it is protected only if it inherits the flag the test process published. + */ +const CHILD_HELPER = String.raw` + const store = await import(${JSON.stringify(REPO_ROOT_URL)} + "src/oauth/store.ts"); + try { + await store.saveCredential("anthropic", { access: "child", refresh: "child", expires: Date.now() + 3_600_000, email: "child@example.test" }); + console.log("CHILD WROTE"); + } catch (error) { + console.log(/refusing to write the real OpenCodex home/.test(String((error as Error)?.message)) ? "CHILD REFUSED" : "CHILD OTHER"); + } +`; + function writeFixture(dir: string, name: string, home: string, asTest: boolean): string { // JSON-encoded so a Windows path keeps its backslashes inside the child's string literal. - const body = ATTEMPTS.replace('"__CODEX_AUTH__"', JSON.stringify(join(home, ".codex", "auth.json"))); + let body = ATTEMPTS.replace('"__CODEX_AUTH__"', JSON.stringify(join(home, ".codex", "auth.json"))); + if (asTest) { + const helper = join(dir, "spawned-helper.ts"); + writeFileSync(helper, CHILD_HELPER); + body = body.replace(' console.log("REPORT "', " const spawned = Bun.spawnSync([process.execPath, \"run\", " + JSON.stringify(helper) + "], { env: process.env });\n" + + " out.child = /CHILD (\\w+)/.exec(spawned.stdout.toString())?.[1] ?? \"none\";\n" + + ' console.log("REPORT "'); + } const source = asTest ? 'import { test } from "bun:test";\ntest("unpreloaded writes", async () => {\n' + body + "\n}, 30_000);\n" : body; @@ -177,6 +198,8 @@ describe("bun test without the preload (#7407)", () => { expect(seen.flag).toBe("1"); for (const path of WRITE_PATHS) expect({ path, outcome: seen[path] }).toEqual({ path, outcome: "REFUSED" }); expect(seen.hint).toBe("true"); + // A spawned helper with a non-test entry inherits the arming instead of writing the real home. + expect(seen.child).toBe("REFUSED"); // Nothing landed, nothing was created, and no mode changed. expect(snapshot(home)).toEqual(before); expect(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")).toBe(SENTINEL_AUTH); @@ -192,6 +215,8 @@ describe("bun test without the preload (#7407)", () => { expect(seen.armed).toBe("false"); expect(seen.flag).toBe("undefined"); + // fetch is disabled, so a network path may fail otherwise, but none may hit the guard. + for (const path of WRITE_PATHS) expect({ path, refused: seen[path] === "REFUSED" }).toEqual({ path, refused: false }); expect(seen.saveCredential).toBe("WROTE"); expect(seen.saveConfig).toBe("WROTE"); expect(seen.codexAuthJsonMirror).toBe("WROTE"); From abfcafd1053acacbc97814a5d6542531c1fa6016 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 18:23:16 +0900 Subject: [PATCH 3/5] fix(guard): normalise a stray OCX_TEST_HOME_GUARD so spawned helpers inherit the arming (#7407) --- src/lib/test-home-guard.ts | 8 ++++++-- .../test-home-guard-unpreloaded.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/lib/test-home-guard.ts b/src/lib/test-home-guard.ts index 1c0f094c688..d218b248370 100644 --- a/src/lib/test-home-guard.ts +++ b/src/lib/test-home-guard.ts @@ -158,9 +158,13 @@ export function isTestHomeGuardArmed(): boolean { * A test process that armed through its entry file publishes the flag, so the CLI and helper * processes it spawns — whose own entry is not a test file — inherit the arming instead of * writing the real home they also inherited. Runs at import, which in such a run happens before - * any test body. A production process never matches, so it never reaches the assignment. + * any test body. A production process never matches, so it never reaches the assignment. Any value + * but the explicit "0" opt-out is normalised, because an empty or stray value arms this process by + * name but would leave a non-test child, which cannot match by name, disarmed. */ -if (process.env[GUARD_ENV] === undefined && isBunTestFileProcess()) process.env[GUARD_ENV] = "1"; +if (process.env[GUARD_ENV] !== "0" && process.env[GUARD_ENV] !== "1" && isBunTestFileProcess()) { + process.env[GUARD_ENV] = "1"; +} /** * Appended to a refusal when no preload ran, because the fix there is different: the test did diff --git a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts index d35f87eb2e0..8637e464152 100644 --- a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts +++ b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts @@ -224,6 +224,22 @@ describe("bun test without the preload (#7407)", () => { expect(Object.keys(auth)).toContain("anthropic"); }, CHILD_MS + 5_000); + // Only "0" opts out; any other value is normalised to "1" before a helper can inherit it. + test.each(["", "yes"])("a stray OCX_TEST_HOME_GUARD=%p is normalised so spawned helpers stay armed", async stray => { + const home = fakeRealHome(); + const outside = owned("ocx-7407-stray-cwd-"); + const fixture = writeFixture(outside, "stray-flag.test.ts", home, true); + const before = snapshot(home); + + const result = await run(["test", fixture], outside, unsandboxedEnv(home, { OCX_TEST_HOME_GUARD: stray })); + const seen = report(result.stdout, result.stderr); + + expect(seen.armed).toBe("true"); + expect(seen.flag).toBe("1"); + expect(seen.child).toBe("REFUSED"); + expect(snapshot(home)).toEqual(before); + }, CHILD_MS + 5_000); + test("an explicit OCX_TEST_HOME_GUARD=0 still disarms a test file", async () => { const home = fakeRealHome(); const outside = owned("ocx-7407-optout-cwd-"); From 3b5d030bdfc8aa2e7588b4e14a8da1e456d74672 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 18:38:58 +0900 Subject: [PATCH 4/5] test(guard): assert Kimi and Nous writers still persist in production (#7407) --- tests/ci-workflows/test-home-guard-unpreloaded.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts index 8637e464152..9449f94d6cb 100644 --- a/tests/ci-workflows/test-home-guard-unpreloaded.test.ts +++ b/tests/ci-workflows/test-home-guard-unpreloaded.test.ts @@ -222,6 +222,11 @@ describe("bun test without the preload (#7407)", () => { expect(seen.codexAuthJsonMirror).toBe("WROTE"); const auth = JSON.parse(readFileSync(join(home, ".opencodex", "auth.json"), "utf8")) as Record; expect(Object.keys(auth)).toContain("anthropic"); + // The writers that now assert first still persist before their network call fails. + expect(readFileSync(join(home, ".opencodex", "kimi-device-id"), "utf8").trim()).not.toBe(""); + const intents = readdirSync(join(home, ".opencodex", ".nous-refresh-intent")).filter(name => name.endsWith(".json")); + expect(intents.length).toBe(1); + expect(JSON.parse(readFileSync(join(home, ".opencodex", ".nous-refresh-intent", intents[0]!), "utf8")).status).toBeString(); }, CHILD_MS + 5_000); // Only "0" opts out; any other value is normalised to "1" before a helper can inherit it. From 7993b710678e7de7ca977356fb949004e77a8d62 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 21 Oct 2026 18:55:05 +0900 Subject: [PATCH 5/5] test(layout): register the unpreloaded guard suite on its sibling line to stay under the layout cap --- scripts/test-layout/layout.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f83b12ebfae..d3ec63cefb5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1820,8 +1820,7 @@ "terminal-continuation-owner-rotation.test.ts": "adapters", "terminal-guard-server.test.ts": "server", "terminal-guard.test.ts": "server", - "test-home-guard.test.ts": "ci-workflows", - "test-home-guard-unpreloaded.test.ts": "ci-workflows", + "test-home-guard.test.ts": "ci-workflows", "test-home-guard-unpreloaded.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "test-runner-bun.test.ts": "ci-workflows", "test-runner-spawnsync-finalizer.test.ts": "ci-workflows", "test-sandbox-cleanup.test.ts": "ci-workflows", "test-temp-quarantine.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", "token-estimate.test.ts": "lib",