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
31 changes: 31 additions & 0 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,46 @@
import { removeCodexAccountCredential } from "./account-store";
import { clearAccountNeedsReauth } from "./account-runtime-state";
import { getMainChatgptAccountId } from "./auth-collision";
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
import { clearAccountQuota } from "./quota";
import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing";
import { invalidateCodexWebSocketsForAccount } from "./websocket-registry";
import { clearMainAccountInfoCache } from "./main-account-cache";
import type { OcxConfig } from "../types";

let observedMainChatgptAccountId: string | undefined;

export function purgeCodexAccountRuntimeState(accountId: string): void {
clearAccountNeedsReauth(accountId);
clearAccountQuota(accountId);
clearThreadAccountMapForAccount(accountId);
clearCodexUpstreamHealthForAccount(accountId);
if (accountId === MAIN_CODEX_ACCOUNT_ID) clearMainAccountInfoCache();
}

/**
* The main Codex login is stored under the stable `__main__` alias, while
* `~/.codex/auth.json` can be replaced with credentials for another physical
* ChatGPT account. Drop alias-keyed runtime state when that identity changes so
* cooldown, quota, reauth, and thread affinity do not leak across accounts.
*/
export function reconcileMainCodexAccountRuntimeState(): boolean {
const currentAccountId = getMainChatgptAccountId();
// A missing/malformed auth.json is an unknown identity, not a confirmed account switch. Keep the
// prior observation and its safety state until a real account id can be read again.
if (currentAccountId === null) return false;
const previousAccountId = observedMainChatgptAccountId;
observedMainChatgptAccountId = currentAccountId;
if (previousAccountId === undefined || previousAccountId === currentAccountId) return false;
Comment on lines +28 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid clearing main state on null auth reads

If auth.json is temporarily absent or malformed during a Codex logout/login or non-atomic rewrite, getMainChatgptAccountId() returns null; this code records that null as the observed identity and treats both A -> null and null -> A as account changes. That purges __main__ cooldown/reauth/quota state for the same physical account, so a 429-cooled main account can be retried upstream immediately after a transient file-read gap. Keep the last non-null observed account (or only purge when both previous and current identities are non-null and different) so missing credentials fail closed without resetting per-account health.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed identity tracking before caching main state

When the startup quota prime or the accounts API calls fetchMainAccountInfo() before the first pool request, it can already populate __main__ quota/plan or mark __main__ as needing reauth, but observedMainChatgptAccountId is still undefined. If the user then replaces auth.json, this first reconciliation takes the previousAccountId === undefined branch and leaves that stale state attached to the new main account, so the original cooled/reauth-marked-account bug still reproduces whenever state was created by priming or the dashboard rather than an earlier request. Record the observed main identity in the same paths that populate main runtime state, or make the first reconciliation clear existing __main__ state when any such state is present.

Useful? React with 👍 / 👎.


purgeCodexAccountRuntimeState(MAIN_CODEX_ACCOUNT_ID);
setMainAccountPlan(null);
Comment on lines +36 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the main account info cache on identity changes

This reset path clears the shared quota store and plan, but it does not invalidate mainAccountCache in fetchMainAccountInfo(). After account A's WHAM response is cached, switching auth.json to account B and hitting a non-forced /api/codex-auth/accounts or /api/provider-quotas request can return A's email, plan, and quota for up to the cache TTL, so the dashboard/CLI can show another account's details after the identity switch. Expose a cache invalidation helper and call it from this reconciliation before returning.

Useful? React with 👍 / 👎.

invalidateCodexWebSocketsForAccount(MAIN_CODEX_ACCOUNT_ID);
return true;
}

export function resetMainCodexAccountIdentityTrackingForTests(): void {
observedMainChatgptAccountId = undefined;
}

export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void {
Expand Down
63 changes: 50 additions & 13 deletions src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {
CodexCredentialRefreshLockTimeoutError,
TokenRefreshError,
} from "./account-store";
import { deleteCodexAccount } from "./account-lifecycle";
import { checkAccountIdCollision, readCodexTokens } from "./auth-collision";
import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens } from "./auth-collision";
export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
Expand All @@ -26,6 +26,13 @@ import {
export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./quota";
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
import {
clearMainAccountInfoCache,
getMainAccountInfoCache,
setMainAccountInfoCache,
type MainAccountInfo,
} from "./main-account-cache";
export { clearMainAccountInfoCache } from "./main-account-cache";
import { maskEmail } from "../lib/privacy";
import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
export { maskEmail } from "../lib/privacy";
Expand Down Expand Up @@ -179,7 +186,6 @@ function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"):
}
}

