Skip to content
Closed
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
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 { dirname, join, resolve } from "node:path";
import { readCodexTokens } from "./auth-collision";
import {
Expand All @@ -20,8 +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, writeNativeMainAuthFile } from "./native-main-auth-file";
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
import {
clearAccountNeedsReauth,
Expand Down Expand Up @@ -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;
}
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