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..2e57d543cc0 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 { nativeAuthStatStamp, readNativeMainAuthBytesStable } from '../native-main-auth-file'; import type { UsageIdentity } from './usage-controller'; type Credential = { identity: UsageIdentity; accessToken: string }; @@ -9,14 +9,10 @@ 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 snapshot = `${stamp(after)}:${createHash('sha256').update(text).digest('hex')}`; + const { bytes, stat: after } = readNativeMainAuthBytesStable(authPath, 1048576n); + const text = bytes.toString('utf8'); + 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/main-account.ts b/src/codex/main-account.ts index 1d687c3c66d..8ed9e6e4193 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 { sameFileIdentity, type FileIdentity } from "../lib/file-identity"; import { dirname, join, resolve } from "node:path"; import { readCodexTokens } from "./auth-collision"; @@ -21,8 +21,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, writeNativeMainAuthFile } from "./native-main-auth-file"; import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard"; import { clearAccountNeedsReauth, @@ -215,7 +216,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; } @@ -322,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, @@ -336,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 }; @@ -403,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 new file mode 100644 index 00000000000..79699a5a66b --- /dev/null +++ b/src/codex/native-main-auth-file.ts @@ -0,0 +1,104 @@ +import { + closeSync, + constants as fsConstants, + fstatSync, + lstatSync, + openSync, + readFileSync, + readSync, + statSync, + 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; + +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"); +} + +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 }); + if (nativeAuthStatStamp(before) !== nativeAuthStatStamp(after)) throw new Error('Native credentials changed during read'); + 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; + 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-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/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 2dbb670dc6d..e07c05c4c05 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, 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