Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo>/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"]
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1820,7 +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.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",
Expand Down
89 changes: 78 additions & 11 deletions src/lib/test-home-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -109,8 +110,71 @@ 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 <repo>/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.
*
* 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;
const entry = typeof main === "string" ? main : process.argv[1];
return typeof entry === "string" && BUN_TEST_FILE.test(basename(entry));
Comment thread
lidge-jun marked this conversation as resolved.
Resolved
}

/**
* 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. 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] !== "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
* 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 <repo>/tests/preload.ts.";
}

/**
Expand Down Expand Up @@ -144,7 +208,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(),
);
}

Expand Down Expand Up @@ -177,7 +242,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(),
);
}

Expand Down Expand Up @@ -351,12 +417,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);
Expand Down
2 changes: 2 additions & 0 deletions src/oauth/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });
Expand Down
5 changes: 4 additions & 1 deletion src/oauth/nous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
10 changes: 10 additions & 0 deletions structure/ops/test-sandbox-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading