Skip to content
45 changes: 23 additions & 22 deletions src/codex/auth-collision.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { getCodexAccountCredential } from "./account-store";
import { loadConfig } from "../config";
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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
Expand Down
12 changes: 4 additions & 8 deletions src/codex/desktop-compatibility/native-identity.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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'
Expand Down
53 changes: 23 additions & 30 deletions src/codex/main-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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();
}
Expand Down
104 changes: 104 additions & 0 deletions src/codex/native-main-auth-file.ts
Original file line number Diff line number Diff line change
@@ -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<void>): Promise<void> {
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;
}
}
}
}
7 changes: 4 additions & 3 deletions src/codex/native-profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading