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
413 changes: 300 additions & 113 deletions src/codex/account-store.ts

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/codex/account-usability.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getCodexAccountCredential } from "./account-store";
import { isAccountNeedsReauth } from "./account-runtime-state";
import { MAIN_CODEX_ACCOUNT_ID, isMainAccountTokenLive } from "./main-account";
import { MAIN_CODEX_ACCOUNT_ID, isMainAccountCredentialUsable, isMainAccountTokenLive } from "./main-account";
import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id";
import type { OcxConfig } from "../types";
import { isNativeMainTrafficBlocked } from "./native-profile-startup";
Expand Down Expand Up @@ -32,8 +32,8 @@ export function isCodexAccountUsable(
// before reservation or token materialization. Treat cached main as a routing
// candidate without touching the credential file so affinity is not rebound.
if (options.nativeMainSelectionOnly) return true;
// Main account: credential is the read-only ~/.codex/auth.json token (Option A).
return (options.isMainAccountTokenLive ?? isMainAccountTokenLive)();
// Main account: credential is ~/.codex/auth.json and may be refreshed from its native refresh token.
return (options.isMainAccountTokenLive ?? isMainAccountCredentialUsable)();
}
const exists = (config.codexAccounts ?? [])
.some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
Expand Down
4 changes: 3 additions & 1 deletion src/codex/auth-collision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface CodexTokens {
access_token: string;
account_id: string;
id_token?: string;
refresh_token?: string;
}

