Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 26 additions & 0 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
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 type { OcxConfig } from "../types";

let observedMainChatgptAccountId: string | null | undefined;

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

/**
* 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();
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 {
removeCodexAccountCredential(accountId);
runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId);
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
31 changes: 31 additions & 0 deletions tests/codex-main-rotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ import {
resolveCodexAuthContext,
} from "../src/codex/auth-context";
import { isCodexAccountUsable } from "../src/codex/account-usability";
import { resetMainCodexAccountIdentityTrackingForTests } from "../src/codex/account-lifecycle";
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../src/codex/main-account";
import { saveCodexAccountCredential } from "../src/codex/account-store";
import {
clearAccountNeedsReauth,
clearAccountQuota,
getAccountQuota,
isAccountNeedsReauth,
markAccountNeedsReauth,
updateAccountQuota,
} from "../src/codex/auth-api";
Expand Down Expand Up @@ -74,6 +77,7 @@ describe("main account rotation (Option A)", () => {
clearThreadAccountMap();
clearCodexUpstreamHealth();
clearAccountQuota();
resetMainCodexAccountIdentityTrackingForTests();
setMainAccountPlan(null);
for (const id of ["a", "b", MAIN_CODEX_ACCOUNT_ID]) clearAccountNeedsReauth(id);
saveCred("a");
Expand All @@ -85,6 +89,7 @@ describe("main account rotation (Option A)", () => {
clearThreadAccountMap();
clearCodexUpstreamHealth();
clearAccountQuota();
resetMainCodexAccountIdentityTrackingForTests();
setMainAccountPlan(null);
for (const id of ["a", "b", MAIN_CODEX_ACCOUNT_ID]) clearAccountNeedsReauth(id);
for (const d of [STORE_DIR, CODEX_DIR]) if (existsSync(d)) rmSync(d, { recursive: true });
Expand Down Expand Up @@ -147,6 +152,32 @@ describe("main account rotation (Option A)", () => {
expect(headers.get("chatgpt-account-id")).toBe("main_acct");
});

test("switching the main auth identity discards runtime state from the previous account", async () => {
const config = makeConfig({ activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, autoSwitchThreshold: 0, codexAccounts: [] });
await expect(resolveCodexAuthContext(new Headers(), config, "pool")).resolves.toMatchObject({
kind: "main-pool",
chatgptAccountId: "main_acct",
});

recordCodexUpstreamOutcome(config, MAIN_CODEX_ACCOUNT_ID, 429, { retryAfter: "3600" });
markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 100, 0);
writeFileSync(
join(CODEX_DIR, "auth.json"),
JSON.stringify({ tokens: { access_token: "replacement_access", account_id: "replacement_acct" } }),
);

await expect(resolveCodexAuthContext(new Headers(), config, "pool")).resolves.toEqual({
kind: "main-pool",
accountId: MAIN_CODEX_ACCOUNT_ID,
accessToken: "replacement_access",
chatgptAccountId: "replacement_acct",
});
expect(isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID)).toBe(false);
expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false);
expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toBeNull();
});

test("no active id selects from main plus added accounts and binds main affinity", async () => {
const config = makeConfig({ activeCodexAccountId: undefined, autoSwitchThreshold: 0 });
updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5, 0);
Expand Down
Loading