From 295860e123cd9cead854d567304767ac4b0cda60 Mon Sep 17 00:00:00 2001 From: Marcelo Cruz <58499846+MarcTCruz@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:56:22 +0000 Subject: [PATCH 1/4] refactor(codex): read the native-main auth.json through one module Every read of the bytes of /auth.json now calls src/codex/native-main-auth-file.ts. The module still reads the file on each call and keeps each caller's read mode (plain, bounded, local-bounded, or the stat-guarded read of the desktop identity reader), so results, statuses and error messages do not change. readBounded and MAX_AUTH_BYTES move to the new module (re-exported from native-profile-store.ts) to avoid an import cycle. Part 1a of the split plan in #7345 (from the closed #7344). --- src/codex/auth-collision.ts | 45 +++++----- .../desktop-compatibility/native-identity.ts | 12 ++- src/codex/main-account.ts | 5 +- src/codex/native-main-auth-file.ts | 90 +++++++++++++++++++ src/codex/native-profile-store.ts | 64 +------------ src/codex/orca-import.ts | 11 ++- structure/codex-home.md | 2 +- 7 files changed, 135 insertions(+), 94 deletions(-) create mode 100644 src/codex/native-main-auth-file.ts diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index 20452a1e64a..5d1f47605ef 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { join } from "node:path"; import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; @@ -7,7 +6,7 @@ import { extractAccountId } from "../oauth/chatgpt"; import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; import { codexPlanKey } from "./plan"; import { getObservedMainCodexCollisionIdentity, sameCodexCollisionIdentity, type CodexCollisionIdentity } from "./collision-identity"; -import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store"; +import { readNativeMainAuthBytes } from "./native-main-auth-file"; export interface CodexTokens { access_token: string; @@ -25,6 +24,25 @@ export type CodexTokenReadResult = | { status: "ok"; tokens: CodexTokens } | { status: "missing" | "invalid" | "unreadable" }; +function parseCodexTokens(raw: string): CodexTokenReadResult { + try { + const j = JSON.parse(raw) as { + tokens?: { access_token?: string; account_id?: string; id_token?: string }; + }; + if (!j?.tokens?.access_token) return { status: "invalid" }; + return { + status: "ok", + tokens: { + access_token: j.tokens.access_token, + account_id: j.tokens.account_id ?? "", + id_token: j.tokens.id_token, + }, + }; + } catch { + return { status: "invalid" }; + } +} + function hasErrnoCode(error: unknown, code: string): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === code; @@ -45,28 +63,11 @@ export function readCodexTokensResult( ): CodexTokenReadResult { let raw: string; try { - raw = options?.bounded === true - ? readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8") - : readFileSync(authPath, "utf-8"); + raw = readNativeMainAuthBytes(authPath, options?.bounded === true ? "bounded" : "plain").toString("utf-8"); } catch (error) { return { status: hasErrnoCode(error, "ENOENT") ? "missing" : "unreadable" }; } - try { - const j = JSON.parse(raw) as { - tokens?: { access_token?: string; account_id?: string; id_token?: string }; - }; - if (!j?.tokens?.access_token) return { status: "invalid" }; - return { - status: "ok", - tokens: { - access_token: j.tokens.access_token, - account_id: j.tokens.account_id ?? "", - id_token: j.tokens.id_token, - }, - }; - } catch { - return { status: "invalid" }; - } + return parseCodexTokens(raw); } /** @@ -132,7 +133,7 @@ export function readMainAuth(authPath = join(resolveCodexHomeDir(), "auth.json") const main = readCodexTokensResult(authPath, { bounded: true }); if (main.status !== "invalid") return main; try { - const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8")); + const j: unknown = JSON.parse(readNativeMainAuthBytes(authPath, "bounded").toString("utf-8")); if (j === null || typeof j !== "object" || Array.isArray(j)) return main; const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown }; const apiKeyOnly = envelope.tokens == null diff --git a/src/codex/desktop-compatibility/native-identity.ts b/src/codex/desktop-compatibility/native-identity.ts index e3f97d59e86..1a692c5522c 100644 --- a/src/codex/desktop-compatibility/native-identity.ts +++ b/src/codex/desktop-compatibility/native-identity.ts @@ -1,5 +1,5 @@ -import { readFileSync, statSync, type BigIntStats } from 'node:fs'; import { createHash, randomUUID } from 'node:crypto'; +import { readNativeMainAuthBytesStable } from '../native-main-auth-file'; import type { UsageIdentity } from './usage-controller'; type Credential = { identity: UsageIdentity; accessToken: string }; @@ -9,13 +9,11 @@ const equal = (a: UsageIdentity, b: UsageIdentity) => a.id === b.id && a.userId /** Local native credentials remain in memory and are never returned by the public reader. */ export function createNativeIdentityReader(authPath: string, upstreamFetch: typeof fetch = fetch) { const readerId = randomUUID(); let generation = 0, previousSnapshot: string | undefined; - const stamp = (value: BigIntStats) => - `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; const readCredential = (): Credential => { - const before = statSync(authPath, { bigint: true }); - if (before.size > 1048576n) throw new Error('Unexpected native auth size'); - const text = readFileSync(authPath, 'utf8'), after = statSync(authPath, { bigint: true }); - if (stamp(before) !== stamp(after)) throw new Error('Native credentials changed during read'); + const { bytes, stat: after } = readNativeMainAuthBytesStable(authPath, 1048576n); + const text = bytes.toString('utf8'); + const stamp = (value: typeof after) => + `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; const snapshot = `${stamp(after)}:${createHash('sha256').update(text).digest('hex')}`; if (snapshot !== previousSnapshot) { previousSnapshot = snapshot; generation++; } const auth = JSON.parse(text); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 50137e447dd..621ac9689c0 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -3,7 +3,7 @@ import { captureMainAccountIdentityGeneration, getMainQuotaCredentialGeneration, import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; -import { lstatSync, readFileSync, statSync } from "node:fs"; +import { lstatSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { readCodexTokens } from "./auth-collision"; import { @@ -22,6 +22,7 @@ import { } from "./account-store"; import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write"; import { resolveCodexHomeDir } from "./home"; +import { readNativeMainAuthBytes } from "./native-main-auth-file"; import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; import { clearAccountNeedsReauth, @@ -214,7 +215,7 @@ function readMainAuthJsonCredential(): MainAuthJsonCredential | null { const path = resolveWriteTarget(join(resolveCodexHomeDir(), "auth.json")); let raw: string; try { - raw = readFileSync(path, "utf8"); + raw = readNativeMainAuthBytes(path, "plain").toString("utf8"); } catch { return null; } diff --git a/src/codex/native-main-auth-file.ts b/src/codex/native-main-auth-file.ts new file mode 100644 index 00000000000..3ac394951fe --- /dev/null +++ b/src/codex/native-main-auth-file.ts @@ -0,0 +1,90 @@ +import { + closeSync, + constants as fsConstants, + fstatSync, + lstatSync, + openSync, + readFileSync, + readSync, + statSync, + type BigIntStats, +} from "node:fs"; +import { readBoundedLocalFile } from "./orca-auth-source"; + +export const MAX_AUTH_BYTES = 4 * 1024 * 1024; + +export interface BoundedReadTestSeam { + beforeOpen?: (path: string) => void; + afterValidatedOpen?: (path: string, fd: number) => void; + onBufferAllocated?: (byteLength: number) => void; + onRead?: (requestedBytes: number, bytesRead: number, totalBytesRead: number) => void; +} + +export type NativeMainAuthReadMode = "plain" | "bounded" | "local-bounded"; + +/** Reads the raw bytes of a native-main login file. Throws the error of the underlying read unchanged. */ +export function readNativeMainAuthBytes(path: string, mode: NativeMainAuthReadMode, limit?: number): Buffer { + if (mode === "plain") return readFileSync(path); + if (mode === "bounded") return readBounded(path, limit ?? MAX_AUTH_BYTES); + return Buffer.from(readBoundedLocalFile(path, limit ?? MAX_AUTH_BYTES), "utf8"); +} + +/** Reads with a stat before and after, as the desktop identity reader did. */ +export function readNativeMainAuthBytesStable(path: string, maxBytes: bigint): { bytes: Buffer; stat: BigIntStats } { + const before = statSync(path, { bigint: true }); + if (before.size > maxBytes) throw new Error('Unexpected native auth size'); + const bytes = readFileSync(path); + const after = statSync(path, { bigint: true }); + const stamp = (value: BigIntStats) => `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; + if (stamp(before) !== stamp(after)) throw new Error('Native credentials changed during read'); + return { bytes, stat: after }; +} + +export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { + let fd: number | undefined; + let failed = false; + try { + testSeam?.beforeOpen?.(path); + const flags = process.platform === "win32" + ? fsConstants.O_RDONLY + : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; + fd = openSync(path, flags); + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.size > BigInt(limit)) throw new Error("invalid bounded file"); + + if (process.platform === "win32") { + const pathEntry = lstatSync(path, { bigint: true }); + if ( + !pathEntry.isFile() + || pathEntry.isSymbolicLink() + || pathEntry.dev !== opened.dev + || pathEntry.ino !== opened.ino + ) throw new Error("invalid bounded file"); + } + + testSeam?.afterValidatedOpen?.(path, fd); + const buffer = Buffer.allocUnsafe(limit + 1); + testSeam?.onBufferAllocated?.(buffer.byteLength); + let totalBytesRead = 0; + while (totalBytesRead < buffer.byteLength) { + const requestedBytes = buffer.byteLength - totalBytesRead; + const bytesRead = readSync(fd, buffer, totalBytesRead, requestedBytes, totalBytesRead); + totalBytesRead += bytesRead; + testSeam?.onRead?.(requestedBytes, bytesRead, totalBytesRead); + if (bytesRead === 0) break; + } + if (totalBytesRead > limit) throw new Error("invalid bounded file"); + return buffer.subarray(0, totalBytesRead); + } catch (error) { + failed = true; + throw error; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch (error) { + if (!failed) throw error; + } + } + } +} diff --git a/src/codex/native-profile-store.ts b/src/codex/native-profile-store.ts index 1f41cc2c0b9..da6bfaed115 100644 --- a/src/codex/native-profile-store.ts +++ b/src/codex/native-profile-store.ts @@ -7,19 +7,17 @@ import { timingSafeEqual, } from "node:crypto"; import { - closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, - openSync, readdirSync, - readSync, realpathSync, statSync, } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { getConfigDir } from "../config"; +import { MAX_AUTH_BYTES, readBounded, readNativeMainAuthBytes, type BoundedReadTestSeam } from "./native-main-auth-file"; import { loadKeyringBinding } from "../lib/keyring-native"; import { extractAccountId } from "../oauth/chatgpt"; import { getCodexHome, readRootTomlString } from "./paths"; @@ -42,7 +40,7 @@ const KEYRING_SERVICE = "opencodex.native-main-profile.v1"; const SHARED_METADATA_DIR = ".opencodex-native-main-profiles"; const INSTANCE_STAGING_DIR = "native-main-profile-staging"; const LEGACY_METADATA_DIR = "native-main-profiles"; -export const MAX_AUTH_BYTES = 4 * 1024 * 1024; +export { MAX_AUTH_BYTES, readBounded } from "./native-main-auth-file"; export const MAX_NATIVE_PROFILE_METADATA_BYTES = 4 * 1024 * 1024; export const MAX_NATIVE_PROFILE_JOURNAL_BYTES = 17 * 1024 * 1024; export const MAX_NATIVE_PROFILES = 32; @@ -67,13 +65,6 @@ export interface NativeProfileContext { lockPath: string; } -interface BoundedReadTestSeam { - beforeOpen?: (path: string) => void; - afterValidatedOpen?: (path: string, fd: number) => void; - onBufferAllocated?: (byteLength: number) => void; - onRead?: (requestedBytes: number, bytesRead: number, totalBytesRead: number) => void; -} - function boundedReadTestSeam(context: NativeProfileContext): BoundedReadTestSeam | undefined { return (context as NativeProfileContext & { [BOUNDED_READ_TEST_SEAM]?: BoundedReadTestSeam })[BOUNDED_READ_TEST_SEAM]; } @@ -378,55 +369,6 @@ export function resolveNativeProfileContext(options: { codexHome?: string; confi }; } -export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { - let fd: number | undefined; - let failed = false; - try { - testSeam?.beforeOpen?.(path); - const flags = process.platform === "win32" - ? fsConstants.O_RDONLY - : fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK; - fd = openSync(path, flags); - const opened = fstatSync(fd, { bigint: true }); - if (!opened.isFile() || opened.size > BigInt(limit)) throw new Error("invalid bounded file"); - - if (process.platform === "win32") { - const pathEntry = lstatSync(path, { bigint: true }); - if ( - !pathEntry.isFile() - || pathEntry.isSymbolicLink() - || pathEntry.dev !== opened.dev - || pathEntry.ino !== opened.ino - ) throw new Error("invalid bounded file"); - } - - testSeam?.afterValidatedOpen?.(path, fd); - const buffer = Buffer.allocUnsafe(limit + 1); - testSeam?.onBufferAllocated?.(buffer.byteLength); - let totalBytesRead = 0; - while (totalBytesRead < buffer.byteLength) { - const requestedBytes = buffer.byteLength - totalBytesRead; - const bytesRead = readSync(fd, buffer, totalBytesRead, requestedBytes, totalBytesRead); - totalBytesRead += bytesRead; - testSeam?.onRead?.(requestedBytes, bytesRead, totalBytesRead); - if (bytesRead === 0) break; - } - if (totalBytesRead > limit) throw new Error("invalid bounded file"); - return buffer.subarray(0, totalBytesRead); - } catch (error) { - failed = true; - throw error; - } finally { - if (fd !== undefined) { - try { - closeSync(fd); - } catch (error) { - if (!failed) throw error; - } - } - } -} - export function resolveNativeCredentialStoreMode(context: NativeProfileContext): string { const path = join(context.codexHome, "config.toml"); let content: string; @@ -501,7 +443,7 @@ export function parseNativeEnvelopeBytes(raw: Buffer): NativeEnvelopeSnapshot { export function readNativeEnvelopeResult(path: string): NativeEnvelopeReadResult { let raw: Buffer; try { - raw = readBounded(path, MAX_AUTH_BYTES); + raw = readNativeMainAuthBytes(path, "bounded"); } catch (error) { return { status: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unreadable" }; } diff --git a/src/codex/orca-import.ts b/src/codex/orca-import.ts index 918998aae85..bf9e64483d1 100644 --- a/src/codex/orca-import.ts +++ b/src/codex/orca-import.ts @@ -5,6 +5,7 @@ import { atomicWriteFile, getConfigDir, getConfigPath, mutatePersistedConfig, re import { readAlivePid, readRuntimePort } from "../config/process-state"; import { getCodexHome } from "./paths"; import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; +import { readNativeMainAuthBytes } from "./native-main-auth-file"; import { assertPlainLocalPath, ORCA_ACCOUNT_DIRECTORY, parseOrcaAuth, readBoundedLocalFile, readOrcaAuthSource, sameLocalPath } from "./orca-auth-source"; export class OrcaImportError extends Error { @@ -101,7 +102,15 @@ function prepare(sourceDir: string, registryPath: string) { recoverable.set(credential.chatgptAccountId, { id, credential }); } } - const mainRaw = optionalFile(join(getCodexHome(), "auth.json")); + let mainRaw: string | undefined; + const mainAuthPath = join(getCodexHome(), "auth.json"); + try { + mainRaw = readNativeMainAuthBytes(mainAuthPath, "local-bounded", 4 * 1024 * 1024).toString("utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw new OrcaImportError("A target or main credential file is unreadable; import refused."); + } + } if (mainRaw !== undefined) { const main = object(JSON.parse(mainRaw)); if (main.tokens !== undefined) { diff --git a/structure/codex-home.md b/structure/codex-home.md index 2da166cd57b..4207b50cd48 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -34,7 +34,7 @@ issuing deferred inference warmups. Already dispatched requests retain their cap ## Codex home `src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from -`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. On WSL, +`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. Native-main credential byte reads share one reader while preserving each caller's plain, bounded, or stable-read behavior. On WSL, discovery of a Windows Desktop home applies when the Linux `~/.codex` is absent, is not a directory, or is a directory holding no Codex state (none of `config.toml`, `auth.json`, `sessions`, `history.jsonl`; `defaultCodexHome` in `src/codex/home.ts`). A fresh local home Codex is already using From d90b205d4bff29fe9471b2d562abd81fb20069d8 Mon Sep 17 00:00:00 2001 From: Marcelo Cruz <58499846+MarcTCruz@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:00:50 +0000 Subject: [PATCH 2/4] test(codex): guard the auth.json literals in src with a file:line allowlist Every src line that names "auth.json" must be on the allowlist, with the reason it is allowed. A new reader of the native-main login therefore fails this test until it goes through src/codex/native-main-auth-file.ts or is added on purpose. Part 1a of the split plan in #7345 (from the closed #7344). --- .../native-main-auth-literal-guard.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/codex-integration/native-main-auth-literal-guard.test.ts diff --git a/tests/codex-integration/native-main-auth-literal-guard.test.ts b/tests/codex-integration/native-main-auth-literal-guard.test.ts new file mode 100644 index 00000000000..19ca95b8a50 --- /dev/null +++ b/tests/codex-integration/native-main-auth-literal-guard.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { repoRoot } from "../helpers/repo-root"; +const ROOT = repoRoot(); +const ALLOWED: string[] = [ + "src/cli/codex-launch-auth.ts:36", // launch layer presence probe (lstat), reads no bytes + "src/cli/doctor.ts:316", // diagnostics presence check, reads no bytes + "src/codex/auth-api/main-account-probe.ts:164", // presence check, reads no bytes + "src/codex/auth-collision.ts:132", // reads through readNativeMainAuthBytes of the module + "src/codex/auth-collision.ts:61", // reads through readNativeMainAuthBytes of the module + "src/codex/desktop-compatibility/runtime.ts:133", // wires the identity reader of the module + "src/codex/home.ts:170", // presence check, reads no bytes + "src/codex/main-account.ts:215", // reads through readNativeMainAuthBytes of the module + "src/codex/native-profile-manager.ts:1089", // staged-home read through readNativeEnvelope of the module + "src/codex/native-profile-manager.ts:717", // staged-home cleanup, deletes instead of reading + "src/codex/native-profile-store.ts:363", // context path; bytes read through readNativeMainAuthBytes + "src/codex/orca-auth-source.ts:85", // Orca location validation, reads no bytes + "src/codex/orca-import.ts:106", // native-main login path; bytes read through readNativeMainAuthBytes + "src/codex/orca-import.ts:152", // Orca account home, not the native-main login + "src/config.ts:219", // OpenCodex provider store, ACL hardening only + "src/integrations/hermes-profiles.ts:30", // identity marker list, reads no bytes + "src/lib/config-ownership.ts:41", // ownership manifest name list, reads no bytes + "src/local-credentials/inventory.ts:141", // reads through readBounded of the module + "src/local-credentials/inventory.ts:88", // integrity target list, the read goes through readBounded + "src/local-credentials/inventory.ts:93", // OpenCodex provider store, not the native-main login + "src/oauth/command-code.ts:40", // CommandCode store, not the native-main login + "src/oauth/local-token-detect.ts:16", // Grok store, not the native-main login + "src/oauth/meta-muse.ts:33", // muse pointer path, not the native-main login + "src/oauth/store.ts:82", // OpenCodex provider store, not the native-main login + "src/server/configured-credentials.ts:186", // OpenCodex provider store, not the native-main login +]; + +test("every auth.json literal line in src matches the file:line allowlist", () => { + const scan = (dir: string): string[] => readdirSync(dir, { withFileTypes: true }).flatMap(entry => + entry.isDirectory() ? scan(join(dir, entry.name)) : /\.(ts|mts|mjs)$/.test(entry.name) ? [join(dir, entry.name)] : []); + const found = scan(join(ROOT, "src")).flatMap(file => readFileSync(file, "utf8").split("\n").flatMap((line, index) => + line.includes('"auth.json"') || line.includes("'auth.json'") ? [`${file.slice(ROOT.length + 1).replaceAll("\\", "/")}:${index + 1}`] : [])); + expect(found.sort()).toEqual(ALLOWED.slice().sort()); +}); From 3f45b86008f45c4ad8323ba8302c4c522ba634bc Mon Sep 17 00:00:00 2001 From: Marcelo Cruz <58499846+MarcTCruz@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:21:08 +0000 Subject: [PATCH 3/4] refactor(codex): share the native-main auth.json stat stamp (CodeRabbit review) --- src/codex/desktop-compatibility/native-identity.ts | 6 ++---- src/codex/native-main-auth-file.ts | 7 +++++-- structure/codex-home.md | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/codex/desktop-compatibility/native-identity.ts b/src/codex/desktop-compatibility/native-identity.ts index 1a692c5522c..2e57d543cc0 100644 --- a/src/codex/desktop-compatibility/native-identity.ts +++ b/src/codex/desktop-compatibility/native-identity.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { readNativeMainAuthBytesStable } from '../native-main-auth-file'; +import { nativeAuthStatStamp, readNativeMainAuthBytesStable } from '../native-main-auth-file'; import type { UsageIdentity } from './usage-controller'; type Credential = { identity: UsageIdentity; accessToken: string }; @@ -12,9 +12,7 @@ export function createNativeIdentityReader(authPath: string, upstreamFetch: type const readCredential = (): Credential => { const { bytes, stat: after } = readNativeMainAuthBytesStable(authPath, 1048576n); const text = bytes.toString('utf8'); - const stamp = (value: typeof after) => - `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; - const snapshot = `${stamp(after)}:${createHash('sha256').update(text).digest('hex')}`; + const snapshot = `${nativeAuthStatStamp(after)}:${createHash('sha256').update(text).digest('hex')}`; if (snapshot !== previousSnapshot) { previousSnapshot = snapshot; generation++; } const auth = JSON.parse(text); if (auth.auth_mode !== 'chatgpt' || typeof auth.tokens?.id_token !== 'string' diff --git a/src/codex/native-main-auth-file.ts b/src/codex/native-main-auth-file.ts index 3ac394951fe..194229706d6 100644 --- a/src/codex/native-main-auth-file.ts +++ b/src/codex/native-main-auth-file.ts @@ -29,14 +29,17 @@ export function readNativeMainAuthBytes(path: string, mode: NativeMainAuthReadMo return Buffer.from(readBoundedLocalFile(path, limit ?? MAX_AUTH_BYTES), "utf8"); } +export function nativeAuthStatStamp(value: BigIntStats): string { + return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; +} + /** Reads with a stat before and after, as the desktop identity reader did. */ export function readNativeMainAuthBytesStable(path: string, maxBytes: bigint): { bytes: Buffer; stat: BigIntStats } { const before = statSync(path, { bigint: true }); if (before.size > maxBytes) throw new Error('Unexpected native auth size'); const bytes = readFileSync(path); const after = statSync(path, { bigint: true }); - const stamp = (value: BigIntStats) => `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`; - if (stamp(before) !== stamp(after)) throw new Error('Native credentials changed during read'); + if (nativeAuthStatStamp(before) !== nativeAuthStatStamp(after)) throw new Error('Native credentials changed during read'); return { bytes, stat: after }; } diff --git a/structure/codex-home.md b/structure/codex-home.md index 4207b50cd48..5f6dd991448 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -34,7 +34,7 @@ issuing deferred inference warmups. Already dispatched requests retain their cap ## Codex home `src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from -`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. Native-main credential byte reads share one reader while preserving each caller's plain, bounded, or stable-read behavior. On WSL, +`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. Native-main credential byte reads share one reader while preserving each caller's plain, bounded, local-bounded, or stable-read behavior. On WSL, discovery of a Windows Desktop home applies when the Linux `~/.codex` is absent, is not a directory, or is a directory holding no Codex state (none of `config.toml`, `auth.json`, `sessions`, `history.jsonl`; `defaultCodexHome` in `src/codex/home.ts`). A fresh local home Codex is already using From eb746134045f211ea9b02a2a00b3e4e360519e94 Mon Sep 17 00:00:00 2001 From: Marcelo Cruz <58499846+MarcTCruz@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:05:20 +0000 Subject: [PATCH 4/4] refactor(codex): write the native-main auth.json through one module (#7345 part 1b) --- src/codex/main-account.ts | 50 ++++++++----------- src/codex/native-main-auth-file.ts | 11 ++++ src/codex/native-profile-manager.ts | 7 +-- structure/codex-home.md | 2 +- .../native-main-auth-literal-guard.test.ts | 4 +- 5 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 621ac9689c0..63ac8fe5aee 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -20,9 +20,9 @@ import { refreshGrantFingerprintForToken, withCodexRefreshFileLock, } from "./account-store"; -import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write"; +import { resolveWriteTarget } from "../config/atomic-write"; import { resolveCodexHomeDir } from "./home"; -import { readNativeMainAuthBytes } from "./native-main-auth-file"; +import { readNativeMainAuthBytes, writeNativeMainAuthFile } from "./native-main-auth-file"; import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; import { clearAccountNeedsReauth, @@ -323,6 +323,21 @@ function assertMainAuthJsonIdentityUnchanged(expected: MainAuthJsonCredential): } } +function publishMainAuthJson(expected: MainAuthJsonCredential, content: string): void { + writeNativeMainAuthFile(expected.path, content, { + beforeRename: () => { + assertMainAuthJsonSnapshotUnchanged(expected); + const hook = beforeMainAuthJsonRenameForTests; + beforeMainAuthJsonRenameForTests = null; + hook?.(); + }, + // Runs immediately before rename(2), after the test hook has had its chance to + // simulate an external writer. Full snapshot check (content AND identity): this is + // the last look we get, so it asks everything it can rather than the cheap question. + validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), + }); +} + function persistRefreshedMainAuthJson( expected: MainAuthJsonCredential, refreshed: OAuthCredentials, @@ -337,22 +352,9 @@ function persistRefreshedMainAuthJson( refresh_token: refreshToken, account_id: chatgptAccountId, }; - publishCredentialSync(() => atomicWriteFile( - expected.path, + publishCredentialSync(() => publishMainAuthJson( + expected, JSON.stringify({ ...expected.root, tokens }, null, 2) + "\n", - undefined, - { - beforeRename: () => { - assertMainAuthJsonSnapshotUnchanged(expected); - const hook = beforeMainAuthJsonRenameForTests; - beforeMainAuthJsonRenameForTests = null; - hook?.(); - }, - // Runs immediately before rename(2), after the test hook has had its chance to - // simulate an external writer. Full snapshot check (content AND identity): this is - // the last look we get, so it asks everything it can rather than the cheap question. - validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), - }, )); advanceCodexCredentialMutationEpoch(); return { accessToken, chatgptAccountId }; @@ -404,19 +406,9 @@ function persistNativeMainReauthTokens( id_token: tokens.idToken, account_id: tokens.chatgptAccountId, }; - atomicWriteFile( - expected.path, + publishMainAuthJson( + expected, JSON.stringify({ ...expected.root, tokens: nextTokens }, null, 2) + "\n", - undefined, - { - beforeRename: () => { - assertMainAuthJsonSnapshotUnchanged(expected); - const hook = beforeMainAuthJsonRenameForTests; - beforeMainAuthJsonRenameForTests = null; - hook?.(); - }, - validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), - }, ); advanceCodexCredentialMutationEpoch(); } diff --git a/src/codex/native-main-auth-file.ts b/src/codex/native-main-auth-file.ts index 194229706d6..79699a5a66b 100644 --- a/src/codex/native-main-auth-file.ts +++ b/src/codex/native-main-auth-file.ts @@ -10,6 +10,7 @@ import { type BigIntStats, } from "node:fs"; import { readBoundedLocalFile } from "./orca-auth-source"; +import { atomicWriteFile, type AtomicWriteHooks } from "../config/atomic-write"; export const MAX_AUTH_BYTES = 4 * 1024 * 1024; @@ -43,6 +44,16 @@ export function readNativeMainAuthBytesStable(path: string, maxBytes: bigint): { return { bytes, stat: after }; } +/** The only sync writer of a native-main login file. */ +export function writeNativeMainAuthFile(path: string, content: string, hooks: AtomicWriteHooks): void { + atomicWriteFile(path, content, undefined, hooks); +} + +/** The only async writer of a native-main login file. `write` keeps the caller's injected writer (a test seam of NativeProfileManager). */ +export async function writeNativeMainAuthFileAsync(path: string, content: string, write: (path: string, content: string) => Promise): Promise { + await write(path, content); +} + export function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam): Buffer { let fd: number | undefined; let failed = false; diff --git a/src/codex/native-profile-manager.ts b/src/codex/native-profile-manager.ts index 3fd400cf18f..0d49fee20e4 100644 --- a/src/codex/native-profile-manager.ts +++ b/src/codex/native-profile-manager.ts @@ -75,6 +75,7 @@ import { type NativeStageProof, type NativeStageTerminalOutcome, } from "./native-profile-stage-store"; +import { writeNativeMainAuthFileAsync } from "./native-main-auth-file"; const LOCK_WAIT_MS = 5_000; const LEGACY_STAGE_MAX_AGE_MS = 30 * 60_000; @@ -1307,7 +1308,7 @@ export class NativeProfileManager { journalPrepared = true; await this.onSwitchBoundary("journal-prepared"); this.assertOperationStorageStable(); - await this.atomicWrite(this.context.authPath, target.text); + await writeNativeMainAuthFileAsync(this.context.authPath, target.text, this.atomicWrite); const observedTarget = this.verifyWrittenEnvelope(target.digest, targetProfile.identityHash, key); observedTarget.raw.fill(0); advanceCodexCredentialMutationEpoch(); @@ -1336,7 +1337,7 @@ export class NativeProfileManager { } try { this.assertOperationStorageStable(); - await this.atomicWrite(this.context.authPath, source!.text); + await writeNativeMainAuthFileAsync(this.context.authPath, source!.text, this.atomicWrite); const restored = this.verifyWrittenEnvelope(source!.digest, nativeIdentityHash(key!.key, source!.accountId), key!); restored.raw.fill(0); advanceCodexCredentialMutationEpoch(); @@ -1451,7 +1452,7 @@ export class NativeProfileManager { rollbackVaultPublished = true; } this.assertOperationStorageStable(); - await this.atomicWrite(this.context.authPath, sourceEnvelope.text); + await writeNativeMainAuthFileAsync(this.context.authPath, sourceEnvelope.text, this.atomicWrite); const restored = this.verifyWrittenEnvelope(sourceEnvelope.digest, journal.sourceIdentityHash, key); restored.raw.fill(0); advanceCodexCredentialMutationEpoch(); diff --git a/structure/codex-home.md b/structure/codex-home.md index 5f6dd991448..27e310b8e68 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -34,7 +34,7 @@ issuing deferred inference warmups. Already dispatched requests retain their cap ## Codex home `src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from -`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. Native-main credential byte reads share one reader while preserving each caller's plain, bounded, local-bounded, or stable-read behavior. On WSL, +`~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. Native-main credential byte reads share one reader while preserving each caller's plain, bounded, local-bounded, or stable-read behavior; the same module owns the synchronous and asynchronous writers for the native-main login file. On WSL, discovery of a Windows Desktop home applies when the Linux `~/.codex` is absent, is not a directory, or is a directory holding no Codex state (none of `config.toml`, `auth.json`, `sessions`, `history.jsonl`; `defaultCodexHome` in `src/codex/home.ts`). A fresh local home Codex is already using diff --git a/tests/codex-integration/native-main-auth-literal-guard.test.ts b/tests/codex-integration/native-main-auth-literal-guard.test.ts index 19ca95b8a50..a5fb6805646 100644 --- a/tests/codex-integration/native-main-auth-literal-guard.test.ts +++ b/tests/codex-integration/native-main-auth-literal-guard.test.ts @@ -12,8 +12,8 @@ const ALLOWED: string[] = [ "src/codex/desktop-compatibility/runtime.ts:133", // wires the identity reader of the module "src/codex/home.ts:170", // presence check, reads no bytes "src/codex/main-account.ts:215", // reads through readNativeMainAuthBytes of the module - "src/codex/native-profile-manager.ts:1089", // staged-home read through readNativeEnvelope of the module - "src/codex/native-profile-manager.ts:717", // staged-home cleanup, deletes instead of reading + "src/codex/native-profile-manager.ts:1090", // staged-home read through readNativeEnvelope of the module + "src/codex/native-profile-manager.ts:718", // staged-home cleanup, deletes instead of reading "src/codex/native-profile-store.ts:363", // context path; bytes read through readNativeMainAuthBytes "src/codex/orca-auth-source.ts:85", // Orca location validation, reads no bytes "src/codex/orca-import.ts:106", // native-main login path; bytes read through readNativeMainAuthBytes