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
29 changes: 29 additions & 0 deletions src/codex/account-runtime-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ import { isCodexAccountGenerationLive } from "./account-store";
const reauthAccounts = new Map<string, number | undefined>();
let lastReconciledGeneration = 0;
let liveAccountIds = new Set<string>();
/**
* Fingerprint of the native-main refresh grant the token endpoint itself refused.
*
* A main quarantine is deliberately overridable by the presence of a refresh grant
* ({@link import("./main-account").hasMainAccountRefreshGrant}), because a bare WHAM 401 can
* quarantine a credential the next refresh would have fixed. A grant the token endpoint answered
* with `invalid_grant` can fix nothing, so it must stop vouching for the account — otherwise a
* revoked session is rediscovered by an upstream round trip on every single request.
*
* A fingerprint, never a token, and never the account id: a replacement credential written by
* login, reauth, or another process carries a different grant and makes this verdict inert.
*/
let deadMainRefreshGrant: string | undefined;

/** `undefined` retracts the verdict: a refresh that succeeded proved the grant is alive. */
export function setMainRefreshGrantDead(fingerprint: string | undefined): void {
deadMainRefreshGrant = fingerprint;
}

export function isMainRefreshGrantDead(fingerprint: string): boolean {
return deadMainRefreshGrant === fingerprint;
}