let mainAccountCache: { email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null; ts: number } | null = null;
const MAIN_CACHE_TTL = 5 * 60_000;
const POOL_CACHE_TTL = 5 * 60_000;
const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
Expand Down Expand Up @@ -245,36 +251,62 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
}
}

export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null }> {
export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
return fetchMainAccountInfoAttempt(forceRefresh, 1);
}

const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };

async function retryMainAccountInfoIfIdentityChanged(
requestAccountId: string | null,
retriesRemaining: number,
): Promise<MainAccountInfo | null> {
const currentAccountId = getMainChatgptAccountId();
if (currentAccountId === null || currentAccountId === requestAccountId) return null;
reconcileMainCodexAccountRuntimeState();
return retriesRemaining > 0
? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
: EMPTY_MAIN_ACCOUNT_INFO;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfo> {
reconcileMainCodexAccountRuntimeState();
const tokens = readCodexTokens();
if (!tokens) {
mainAccountCache = null;
clearMainAccountInfoCache();
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
return { email: null, plan: null, quota: null };
return EMPTY_MAIN_ACCOUNT_INFO;
}
if (!forceRefresh && mainAccountCache && Date.now() - mainAccountCache.ts < MAIN_CACHE_TTL) {
return mainAccountCache;
const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
const cached = getMainAccountInfoCache();
if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
return cached;
}
try {
const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
signal: AbortSignal.timeout(8000),
});
if (!resp.ok) {
if (await isTerminalMainAuthResponse(resp)) {
mainAccountCache = null;
const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
if (retried) return retried;
if (terminalAuthFailure) {
clearMainAccountInfoCache();
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
}
return { email: null, plan: null, quota: null };
return EMPTY_MAIN_ACCOUNT_INFO;
}
const data = (await resp.json()) as WhamUsageResponse;
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
if (retried) return retried;
const result = {
email: data.email ?? null,
plan: data.plan_type ?? null,
quota: parseUsageQuota(data),
ts: Date.now(),
};
mainAccountCache = result;
setMainAccountInfoCache(result);
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
// Mirror main quota + plan into the shared stores so the rotation engine can
// score and auto-switch the main account exactly like a pool account (Option A).
Expand All @@ -291,7 +323,8 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
}
return result;
} catch {
return { email: null, plan: null, quota: null };
const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
return retried ?? EMPTY_MAIN_ACCOUNT_INFO;
}
}

Expand Down Expand Up @@ -369,6 +402,10 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
|| providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
) return;
if (primeInFlight) return primeInFlight;
// Seed the observed physical main identity before startup/lazy priming can populate quota or
// plan state. Otherwise the first post-startup account switch sees no previous identity and
// skips the purge that protects the stable __main__ alias.
reconcileMainCodexAccountRuntimeState();
primeInFlight = (async () => {
const runtimeConfig = getRuntimeConfig(config);
const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
Expand Down
2 changes: 2 additions & 0 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "./account-store";
import { markAccountNeedsReauth } from "./account-runtime-state";
import { isCodexAccountUsable } from "./account-usability";
import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
import {
getCodexAccountCooldownUntil,
Expand Down Expand Up @@ -107,6 +108,7 @@ export async function resolveCodexAuthContext(
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
return { kind: "main", accountId: null };
}
reconcileMainCodexAccountRuntimeState();
const threadId = headers.get("x-codex-parent-thread-id");
const resolution = options.excludeAccountId
? (() => {
Expand Down
25 changes: 25 additions & 0 deletions src/codex/main-account-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { StoredAccountQuota } from "./quota";

export interface MainAccountInfo {
email: string | null;
plan: string | null;
quota: Omit<StoredAccountQuota, "updatedAt"> | null;
}

export interface CachedMainAccountInfo extends MainAccountInfo {
ts: number;
}

let cachedMainAccountInfo: CachedMainAccountInfo | null = null;

export function getMainAccountInfoCache(): CachedMainAccountInfo | null {
return cachedMainAccountInfo;
}

export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
cachedMainAccountInfo = value;
}

export function clearMainAccountInfoCache(): void {
cachedMainAccountInfo = null;
}
Loading
Loading