/**
Expand Down Expand Up @@ -42,7 +43,7 @@ export function readCodexTokensResult(): CodexTokenReadResult {
}
try {
const j = JSON.parse(raw) as {
tokens?: { access_token?: string; account_id?: string; id_token?: string };
tokens?: { access_token?: string; account_id?: string; id_token?: string; refresh_token?: string };
};
if (!j?.tokens?.access_token) return { status: "invalid" };
return {
Expand All @@ -51,6 +52,7 @@ export function readCodexTokensResult(): CodexTokenReadResult {
access_token: j.tokens.access_token,
account_id: j.tokens.account_id ?? "",
id_token: j.tokens.id_token,
refresh_token: j.tokens.refresh_token,
},
};
} catch {
Expand Down
101 changes: 76 additions & 25 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CodexCredentialRefreshLockTimeoutError,
CodexCredentialRefreshBusyError,
CodexCredentialRefreshStaleError,
TokenRefreshError,
getValidCodexToken,
isCodexAccountGenerationLive,
} from "./account-store";
Expand All @@ -11,7 +12,8 @@ import { isCodexAccountPaused } from "./account-pause";
import { ConfigMutationLockError } from "../config";
import { isCodexAccountUsable } from "./account-usability";
import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken, isMainAccountTokenLive } from "./main-account";
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken, getValidMainAccountToken, isMainAccountTokenLive } from "./main-account";
import type { NativeMainRefreshDependencies } from "./main-account";
import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
import type { NativeMainStartupBlockReason } from "./native-profile-startup";
import {
Expand Down Expand Up @@ -39,6 +41,16 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
import { FORWARD_HEADERS } from "../adapters/openai-responses";
import { captureConfigGeneration } from "../lib/state-store-sweeper";

function startLazyQuotaPrime(config: OcxConfig, prime?: (config: OcxConfig, reason: string) => Promise<void>): void {
if (prime) {
void prime(config, "pre-route").catch(() => {});
return;
}
void import("./auth-api")
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
.catch(() => {});
}

export type CodexAuthContext =
| { kind: "main"; accountId: null }
| {
Expand Down Expand Up @@ -295,6 +307,8 @@ export interface ResolveCodexAuthContextOptions {
/** Test-only native credential read seams. */
isMainAccountTokenLive?: () => boolean;
getMainAccountToken?: typeof getMainAccountToken;
getValidMainAccountToken?: typeof getValidMainAccountToken;
nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void>;
/** Test seam for account-gated native model discovery. */
resolveCodexModelEntitlements?: (
Expand Down Expand Up @@ -450,13 +464,7 @@ export async function resolveCodexAuthContext(
// blocks the current request, and the helper's single-flight guard collapses
// repeated triggers into one pass.
if (fixedAccountId === undefined && !nativeMainReadsForbidden && !getAccountQuota(accountId)) {
if (options.primeCodexPoolQuotas) {
void options.primeCodexPoolQuotas(config, "pre-route").catch(() => {});
} else {
import("./auth-api")
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
.catch(() => {});
}
startLazyQuotaPrime(config, options.primeCodexPoolQuotas);
}
// Snapshot (not just the deadline) so a refused request can report WHY it is cooled:
// a literal Retry-After reads very differently to a user than a reset-derived guess.
Expand All @@ -483,27 +491,48 @@ export async function resolveCodexAuthContext(
}

if (accountId === MAIN_CODEX_ACCOUNT_ID) {
// Main account in rotation: inject the read-only auth.json token and fail closed if it vanished.
const token = (options.getMainAccountToken ?? getMainAccountToken)();
if (!token) {
try {
// Main account in rotation: inject a valid auth.json token, refreshing it when possible.
const token = options.getValidMainAccountToken
? await options.getValidMainAccountToken()
: options.getMainAccountToken
? options.getMainAccountToken()
: await getValidMainAccountToken({ dependencies: options.nativeMainRefreshDependencies });
if (!token) {
// Nothing will reach upstream, so give the probe back instead of burning it.
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
throw new CodexPoolAuthenticationError(
fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
);
}
return {
kind: "main-pool",
accountId,
writerGeneration,
accessToken: token.accessToken,
chatgptAccountId: token.chatgptAccountId,
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
...(quotaScope ? { quotaScope } : {}),
...(probeLeaseId ? { probeLeaseId } : {}),
...(probeQuotaScope ? { probeQuotaScope } : {}),
};
} catch (cause) {
// Nothing will reach upstream, so give the probe back instead of burning it.
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
throw new CodexPoolAuthenticationError(
fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
);
if (
cause instanceof CodexPoolAuthenticationError
|| cause instanceof TokenRefreshError
|| cause instanceof CodexCredentialRefreshLockTimeoutError
|| cause instanceof CodexCredentialRefreshBusyError
|| cause instanceof CodexCredentialRefreshStaleError
) throw cause;
if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
markAccountNeedsReauth(accountId, writerGeneration);
}
throw new CodexAuthContextError(accountId, cause);
}
return {
kind: "main-pool",
accountId,
writerGeneration,
accessToken: token.accessToken,
chatgptAccountId: token.chatgptAccountId,
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
...(quotaScope ? { quotaScope } : {}),
...(probeLeaseId ? { probeLeaseId } : {}),
...(probeQuotaScope ? { probeQuotaScope } : {}),
};
}

try {
Expand Down Expand Up @@ -605,6 +634,28 @@ export function materializeCodexUpstreamAuth(
return selected;
}

export async function materializeCodexUpstreamAuthAsync(args: {
headers: Headers;
ctx: CodexAuthContext;
options?: { substituteMainCredential?: boolean; nativeMainRefreshDependencies?: NativeMainRefreshDependencies };
}): Promise<Headers> {
const options = args.options ?? {};
if (args.ctx.kind !== "main" || options.substituteMainCredential !== true) {
return materializeCodexUpstreamAuth(args.headers, args.ctx, options);
}
const selected = new Headers();
for (const name of FORWARD_HEADERS) {
const value = args.headers.get(name);
if (value) selected.set(name, value);
}
const stored = await getValidMainAccountToken({ dependencies: options.nativeMainRefreshDependencies });
// Fail BEFORE any upstream I/O. Falling through here would send the admission secret.
if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError();
selected.set("authorization", `Bearer ${stored.accessToken}`);
if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
return selected;
}

/** @deprecated Prefer materializeCodexUpstreamAuth; kept for call sites without admission context. */
export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
return materializeCodexUpstreamAuth(headers, ctx);
Expand Down
Loading
Loading