Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ named a reason that only a new sign-in fixes (`token_invalidated`, `invalid_refr
`invalid_workspace_selected`). A `token_invalidated` response indicates a revoked session;
the row then shows `needsReauth: true` with the last-known plan.

If the token endpoint rejects the stored main refresh grant, automatic requests stop retrying
that same grant even if a usage refresh succeeds. Sign in again with `codex login` to replace
the credential. The process keeps up to 64 profile-and-grant refusal records; restarting the
proxy or evicting an old record permits a fresh check. Rate-limit and server failures remain
retryable and do not retire the grant. Credentials supplied by the caller remain caller-owned.


### Which proxy path is used?

The running proxy service fetches quota. It uses its own environment, not the
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"zed-retained-budget.test.ts": "providers",
"jev-decision-scope.test.ts": "server",
"codex-main-token-invalidated.test.ts": "codex-integration",
"codex-main-grant-refusal.test.ts": "codex-integration",
"responses-alternate-main-cancellation.test.ts": "responses",
"codex-shim-path-readiness.test.ts": "codex-integration",
"codex-shim-fnm.test.ts": "codex-integration",
"claude-intercept-cleanup.test.ts": "claude-integration",
Expand Down
21 changes: 21 additions & 0 deletions src/codex/account-runtime-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ const reauthAccounts = new Map<string, number | undefined>();
let lastReconciledGeneration = 0;
let liveAccountIds = new Set<string>();

// Refusal evidence is independent of the ordinary quarantine flag. Profile switches make a key
// inapplicable, not forgotten; the oldest of at most 64 records is evicted to bound process state.
const refusedMainRefreshGrants = new Set<string>();
const MAX_REFUSED_MAIN_REFRESH_GRANTS = 64;

export function markMainRefreshGrantRejected(key: string): void {
refusedMainRefreshGrants.add(key);
if (refusedMainRefreshGrants.size > MAX_REFUSED_MAIN_REFRESH_GRANTS) {
refusedMainRefreshGrants.delete(refusedMainRefreshGrants.values().next().value!);
}
}

export function isMainRefreshGrantRejected(key: string): boolean {
return refusedMainRefreshGrants.has(key);
}

export function clearMainRefreshGrantRejection(key: string): void {
refusedMainRefreshGrants.delete(key);
}


export function markAccountNeedsReauth(
id: string,
writerGeneration = captureConfigGeneration(),
Expand Down
58 changes: 11 additions & 47 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure, type ChatgptRefreshDiagnosticCode } from "./chatgpt-refresh-failure";
import { readBoundedResponseBody } from "../lib/bounded-body";
export { classifyChatgptRefreshFailure } from "./chatgpt-refresh-failure";
import { readOrcaAuthSource } from "./orca-auth-source";
import { createHash } from "node:crypto";
import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
Expand Down Expand Up @@ -526,7 +529,8 @@ const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";

export class TokenRefreshError extends Error {
reason: "expired" | "revoked" | "unknown";
constructor(reason: "expired" | "revoked" | "unknown", message: string) {
constructor(reason: "expired" | "revoked" | "unknown", message: string,
readonly status?: number, readonly code?: ChatgptRefreshDiagnosticCode) {
super(message);
this.name = "TokenRefreshError";
this.reason = reason;
Expand Down Expand Up @@ -1322,52 +1326,12 @@ async function resolveCodexToken(
signal,
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
let errDesc: string;
let errCodeExact: string | undefined;
try {
const parsed = JSON.parse(errText) as {
error?: string | { code?: string; message?: string };
error_description?: string;
};
if (typeof parsed.error === "string") {
errCodeExact = parsed.error.trim();
errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ");
} else if (parsed.error && typeof parsed.error === "object") {
errCodeExact = typeof parsed.error.code === "string" ? parsed.error.code.trim() : undefined;
errDesc = [parsed.error.code, parsed.error.message, parsed.error_description].filter(Boolean).join(": ");
} else {
errDesc = parsed.error_description || `HTTP ${res.status}`;
}
if (!errDesc) errDesc = `HTTP ${res.status}`;
} catch { errDesc = `HTTP ${res.status}`; }
// `invalid_grant` is the standard OAuth code for a refresh token that is no longer
// usable, and upstream sends it bare with no description. Without it here the dead
// grant is classified "unknown", which callers treat as transient — so the account
// is never retired and every request repeats the same doomed refresh (#2887).
//
// Matched on the exact `error` CODE, not anywhere in the combined text: a transient
// `server_error` whose description happens to mention invalid_grant would otherwise
// retire a healthy account, which is the failure this whole change exists to remove.
//
// That rule binds the DESCRIPTION words too. "invalidated", "revoked" and "expired" read
// as terminal prose, but upstream puts arbitrary text there: a `server_error` whose
// description says "token was revoked" or "session expired" is still a 5xx blip, and
// retiring the account on it is exactly the false quarantine #2887 exists to prevent.
// So a body that carries a structured code is classified by that code ALONE. The
// substring fallback survives only where there is no structured code to read at all --
// a description-only body, or one this parser could not decode -- because there the
// prose is the only signal upstream gave us.
const structuredCode = errCodeExact ? errCodeExact : undefined;
const proseIsOnlySignal = structuredCode === undefined;
const reason = structuredCode === "invalid_grant"
|| structuredCode === "refresh_token_invalidated"
|| (proseIsOnlySignal
&& (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const
: structuredCode === "refresh_token_expired"
|| (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const
: "unknown" as const;
throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
const body = await readBoundedResponseBody(res, { signal, maxBytes: 16_384, fatalUtf8: true })
.catch(() => undefined);
const failure = classifyChatgptRefreshFailure(res.status, body?.displaySafe ? body.text : "");
const { reason } = failure;
noteChatgptRefreshFailure("pool", res.status, failure);
throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`, res.status, failure.code);
}
const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
// Guard against a missing/non-finite/negative expires_in (malformed upstream
Expand Down
19 changes: 11 additions & 8 deletions src/codex/account-usability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { readCodexAccountRecord } from "./account-store";
import { isAccountNeedsReauth } from "./account-runtime-state";
import {
MAIN_CODEX_ACCOUNT_ID,
hasMainAccountRefreshGrant,
isMainAccountCredentialUsable,
getMainAccountCredentialStatus,
isMainAccountTokenLive,
} from "./main-account";
import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id";
Expand All @@ -14,7 +13,7 @@ import { isMainAccountHardLocked } from "./main-account-hard-lock";
export interface CodexAccountUsabilityOptions {
/** Route using cached runtime state only; the caller must reject selected main before auth. */
nativeMainSelectionOnly?: boolean;
/** Test seam for proving whether routing attempted a physical native-token read. */
/** Liveness override; ownership/read fences independently control physical credential reads. */
isMainAccountTokenLive?: typeof isMainAccountTokenLive;
/** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
modelEligibleAccountIds?: ReadonlySet<string>;
Expand Down Expand Up @@ -88,15 +87,19 @@ export function codexAccountUnusableReason(
// A legacy pool row with the sentinel makes an active `__main__` ambiguous.
// Fail closed until the authenticated compatibility-delete path removes it.
if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return "legacy_pool_sentinel";
if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return "needs_reauth";
// A selection-only caller owns the recovery/drain fence and will reject main
// before reservation or token materialization. Treat cached main as a routing
// candidate without touching the credential file so affinity is not rebound.
// These scopes cannot consume physical stored-main state, even indirectly through an
// ordinary quarantine plus hasMainAccountRefreshGrant(). Caller liveness is request-owned.
if (options.nativeMainSelectionOnly) return undefined;
if (options.requestOwnedMainCredential) {
return options.isMainAccountTokenLive?.() === false ? "main_credential_unavailable" : undefined;
}
const credential = getMainAccountCredentialStatus();
if (credential.refreshGrantRejected) return "needs_reauth";
if (isAccountNeedsReauth(accountId) && !credential.hasRefreshGrant) return "needs_reauth";
// Main account: a refresh grant is enough to route; materialization refreshes before I/O.
const mainLive = options.isMainAccountTokenLive
? options.isMainAccountTokenLive()
: isMainAccountCredentialUsable();
: credential.usable;
return mainLive ? undefined : "main_credential_unavailable";
}
const exists = (config.codexAccounts ?? [])
Expand Down
136 changes: 72 additions & 64 deletions src/codex/auth-api/account-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getCodexAccountAutoSwitchThresholdOverride } from "../account-auto-swit
import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
import { getValidMainAccountToken, isMainAccountRefreshGrantRejected, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
import { captureConfigGeneration } from "../../lib/state-store-sweeper";
import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, getObservedMainQuotaIdentityKey, isMainAccountIdentityGenerationLive } from "../main-account-cache";
import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
Expand Down Expand Up @@ -342,62 +342,76 @@ export async function listCodexAuthAccountsSnapshot(
maskEmails,
)];
});
const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
// An ordinary same-account return can be parsed after its credential was replaced.
// The card and hard-lock status must describe the same published quota snapshot.
const mainInfo = mainSnapshotLive
? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info
: EMPTY_MAIN_ACCOUNT_INFO;
const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
? mainResult.hasCredential
: getMainAccountCredentialPresence() ?? false;
const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
const liveQuotaRefresh = mainSnapshotLive && mainResult.quotaRefreshGeneration !== undefined
&& isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
? mainResult.quotaRefresh : undefined;
const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
const mainHealth = projectCodexAccountHealth({
accountId: MAIN_CODEX_ACCOUNT_ID,
needsReauth: mainNeedsReauth,
});
// The main row carries the same attribution as a pool row. Reaching this point without
// A diagnostic status is not causal evidence: a live bare 401 can be transient while an
// earlier refresh failure still owns the quarantine. Only a current terminal probe attributes it.
const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
? "missing_credential"
: mainNeedsReauth
? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error"
? "unauthorized" : "refresh_failed"
: mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
const main: CodexAuthAccountDto = {
id: MAIN_CODEX_ACCOUNT_ID,
email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
plan: mainInfo.plan,
...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}),
logLabel: "main",
isMain: true,
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
hasCredential: hasMainCredential,
needsReauth: mainNeedsReauth,
...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
quota: mainInfo.quota
? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
: null,
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
};
return {
accounts: [main, ...withQuota],
mainIdentityGeneration: mainSnapshotLive
? fetchedMainGeneration
: captureMainAccountIdentityGeneration(),
const projectSnapshot = (mainRefreshRefused = false): CodexAuthAccountsSnapshot => {
const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
// An ordinary same-account return can be parsed after its credential was replaced.
// The card and hard-lock status must describe the same published quota snapshot.
const mainInfo = mainSnapshotLive
? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info
: EMPTY_MAIN_ACCOUNT_INFO;
const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
? mainResult.hasCredential
: getMainAccountCredentialPresence() ?? false;
const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
const liveQuotaRefresh = mainSnapshotLive && mainResult.quotaRefreshGeneration !== undefined
&& isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
? mainResult.quotaRefresh : undefined;
const mainNeedsReauth = mainMissingCredential || mainRefreshRefused || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
const mainHealth = projectCodexAccountHealth({
accountId: MAIN_CODEX_ACCOUNT_ID,
needsReauth: mainNeedsReauth,
});
// The main row carries the same attribution as a pool row. Reaching this point without
// Only a current terminal usage probe can attribute the reauth cause. A transient status
// never replaces a refresh failure, including a separately recorded refused grant.
const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
? "missing_credential"
: mainNeedsReauth
? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error"
? "unauthorized" : "refresh_failed"
: mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
const main: CodexAuthAccountDto = {
id: MAIN_CODEX_ACCOUNT_ID,
email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
plan: mainInfo.plan,
...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}),
logLabel: "main",
isMain: true,
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
hasCredential: hasMainCredential,
needsReauth: mainNeedsReauth,
...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
quota: mainInfo.quota
? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
: null,
...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
};
return {
accounts: [main, ...withQuota],
mainIdentityGeneration: mainSnapshotLive
? fetchedMainGeneration
: captureMainAccountIdentityGeneration(),
};
};
// Project the refusal and DTO in the same owned synchronous observation. A same-account grant
// replacement during earlier pool probes must not inherit the previous grant's refusal.
const projectionLease = tryAcquireNativeMainProfileClaim();
if (!projectionLease) return projectSnapshot();
try {
return await withNativeMainCredentialClaim(async () => projectSnapshot(isMainAccountRefreshGrantRejected()));
} catch (error) {
if (isNativeMainClaimUnavailable(error)) return projectSnapshot();
throw error;
} finally {
projectionLease.release();
}
}

/** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */
Expand All @@ -408,16 +422,10 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
try {
reconcileMainCodexAccountRuntimeState();
if (isAccountNeedsReauth(accountId)) return;
const identityGeneration = captureMainAccountIdentityGeneration();
const writerGeneration = captureConfigGeneration();
try {
// Refresh may need an exclusive claim; prepare before WHAM takes its shared claim.
// Refresh owns credential-scoped refusal evidence; do not recreate a global quarantine.
if (!await getValidMainAccountToken({ preserveReauth: true })) return;
} catch (error) {
if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
&& isMainAccountIdentityGenerationLive(identityGeneration)) {
markAccountNeedsReauth(accountId, writerGeneration);
}
} catch {
return;
}
if (isAccountNeedsReauth(accountId)) return;
Expand Down
Loading
Loading