export function markAccountNeedsReauth(
id: string,
Expand Down Expand Up @@ -67,4 +89,11 @@ export function clearAccountNeedsReauth(id: string, credentialGeneration?: numbe
&& (reauthAccounts.get(id) !== credentialGeneration
|| !isCodexAccountGenerationLive(id, credentialGeneration))) return;
reauthAccounts.delete(id);
// The dead-grant verdict deliberately SURVIVES this. It is a fact about one refresh token, not
// about the quarantine that accompanied it, and it already self-invalidates by fingerprint when
// the credential is replaced. Retracting it here let the WHAM probe's explicit-refresh clear
// (#327) -- which `ocx account list` and an open dashboard both trigger -- hand a grant upstream
// had already refused back to routing, and the probe's own re-mark could not stop it: with a
// grant apparently alive, `hasMainAccountRefreshGrant` cancels the quarantine. Every look at the
// dashboard bought another upstream 401.
}
109 changes: 65 additions & 44 deletions src/codex/account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,70 @@ export function tombstoneCodexAccount(id: string): number {
const CHATGPT_TOKEN_URL = "https://auth.openai.com/oauth/token";
const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";

/**
* What a failed ChatGPT token-endpoint refresh says about the refresh grant itself.
*
* `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). Production only ever confirmed that bare
* code, so the siblings upstream also emits for a revoked or downgraded session are listed
* beside it rather than discovered one incident at a time.
*
* 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 rule 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.
*
* Shared by the stored-pool refresh and the native-main refresh. Native main classified the same
* failures by searching its own formatted message, which both missed `refresh_token_reused` and
* read a 5xx's terminal-sounding prose as proof -- so one dead grant could be terminal for a
* pool account and transient for `__main__`, and a live one could be retired.
*/
export function classifyChatgptRefreshFailure(
status: number,
body: string,
): { reason: "expired" | "revoked" | "unknown"; description: string; code?: string } {
let errDesc: string;
let errCodeExact: string | undefined;
try {
const parsed = JSON.parse(body) 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 = typeof parsed.error_description === "string" && parsed.error_description
? parsed.error_description : `HTTP ${status}`;
}
if (!errDesc) errDesc = `HTTP ${status}`;
} catch { errDesc = `HTTP ${status}`; }
const structuredCode = errCodeExact ? errCodeExact : undefined;
const proseIsOnlySignal = structuredCode === undefined;
const reason = structuredCode === "invalid_grant"
|| structuredCode === "refresh_token_invalidated"
|| structuredCode === "token_invalidated"
|| structuredCode === "refresh_token_reused"
|| (proseIsOnlySignal
&& (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const
: structuredCode === "refresh_token_expired"
|| (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const
: "unknown" as const;
return { reason, description: errDesc, ...(structuredCode ? { code: structuredCode } : {}) };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export class TokenRefreshError extends Error {
reason: "expired" | "revoked" | "unknown";
constructor(reason: "expired" | "revoked" | "unknown", message: string) {
Expand Down Expand Up @@ -1323,50 +1387,7 @@ async function resolveCodexToken(
});
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;
const { reason } = classifyChatgptRefreshFailure(res.status, errText);
throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
}
const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
Expand Down
113 changes: 104 additions & 9 deletions src/codex/main-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,29 @@ import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { readCodexTokens } from "./auth-collision";
import {
CHATGPT_CLIENT_ID,
CHATGPT_TOKEN_URL,
credsFromToken,
decodeJwtPayload,
extractAccountId,
refreshChatGPTToken,
} from "../oauth/chatgpt";
import type { OAuthCredentials } from "../oauth/types";
import { extractChatgptPlanType } from "./plan";
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
import {
classifyChatgptRefreshFailure,
refreshGrantFingerprintForToken,
withCodexRefreshFileLock,
} from "./account-store";
import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write";
import { resolveCodexHomeDir } from "./home";
import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
import { clearAccountNeedsReauth } from "./account-runtime-state";
import {
clearAccountNeedsReauth,
isMainRefreshGrantDead,
markAccountNeedsReauth,
setMainRefreshGrantDead,
} from "./account-runtime-state";
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
import { withNativeMainExclusiveClaim } from "./native-main-claim";
import { resolveNativeProfileContext } from "./native-profile-store";
Expand Down Expand Up @@ -78,6 +86,61 @@ export class MainAccountTokenRefreshError extends Error {
}
}

/**
* The native-main refresh, posted here rather than through `refreshChatGPTToken` so the token
* endpoint's structured `error` code survives to {@link classifyChatgptRefreshFailure}.
*
* `refreshChatGPTToken` collapses the refusal into one display string, and classifying a grant
* from that string is what let `refresh_token_reused` pass as transient -- the grant never
* retired, every request re-earning its own 401 -- while a 5xx whose description merely read
* "session expired" passed as proof and would have retired a live grant.
*
* Same endpoint, client id and grant type as every other ChatGPT refresh, and the success path
* is `credsFromToken` itself, so only the failure branch differs: it classifies by code, exactly
* as the stored-pool refresh does.
*/
async function refreshNativeMainGrant(
refreshToken: string,
options: { signal: AbortSignal },
): Promise<OAuthCredentials> {
const resp = await fetch(CHATGPT_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: CHATGPT_CLIENT_ID,
refresh_token: refreshToken,
}).toString(),
signal: options.signal,
});
if (!resp.ok) {
const body = await resp.text().catch(() => "");
const { reason, description, code } = classifyChatgptRefreshFailure(resp.status, body);
const verdict = reason === "unknown" ? "transient" as const : "reauth" as const;
noteNativeMainRefresh(verdict, resp.status, code);
throw new MainAccountTokenRefreshError(verdict, {
cause: new Error(`ChatGPT refresh failed: ${resp.status} ${description}`),
});
}
noteNativeMainRefresh("ok", resp.status);
return credsFromToken((await resp.json()) as Record<string, unknown>);
}

/**
* One line per native-main refresh verdict, so a dead grant is diagnosable from service.log.
*
* A revoked session is invisible from the outside: the grant is refused, nothing is written, and
* the request fails with a status that looks like every other 401. Carries the token endpoint's
* HTTP status and its structured `error` code and nothing else -- never a token, never a body.
*/
function noteNativeMainRefresh(outcome: string, status?: number, code?: string): void {
console.warn(
`[codex] native main refresh: ${outcome}`
+ (status === undefined ? "" : ` status=${status}`)
+ ` code=${code && /^[A-Za-z0-9._-]{1,64}$/.test(code) ? code : "none"}`,
);
}

function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
Expand Down Expand Up @@ -153,7 +216,10 @@ export function isMainAccountCredentialUsable(now = Date.now()): boolean {
}

export function hasMainAccountRefreshGrant(): boolean {
return !!readMainAuthJsonCredential()?.refreshToken;
const refreshToken = readMainAuthJsonCredential()?.refreshToken;
// A grant the token endpoint has already refused cannot revive the account, so it must not
// override the quarantine that refusal produced.
return !!refreshToken && !isMainRefreshGrantDead(refreshGrantFingerprintForToken(refreshToken));
}

function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential): void {
Expand Down Expand Up @@ -343,13 +409,23 @@ async function resolveMainAccountToken(
rejectedAccessToken?: string,
): Promise<{ accessToken: string; chatgptAccountId: string } | null> {
const initial = readMainAuthJsonCredential();
if (!initial) return null;
// Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from
// the outside is indistinguishable from a refusal: same failed request, no write, no verdict.
// A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports.
if (!initial) {
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential");
return null;
}
const now = Date.now();
if (initial.accessToken !== rejectedAccessToken
&& mainAccessTokenFresh(initial.accessToken, now, MAIN_TOKEN_REFRESH_SKEW_MS)) {
// The stored credential is no longer the one upstream rejected: somebody else replaced it
// while this request was in flight, so there is nothing here to refresh or to retire.
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-credential-replaced");
return { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId };
}
if (!initial.refreshToken) {
if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-refresh-grant");
return initial.accessToken !== rejectedAccessToken
&& mainAccessTokenFresh(initial.accessToken, now, 0)
? { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId }
Expand Down Expand Up @@ -392,16 +468,30 @@ async function resolveMainAccountToken(
&& mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
}
const refresh = dependencies.refreshToken
?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
let refreshed: OAuthCredentials;
try {
refreshed = await refresh(locked.refreshToken, { signal });
} catch (cause) {
// A refusal already classified at the endpoint keeps that verdict. An injected refresh
// threw an ordinary Error and has only its message, so prose still decides there --
// and only there, where there is no structured code to read.
const message = cause instanceof Error ? cause.message.toLowerCase() : "";
const reason = /invalid_grant|invalidated|revoked|expired/.test(message)
? "reauth" as const
: "transient" as const;
const reason = cause instanceof MainAccountTokenRefreshError
? cause.reason
: /invalid_grant|invalidated|revoked|expired/.test(message)
? "reauth" as const
: "transient" as const;
// The shared point every request path reaches a terminal native-main verdict through:
// the forced refresh after an upstream 401, the pre-send refresh of an expired bearer,
// and the compact twin all land here. Recording the quarantine at the callers instead
// left the native main slot alone unrecorded -- `refreshNativeMainForwardAuth` has no
// `quarantine` flag for its caller to act on the way the stored-pool twin does -- so a
// revoked session stayed `needsReauth: false` and every request re-earned its own 401.
if (reason === "reauth") {
setMainRefreshGrantDead(lockKey);
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
}
throw new MainAccountTokenRefreshError(reason, { cause });
}
// The refresh may resolve after the caller went away (an implementation that does
Expand All @@ -411,6 +501,11 @@ async function resolveMainAccountToken(
// before its own commit above.
if (dependencies.signal?.aborted) throw dependencies.signal.reason;
const result = persistRefreshedMainAuthJson(locked, refreshed);
// Retracted even under `preserveReauth`: that option preserves a quarantine whose cause
// this refresh did not speak to, while a 200 from the token endpoint is direct proof
// about the grant itself. Unconditional because a rotation-less success keeps the same
// refresh token, so the fingerprint alone would not clear the verdict.
setMainRefreshGrantDead(undefined);
if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
return result;
}),
Expand Down
Loading
Loading