From 67e41fc21b66f134545f85d1b22ad1ac142b6f4e Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 11:58:52 +0800 Subject: [PATCH 01/11] test: broaden multi-workspace issue 121 coverage --- test/codex-manager-cli.test.ts | 107 +++++++++++++++++++++++++++++++++ test/index.test.ts | 70 +++++++++++++++++++++ test/storage.test.ts | 30 +++++++++ 3 files changed, 207 insertions(+) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index bf9fbef6..e4481269 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2384,6 +2384,113 @@ describe("codex manager cli commands", () => { expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); }); + it("preserves distinct same-email workspaces when oauth login reuses a refresh token", async () => { + const now = Date.now(); + let storageState = { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "user@example.com", + accountId: "workspace-alpha", + accountIdSource: "org", + accountLabel: "Workspace Alpha [id:alpha]", + refreshToken: "shared-refresh", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); + saveAccountsMock.mockImplementation(async (nextStorage) => { + storageState = structuredClone(nextStorage); + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "add" }) + .mockResolvedValueOnce({ mode: "cancel" }); + promptAddAnotherAccountMock.mockResolvedValue(false); + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValue({ + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-beta", + refresh: "shared-refresh", + expires: now + 7_200_000, + idToken: "id-token-beta", + multiAccount: true, + }); + const browserModule = await import("../lib/auth/browser.js"); + vi.mocked(browserModule.openBrowserUrl).mockReturnValue(true); + const serverModule = await import("../lib/auth/server.js"); + vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValue({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + () => "user@example.com", + ); + vi.mocked(accountsModule.getAccountIdCandidates).mockReturnValueOnce([ + { + accountId: "workspace-beta", + source: "org", + label: "Workspace Beta [id:beta]", + }, + ]); + vi.mocked(accountsModule.selectBestAccountCandidate).mockImplementationOnce( + (candidates) => candidates[0] ?? null, + ); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(storageState.accounts).toHaveLength(2); + expect( + storageState.accounts.map((account) => ({ + accountId: account.accountId, + accountIdSource: account.accountIdSource, + accountLabel: account.accountLabel, + email: account.email, + refreshToken: account.refreshToken, + })), + ).toEqual([ + { + accountId: "workspace-alpha", + accountIdSource: "org", + accountLabel: "Workspace Alpha [id:alpha]", + email: "user@example.com", + refreshToken: "shared-refresh", + }, + { + accountId: "workspace-beta", + accountIdSource: "org", + accountLabel: "Workspace Beta [id:beta]", + email: "user@example.com", + refreshToken: "shared-refresh", + }, + ]); + expect(storageState.activeIndex).toBe(1); + expect(storageState.activeIndexByFamily.codex).toBe(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + + vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + () => undefined, + ); + }); + it("updates a unique shared-accountId login when the email claim is missing", async () => { const now = Date.now(); let storageState: { diff --git a/test/index.test.ts b/test/index.test.ts index e40d0d50..7810c942 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2009,6 +2009,76 @@ describe("OpenAIOAuthPlugin persistAccountPool", () => { ]); }); + it("preserves same-email workspaces when manual login reuses a refresh token", async () => { + process.env.CODEX_AUTH_ACCOUNT_ID = "workspace-beta"; + mockStorage.accounts = [ + { + accountId: "workspace-alpha", + accountIdSource: "org", + accountLabel: "Workspace Alpha [id:alpha]", + email: "user@example.com", + refreshToken: "shared-refresh", + addedAt: Date.now() - 200000, + lastUsed: Date.now() - 200000, + }, + ]; + + const authModule = await import("../lib/auth/auth.js"); + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ + pkce: { verifier: "persist-verifier-same-refresh", challenge: "persist-challenge-same-refresh" }, + state: "persist-state-same-refresh", + url: "https://auth.openai.com/test?state=persist-state-same-refresh", + }); + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ + type: "success", + access: "access-same-refresh", + refresh: "shared-refresh", + expires: Date.now() + 3600_000, + idToken: "id-token-same-refresh", + }); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const manualMethod = plugin.auth.methods[1] as unknown as { + authorize: () => Promise<{ + callback: (input: string) => Promise<{ type: string }>; + }>; + }; + + const flow = await manualMethod.authorize(); + const result = await flow.callback( + "http://127.0.0.1:1455/auth/callback?code=abc123&state=persist-state-same-refresh", + ); + + expect(result.type).toBe("success"); + expect(mockStorage.accounts).toHaveLength(2); + expect( + mockStorage.accounts.map((account) => ({ + accountId: account.accountId, + accountIdSource: account.accountIdSource, + accountLabel: account.accountLabel, + email: account.email, + refreshToken: account.refreshToken, + })), + ).toEqual([ + { + accountId: "workspace-alpha", + accountIdSource: "org", + accountLabel: "Workspace Alpha [id:alpha]", + email: "user@example.com", + refreshToken: "shared-refresh", + }, + { + accountId: "workspace-beta", + accountIdSource: "manual", + accountLabel: expect.stringContaining("Override [id:"), + email: "user@example.com", + refreshToken: "shared-refresh", + }, + ]); + }); + it("preserves duplicate shared accountId entries when a login has no email claim", async () => { process.env.CODEX_AUTH_ACCOUNT_ID = "shared-workspace"; mockStorage.accounts = [ diff --git a/test/storage.test.ts b/test/storage.test.ts index 14c13ecd..ccca65c0 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -226,6 +226,36 @@ describe("storage", () => { expect(deduplicateAccounts(accounts)).toHaveLength(2); }); + it("does not match a shared refresh token when same-email workspaces have different accountIds", () => { + const accounts = [ + { + accountId: "workspace-alpha", + email: "shared@example.com", + refreshToken: "shared-refresh", + lastUsed: 100, + }, + ]; + + const matchIndex = findMatchingAccountIndex(accounts, { + accountId: "workspace-beta", + email: "shared@example.com", + refreshToken: "shared-refresh", + }); + + expect(matchIndex).toBeUndefined(); + expect( + deduplicateAccounts([ + ...accounts, + { + accountId: "workspace-beta", + email: "shared@example.com", + refreshToken: "shared-refresh", + lastUsed: 200, + }, + ]), + ).toHaveLength(2); + }); + it("prefers composite accountId plus email matches over safe-email fallbacks", () => { const accounts = [ { From 85e07be3dc17474443dcf763f5757eed41cd91d3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 12:12:20 +0800 Subject: [PATCH 02/11] fix(quota): handle multi-workspace cache fallbacks --- lib/codex-manager.ts | 160 ++++++++++++++-- test/codex-manager-cli.test.ts | 340 +++++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+), 21 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 486afea7..e2da3658 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -440,6 +440,55 @@ function hasUniqueQuotaAccountId( return matchCount === 1; } +type QuotaEmailFallbackState = { + matchingCount: number; + distinctAccountIds: Set; +}; + +function buildQuotaEmailFallbackState( + accounts: readonly Pick[], +): ReadonlyMap { + const stateByEmail = new Map(); + for (const account of accounts) { + const email = normalizeQuotaEmail(account.email); + if (!email) continue; + const existing = stateByEmail.get(email); + if (existing) { + existing.matchingCount += 1; + const accountId = normalizeQuotaAccountId(account.accountId); + if (accountId) { + existing.distinctAccountIds.add(accountId); + } + continue; + } + const distinctAccountIds = new Set(); + const accountId = normalizeQuotaAccountId(account.accountId); + if (accountId) { + distinctAccountIds.add(accountId); + } + stateByEmail.set(email, { + matchingCount: 1, + distinctAccountIds, + }); + } + return stateByEmail; +} + +function hasSafeQuotaEmailFallback( + emailFallbackState: ReadonlyMap, + account: Pick, +): boolean { + const email = normalizeQuotaEmail(account.email); + if (!email) return false; + const state = emailFallbackState.get(email); + if (!state) return false; + if (state.distinctAccountIds.size > 1) return false; + if (state.distinctAccountIds.size === 0) { + return state.matchingCount === 1; + } + return true; +} + function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { return { status: entry.status, @@ -514,12 +563,9 @@ function formatAccountQuotaSummary(entry: QuotaCacheEntry): string { function getQuotaCacheEntryForAccount( cache: QuotaCacheData, account: Pick, - accounts: readonly Pick[], + accounts: readonly Pick[], + emailFallbackState = buildQuotaEmailFallbackState(accounts), ): QuotaCacheEntry | null { - const email = normalizeQuotaEmail(account.email); - if (email && cache.byEmail[email]) { - return cache.byEmail[email] ?? null; - } const accountId = normalizeQuotaAccountId(account.accountId); if ( accountId && @@ -528,6 +574,14 @@ function getQuotaCacheEntryForAccount( ) { return cache.byAccountId[accountId] ?? null; } + const email = normalizeQuotaEmail(account.email); + if ( + email && + hasSafeQuotaEmailFallback(emailFallbackState, account) && + cache.byEmail[email] + ) { + return cache.byEmail[email] ?? null; + } return null; } @@ -535,7 +589,8 @@ function updateQuotaCacheForAccount( cache: QuotaCacheData, account: Pick, snapshot: CodexQuotaSnapshot, - accounts: readonly Pick[], + accounts: readonly Pick[], + emailFallbackState = buildQuotaEmailFallbackState(accounts), ): boolean { const nextEntry: QuotaCacheEntry = { updatedAt: Date.now(), @@ -555,20 +610,29 @@ function updateQuotaCacheForAccount( }; let changed = false; - const email = normalizeQuotaEmail(account.email); - if (email) { - cache.byEmail[email] = nextEntry; - changed = true; - return changed; - } const accountId = normalizeQuotaAccountId(account.accountId); if (accountId && hasUniqueQuotaAccountId(accounts, account)) { cache.byAccountId[accountId] = nextEntry; changed = true; } + const email = normalizeQuotaEmail(account.email); + if (email && hasSafeQuotaEmailFallback(emailFallbackState, account)) { + cache.byEmail[email] = nextEntry; + changed = true; + } else if (email && cache.byEmail[email]) { + delete cache.byEmail[email]; + changed = true; + } return changed; } +function cloneQuotaCacheData(cache: QuotaCacheData): QuotaCacheData { + return { + byAccountId: { ...cache.byAccountId }, + byEmail: { ...cache.byEmail }, + }; +} + const DEFAULT_MENU_QUOTA_REFRESH_TTL_MS = 5 * 60_000; const MENU_QUOTA_REFRESH_MODEL = "gpt-5-codex"; @@ -583,12 +647,18 @@ function resolveMenuQuotaProbeInput( cache: QuotaCacheData, maxAgeMs: number, now: number, - accounts: readonly Pick[], + accounts: readonly Pick[], + emailFallbackState = buildQuotaEmailFallbackState(accounts), ): { accountId: string; accessToken: string } | null { if (account.enabled === false) return null; if (!hasUsableAccessToken(account, now)) return null; - const existing = getQuotaCacheEntryForAccount(cache, account, accounts); + const existing = getQuotaCacheEntryForAccount( + cache, + account, + accounts, + emailFallbackState, + ); if ( existing && typeof existing.updatedAt === "number" && @@ -611,6 +681,7 @@ function collectMenuQuotaRefreshTargets( cache: QuotaCacheData, maxAgeMs: number, now = Date.now(), + emailFallbackState = buildQuotaEmailFallbackState(storage.accounts), ): MenuQuotaProbeTarget[] { const targets: MenuQuotaProbeTarget[] = []; for (const account of storage.accounts) { @@ -620,6 +691,7 @@ function collectMenuQuotaRefreshTargets( maxAgeMs, now, storage.accounts, + emailFallbackState, ); if (!probeInput) continue; targets.push({ @@ -637,9 +709,19 @@ function countMenuQuotaRefreshTargets( maxAgeMs: number, now = Date.now(), ): number { + const emailFallbackState = buildQuotaEmailFallbackState(storage.accounts); let count = 0; for (const account of storage.accounts) { - if (resolveMenuQuotaProbeInput(account, cache, maxAgeMs, now, storage.accounts)) { + if ( + resolveMenuQuotaProbeInput( + account, + cache, + maxAgeMs, + now, + storage.accounts, + emailFallbackState, + ) + ) { count += 1; } } @@ -656,8 +738,16 @@ async function refreshQuotaCacheForMenu( return cache; } + const emailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + const nextCache = cloneQuotaCacheData(cache); const now = Date.now(); - const targets = collectMenuQuotaRefreshTargets(storage, cache, maxAgeMs, now); + const targets = collectMenuQuotaRefreshTargets( + storage, + nextCache, + maxAgeMs, + now, + emailFallbackState, + ); const total = targets.length; let processed = 0; onProgress?.(processed, total); @@ -673,18 +763,23 @@ async function refreshQuotaCacheForMenu( model: MENU_QUOTA_REFRESH_MODEL, }); changed = - updateQuotaCacheForAccount(cache, target.account, snapshot, storage.accounts) || - changed; + updateQuotaCacheForAccount( + nextCache, + target.account, + snapshot, + storage.accounts, + emailFallbackState, + ) || changed; } catch { // Keep existing cached values if probing fails. } } if (changed) { - await saveQuotaCache(cache); + await saveQuotaCache(nextCache); } - return cache; + return nextCache; } const ACCESS_TOKEN_FRESH_WINDOW_MS = 5 * 60 * 1000; @@ -824,9 +919,15 @@ function toExistingAccountInfo( const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); const layoutMode = resolveMenuLayoutMode(displaySettings); + const emailFallbackState = buildQuotaEmailFallbackState(storage.accounts); const baseAccounts = storage.accounts.map((account, index) => { const entry = quotaCache - ? getQuotaCacheEntryForAccount(quotaCache, account, storage.accounts) + ? getQuotaCacheEntryForAccount( + quotaCache, + account, + storage.accounts, + emailFallbackState, + ) : null; return { index, @@ -1524,6 +1625,10 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { console.log("No accounts configured."); return; } + const quotaEmailFallbackState = + liveProbe && quotaCache + ? buildQuotaEmailFallbackState(storage.accounts) + : null; let changed = false; let ok = 0; @@ -1575,6 +1680,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } healthDetail = formatQuotaSnapshotForDashboard(snapshot, display); @@ -1651,6 +1757,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } healthyMessage = formatQuotaSnapshotForDashboard(snapshot, display); @@ -2175,6 +2282,10 @@ async function runForecast(args: string[]): Promise { console.log("No accounts configured."); return 0; } + const quotaEmailFallbackState = + options.live && quotaCache + ? buildQuotaEmailFallbackState(storage.accounts) + : null; const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); @@ -2223,6 +2334,7 @@ async function runForecast(args: string[]): Promise { account, liveQuota, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } } @@ -2960,6 +3072,10 @@ async function runFix(args: string[]): Promise { console.log("No accounts configured."); return 0; } + const quotaEmailFallbackState = + options.live && quotaCache + ? buildQuotaEmailFallbackState(storage.accounts) + : null; const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); @@ -3003,6 +3119,7 @@ async function runFix(args: string[]): Promise { account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } reports.push({ @@ -3086,6 +3203,7 @@ async function runFix(args: string[]): Promise { account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } reports.push({ diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index e4481269..af968c1a 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2798,6 +2798,235 @@ describe("codex manager cli commands", () => { }); }); + it("writes multi-workspace quota cache entries by accountId when one email spans multiple workspaces", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "owner@example.com", + accountId: "workspace-alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "owner@example.com", + accountId: "workspace-beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + "workspace-alpha": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + "workspace-beta": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }, + }, + byEmail: {}, + }); + }); + + it("prunes stale email-scoped quota cache entries when same-email workspaces have no stored accountIds", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "owner@example.com", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "owner@example.com", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "owner@example.com": { + updatedAt: now - 5_000, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 99, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 99, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountId).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }, + ); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: {}, + }); + + vi.mocked(accountsModule.extractAccountId).mockImplementation( + () => "acc_test", + ); + }); + it("keeps login loop running when settings action is selected", async () => { const now = Date.now(); const storage = { @@ -3068,6 +3297,117 @@ describe("codex manager cli commands", () => { ]); }); + it("prefers accountId-scoped quota cache entries when one email spans multiple workspaces", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "owner@example.com", + accountId: "workspace-alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "owner@example.com", + accountId: "workspace-beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: false, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: false, + menuSortQuickSwitchVisibleRow: true, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: { + "workspace-alpha": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 80, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 80, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + "workspace-beta": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 0, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 0, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + byEmail: { + "owner@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 99, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 99, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + const firstCallAccounts = promptLoginModeMock.mock.calls[0]?.[0] as Array<{ + accountId?: string; + quota5hLeftPercent?: number; + }>; + expect(firstCallAccounts.map((account) => account.accountId)).toEqual([ + "workspace-beta", + "workspace-alpha", + ]); + expect( + firstCallAccounts.map((account) => account.quota5hLeftPercent), + ).toEqual([100, 20]); + }); + it("uses source-number quick switch mapping when visible-row quick switch is disabled", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ From eee115d296103ba9da87b957758259478b7fea4d Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 13:11:18 +0800 Subject: [PATCH 03/11] fix(quota): close review gaps for mixed fallback state --- lib/codex-manager.ts | 21 ++- test/codex-manager-cli.test.ts | 272 +++++++++++++++++++++++++++++++-- 2 files changed, 276 insertions(+), 17 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index e2da3658..557b5a45 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -483,10 +483,7 @@ function hasSafeQuotaEmailFallback( const state = emailFallbackState.get(email); if (!state) return false; if (state.distinctAccountIds.size > 1) return false; - if (state.distinctAccountIds.size === 0) { - return state.matchingCount === 1; - } - return true; + return state.matchingCount === 1; } function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { @@ -1625,7 +1622,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { console.log("No accounts configured."); return; } - const quotaEmailFallbackState = + let quotaEmailFallbackState = liveProbe && quotaCache ? buildQuotaEmailFallbackState(storage.accounts) : null; @@ -1709,6 +1706,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { if (result.type === "success") { const tokenAccountId = extractAccountId(result.access); const nextEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + let accountIdentityChanged = false; if (account.refreshToken !== result.refresh) { account.refreshToken = result.refresh; changed = true; @@ -1724,14 +1722,19 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { if (nextEmail && nextEmail !== account.email) { account.email = nextEmail; changed = true; + accountIdentityChanged = true; } if (applyTokenAccountIdentity(account, tokenAccountId)) { changed = true; + accountIdentityChanged = true; } if (account.enabled === false) { account.enabled = true; changed = true; } + if (accountIdentityChanged && liveProbe && quotaCache) { + quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + } account.lastUsed = Date.now(); if (i === activeIndex) { activeAccountRefreshed = true; @@ -3072,7 +3075,7 @@ async function runFix(args: string[]): Promise { console.log("No accounts configured."); return 0; } - const quotaEmailFallbackState = + let quotaEmailFallbackState = options.live && quotaCache ? buildQuotaEmailFallbackState(storage.accounts) : null; @@ -3163,6 +3166,7 @@ async function runFix(args: string[]): Promise { const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); const nextAccountId = extractAccountId(refreshResult.access); let accountChanged = false; + let accountIdentityChanged = false; if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; @@ -3179,14 +3183,19 @@ async function runFix(args: string[]): Promise { if (nextEmail && nextEmail !== account.email) { account.email = nextEmail; accountChanged = true; + accountIdentityChanged = true; } if (!account.accountId && nextAccountId) { account.accountId = nextAccountId; account.accountIdSource = "token"; accountChanged = true; + accountIdentityChanged = true; } if (accountChanged) changed = true; + if (accountIdentityChanged && options.live && quotaCache) { + quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + } if (options.live) { const probeAccountId = account.accountId ?? nextAccountId; if (probeAccountId) { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index af968c1a..455c8740 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2417,7 +2417,7 @@ describe("codex manager cli commands", () => { promptAddAnotherAccountMock.mockResolvedValue(false); const authModule = await import("../lib/auth/auth.js"); - vi.mocked(authModule.createAuthorizationFlow).mockResolvedValue({ + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, state: "oauth-state", url: "https://auth.openai.com/mock", @@ -2433,13 +2433,13 @@ describe("codex manager cli commands", () => { const browserModule = await import("../lib/auth/browser.js"); vi.mocked(browserModule.openBrowserUrl).mockReturnValue(true); const serverModule = await import("../lib/auth/server.js"); - vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValue({ + vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValueOnce({ ready: true, waitForCode: vi.fn(async () => ({ code: "oauth-code" })), close: vi.fn(), }); const accountsModule = await import("../lib/accounts.js"); - vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + vi.mocked(accountsModule.extractAccountEmail).mockImplementationOnce( () => "user@example.com", ); vi.mocked(accountsModule.getAccountIdCandidates).mockReturnValueOnce([ @@ -2485,10 +2485,6 @@ describe("codex manager cli commands", () => { expect(storageState.activeIndex).toBe(1); expect(storageState.activeIndexByFamily.codex).toBe(1); expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); - - vi.mocked(accountsModule.extractAccountEmail).mockImplementation( - () => undefined, - ); }); it("updates a unique shared-accountId login when the email claim is missing", async () => { @@ -2532,12 +2528,12 @@ describe("codex manager cli commands", () => { vi.mocked(accountsModule.extractAccountId).mockImplementation( () => "acc_test", ); - vi.mocked(authModule.createAuthorizationFlow).mockResolvedValue({ + vi.mocked(authModule.createAuthorizationFlow).mockResolvedValueOnce({ pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, state: "oauth-state", url: "https://auth.openai.com/mock", }); - vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValue({ + vi.mocked(authModule.exchangeAuthorizationCode).mockResolvedValueOnce({ type: "success", access: "access-new", refresh: "refresh-new", @@ -2546,7 +2542,7 @@ describe("codex manager cli commands", () => { multiAccount: true, }); vi.mocked(browserModule.openBrowserUrl).mockReturnValue(true); - vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValue({ + vi.mocked(serverModule.startLocalOAuthServer).mockResolvedValueOnce({ ready: true, waitForCode: vi.fn(async () => ({ code: "oauth-code" })), close: vi.fn(), @@ -3021,10 +3017,133 @@ describe("codex manager cli commands", () => { byAccountId: {}, byEmail: {}, }); + }); + it("does not reuse email-scoped quota cache entries for mixed same-email accountId rows", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "owner@example.com", + accountId: "workspace-alpha", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + { + email: "owner@example.com", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "owner@example.com": { + updatedAt: now - 5_000, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 99, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 99, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + const accountsModule = await import("../lib/accounts.js"); vi.mocked(accountsModule.extractAccountId).mockImplementation( - () => "acc_test", + (accessToken?: string) => { + if (accessToken === "access-alpha") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }, ); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + "workspace-alpha": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + byEmail: {}, + }); }); it("keeps login loop running when settings action is selected", async () => { @@ -4595,6 +4714,137 @@ describe("codex manager cli commands", () => { ).toBe(true); }); + it("recomputes live quota fallback state after refresh changes a shared-workspace email", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha-stale", + expiresAt: now - 5_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + { + email: "owner@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "owner@example.com": { + updatedAt: now - 5_000, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 95, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 95, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-alpha-refreshed", + refresh: "refresh-alpha-next", + expires: now + 7_200_000, + idToken: "id-token-alpha", + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountId).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }, + ); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }, + ); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--live", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( + "owner@example.com", + ); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect( + payload.reports.filter((report) => report.outcome === "healthy"), + ).toHaveLength(2); + }); + it("deletes an account from manage mode and persists storage", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ From a8c1438522be9fdb15f576e95af1db02e6f54fd0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 13:42:44 +0800 Subject: [PATCH 04/11] fix(cli): tighten quota cache probe persistence --- lib/codex-manager.ts | 42 +++++++---- test/codex-manager-cli.test.ts | 127 ++++++++++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 23 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 557b5a45..33576a32 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -608,12 +608,18 @@ function updateQuotaCacheForAccount( let changed = false; const accountId = normalizeQuotaAccountId(account.accountId); - if (accountId && hasUniqueQuotaAccountId(accounts, account)) { + const hasUniqueAccountId = + accountId !== null && hasUniqueQuotaAccountId(accounts, account); + if (hasUniqueAccountId) { cache.byAccountId[accountId] = nextEntry; changed = true; } const email = normalizeQuotaEmail(account.email); - if (email && hasSafeQuotaEmailFallback(emailFallbackState, account)) { + if ( + email && + hasSafeQuotaEmailFallback(emailFallbackState, account) && + !hasUniqueAccountId + ) { cache.byEmail[email] = nextEntry; changed = true; } else if (email && cache.byEmail[email]) { @@ -665,6 +671,12 @@ function resolveMenuQuotaProbeInput( return null; } + const canStore = + (normalizeQuotaAccountId(account.accountId) !== null && + hasUniqueQuotaAccountId(accounts, account)) || + hasSafeQuotaEmailFallback(emailFallbackState, account); + if (!canStore) return null; + const accessToken = account.accessToken; const accountId = accessToken ? (account.accountId ?? extractAccountId(accessToken)) @@ -1615,6 +1627,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const probeModel = options.model?.trim() || "gpt-5-codex"; const display = options.display ?? DEFAULT_DASHBOARD_DISPLAY_SETTINGS; const quotaCache = liveProbe ? await loadQuotaCache() : null; + const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; setStoragePath(null); const storage = await loadAccounts(); @@ -1670,10 +1683,10 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { accessToken: currentAccessToken, model: probeModel, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, @@ -1753,10 +1766,10 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { accessToken: result.access, model: probeModel, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, @@ -1802,8 +1815,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { if (!display.showPerAccountRows) { console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); } - if (quotaCache && quotaCacheChanged) { - await saveQuotaCache(quotaCache); + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); } if (changed) { @@ -3067,6 +3080,7 @@ async function runFix(args: string[]): Promise { const options = parsedArgs.options; const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; const quotaCache = options.live ? await loadQuotaCache() : null; + const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; setStoragePath(null); @@ -3115,10 +3129,10 @@ async function runFix(args: string[]): Promise { accessToken: currentAccessToken, model: options.model, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, @@ -3205,10 +3219,10 @@ async function runFix(args: string[]): Promise { accessToken: refreshResult.access, model: options.model, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, @@ -3314,8 +3328,8 @@ async function runFix(args: string[]): Promise { } if (options.json) { - if (quotaCache && quotaCacheChanged) { - await saveQuotaCache(quotaCache); + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); } console.log( JSON.stringify( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 455c8740..8101c650 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1253,6 +1253,64 @@ describe("codex manager cli commands", () => { ).toBe(true); }); + it("does not mutate loaded quota cache when live check account save fails", async () => { + const now = Date.now(); + const originalQuotaCache = { + byAccountId: {}, + byEmail: {}, + }; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acc_live", + email: "live@example.com", + refreshToken: "refresh-live", + accessToken: "access-live", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); + saveAccountsMock.mockRejectedValueOnce(new Error("save failed")); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toThrow( + "save failed", + ); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + acc_live: { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: undefined, + windowMinutes: undefined, + resetAtMs: undefined, + }, + secondary: { + usedPercent: undefined, + windowMinutes: undefined, + resetAtMs: undefined, + }, + }, + }, + byEmail: {}, + }); + }); + it("runs fix apply mode and returns a switch recommendation", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -2599,7 +2657,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); expect(queuedRefreshMock).toHaveBeenCalledTimes(1); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(3); expect( logSpy.mock.calls.some((call) => String(call[0]).includes("full refresh test"), @@ -2634,7 +2692,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(3); expect(queuedRefreshMock).not.toHaveBeenCalled(); }); @@ -2677,6 +2735,18 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + acc_a: { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }, + }, + byEmail: {}, + }); }); it("writes shared workspace quota cache entries by email without reusing bare accountId keys", async () => { @@ -3011,12 +3081,8 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: {}, - byEmail: {}, - }); + expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).not.toHaveBeenCalled(); }); it("does not reuse email-scoped quota cache entries for mixed same-email accountId rows", async () => { @@ -3122,7 +3188,7 @@ describe("codex manager cli commands", () => { const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); expect(saveQuotaCacheMock).toHaveBeenCalledWith({ byAccountId: { @@ -4714,6 +4780,49 @@ describe("codex manager cli commands", () => { ).toBe(true); }); + it("does not mutate loaded quota cache when live fix account save fails", async () => { + const now = Date.now(); + const originalQuotaCache = { + byAccountId: {}, + byEmail: {}, + }; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "live-fix@example.com", + accountId: "acc_live_fix", + refreshToken: "refresh-live-fix", + accessToken: "access-live-fix", + expiresAt: now - 5_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-live-fix-next", + refresh: "refresh-live-fix-next", + expires: now + 7_200_000, + }); + saveAccountsMock.mockRejectedValueOnce(new Error("save failed")); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "fix", "--live", "--json"]), + ).rejects.toThrow("save failed"); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveQuotaCacheMock).not.toHaveBeenCalled(); + }); + it("recomputes live quota fallback state after refresh changes a shared-workspace email", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ From 6f24f63ea5ecf80d8dfb6d6b321f2ba7ae17ff91 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 13:54:22 +0800 Subject: [PATCH 05/11] fix(cli): isolate live forecast quota cache --- lib/codex-manager.ts | 13 +++++--- test/codex-manager-cli.test.ts | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 33576a32..d3feec3b 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -482,6 +482,8 @@ function hasSafeQuotaEmailFallback( if (!email) return false; const state = emailFallbackState.get(email); if (!state) return false; + // size > 1 only matters when multiple accounts share the same email but + // disagree on accountId; a single matching account already implies size <= 1. if (state.distinctAccountIds.size > 1) return false; return state.matchingCount === 1; } @@ -630,6 +632,8 @@ function updateQuotaCacheForAccount( } function cloneQuotaCacheData(cache: QuotaCacheData): QuotaCacheData { + // Shallow spreading is safe because quota cache entries are always replaced, + // never mutated in-place. return { byAccountId: { ...cache.byAccountId }, byEmail: { ...cache.byEmail }, @@ -2290,6 +2294,7 @@ async function runForecast(args: string[]): Promise { const options = parsedArgs.options; const display = DEFAULT_DASHBOARD_DISPLAY_SETTINGS; const quotaCache = options.live ? await loadQuotaCache() : null; + const workingQuotaCache = quotaCache ? cloneQuotaCacheData(quotaCache) : null; let quotaCacheChanged = false; setStoragePath(null); @@ -2341,12 +2346,12 @@ async function runForecast(args: string[]): Promise { model: options.model, }); liveQuotaByIndex.set(i, liveQuota); - if (quotaCache) { + if (workingQuotaCache) { const account = storage.accounts[i]; if (account) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, liveQuota, storage.accounts, @@ -2376,8 +2381,8 @@ async function runForecast(args: string[]): Promise { const recommendation = recommendForecastAccount(forecastResults); if (options.json) { - if (quotaCache && quotaCacheChanged) { - await saveQuotaCache(quotaCache); + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); } console.log( JSON.stringify( diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 8101c650..c2fea369 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -567,6 +567,64 @@ describe("codex manager cli commands", () => { expect(payload.recommendation.recommendedIndex).toBe(0); }); + it("does not mutate loaded quota cache when live forecast save fails", async () => { + const now = Date.now(); + const originalQuotaCache = { + byAccountId: {}, + byEmail: {}, + }; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "forecast@example.com", + accountId: "acc_forecast", + refreshToken: "refresh-forecast", + accessToken: "access-forecast", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); + saveQuotaCacheMock.mockRejectedValueOnce(new Error("save failed")); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "forecast", "--live", "--json"]), + ).rejects.toThrow("save failed"); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + acc_forecast: { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: undefined, + windowMinutes: undefined, + resetAtMs: undefined, + }, + secondary: { + usedPercent: undefined, + windowMinutes: undefined, + resetAtMs: undefined, + }, + }, + }, + byEmail: {}, + }); + }); + it("prints implemented 41-feature matrix", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); From c5d1b850e20b0dc009b94aae0853b990960f5b17 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 14:26:03 +0800 Subject: [PATCH 06/11] test(quota): cover windows save failures --- test/codex-manager-cli.test.ts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index c2fea369..986fcfc9 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -591,12 +591,17 @@ describe("codex manager cli commands", () => { ], }); loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); - saveQuotaCacheMock.mockRejectedValueOnce(new Error("save failed")); + saveQuotaCacheMock.mockRejectedValueOnce( + makeErrnoError("save failed", "EBUSY"), + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); await expect( runCodexMultiAuthCli(["auth", "forecast", "--live", "--json"]), - ).rejects.toThrow("save failed"); + ).rejects.toMatchObject({ + code: "EBUSY", + message: "save failed", + }); expect(originalQuotaCache).toEqual({ byAccountId: {}, byEmail: {}, @@ -1335,12 +1340,15 @@ describe("codex manager cli commands", () => { ], }); loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); - saveAccountsMock.mockRejectedValueOnce(new Error("save failed")); + saveAccountsMock.mockRejectedValueOnce( + makeErrnoError("save failed", "EBUSY"), + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toThrow( - "save failed", - ); + await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject({ + code: "EBUSY", + message: "save failed", + }); expect(originalQuotaCache).toEqual({ byAccountId: {}, byEmail: {}, @@ -3037,7 +3045,7 @@ describe("codex manager cli commands", () => { }); }); - it("prunes stale email-scoped quota cache entries when same-email workspaces have no stored accountIds", async () => { + it("skips live probe when same-email workspaces still lack stored accountIds", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, @@ -4868,12 +4876,17 @@ describe("codex manager cli commands", () => { refresh: "refresh-live-fix-next", expires: now + 7_200_000, }); - saveAccountsMock.mockRejectedValueOnce(new Error("save failed")); + saveAccountsMock.mockRejectedValueOnce( + makeErrnoError("save failed", "EBUSY"), + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); await expect( runCodexMultiAuthCli(["auth", "fix", "--live", "--json"]), - ).rejects.toThrow("save failed"); + ).rejects.toMatchObject({ + code: "EBUSY", + message: "save failed", + }); expect(originalQuotaCache).toEqual({ byAccountId: {}, byEmail: {}, From dae35ba923f77ad0310431b0c46644c7d4c12798 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 14:29:56 +0800 Subject: [PATCH 07/11] test(quota): cover concurrent cache save retries --- test/quota-cache.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index 54b5ffb6..aa7d9da0 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -3,6 +3,15 @@ import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +function makeErrnoError( + message: string, + code: "EBUSY" | "EPERM", +): NodeJS.ErrnoException { + const error = new Error(message) as NodeJS.ErrnoException; + error.code = code; + return error; +} + describe("quota cache", () => { let tempDir: string; let originalDir: string | undefined; @@ -166,6 +175,71 @@ describe("quota cache", () => { }, ); + it("keeps the cache file valid across concurrent save retries", async () => { + const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = + await import("../lib/quota-cache.js"); + const payload = { + byAccountId: { + acc_1: { + updatedAt: Date.now(), + status: 200, + model: "gpt-5-codex", + planType: "plus", + primary: { usedPercent: 40, windowMinutes: 300 }, + secondary: { usedPercent: 20, windowMinutes: 10080 }, + }, + }, + byEmail: { + "owner@example.com": { + updatedAt: Date.now(), + status: 200, + model: "gpt-5-codex", + planType: "plus", + primary: { usedPercent: 40, windowMinutes: 300 }, + secondary: { usedPercent: 20, windowMinutes: 10080 }, + }, + }, + }; + const realRename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename"); + let attempts = 0; + const retryableAttempts = new Map([ + [1, "EBUSY"], + [2, "EPERM"], + [4, "EBUSY"], + [5, "EPERM"], + ]); + renameSpy.mockImplementation(async (...args) => { + attempts += 1; + const code = retryableAttempts.get(attempts); + if (code) { + throw makeErrnoError(`rename failed: ${code}`, code); + } + return realRename(...args); + }); + + try { + await Promise.all( + Array.from({ length: 4 }, () => saveQuotaCache(payload)), + ); + + const raw = await fs.readFile(getQuotaCachePath(), "utf8"); + expect(() => JSON.parse(raw)).not.toThrow(); + expect(JSON.parse(raw)).toEqual({ + version: 1, + byAccountId: payload.byAccountId, + byEmail: payload.byEmail, + }); + await expect(loadQuotaCache()).resolves.toEqual(payload); + expect(attempts).toBeGreaterThan(4); + + const entries = await fs.readdir(tempDir); + expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + } finally { + renameSpy.mockRestore(); + } + }); + it("cleans up temp files when rename keeps failing", async () => { const { saveQuotaCache } = await import("../lib/quota-cache.js"); const renameSpy = vi.spyOn(fs, "rename"); From 252ba8aa61c4876cdb097375da9a30e97bd20279 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 17:33:28 +0800 Subject: [PATCH 08/11] fix(cli): align forecast quota cache handling --- lib/codex-manager.ts | 16 +- test/codex-manager-cli.test.ts | 319 +++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 9 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d3feec3b..eaff2204 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1749,7 +1749,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { account.enabled = true; changed = true; } - if (accountIdentityChanged && liveProbe && quotaCache) { + if (accountIdentityChanged && liveProbe && workingQuotaCache) { quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); } account.lastUsed = Date.now(); @@ -2470,8 +2470,8 @@ async function runForecast(args: string[]): Promise { console.log(` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`); } } - if (quotaCache && quotaCacheChanged) { - await saveQuotaCache(quotaCache); + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); } return 0; @@ -3204,15 +3204,13 @@ async function runFix(args: string[]): Promise { accountChanged = true; accountIdentityChanged = true; } - if (!account.accountId && nextAccountId) { - account.accountId = nextAccountId; - account.accountIdSource = "token"; + if (applyTokenAccountIdentity(account, nextAccountId)) { accountChanged = true; accountIdentityChanged = true; } if (accountChanged) changed = true; - if (accountIdentityChanged && options.live && quotaCache) { + if (accountIdentityChanged && options.live && workingQuotaCache) { quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); } if (options.live) { @@ -3410,8 +3408,8 @@ async function runFix(args: string[]): Promise { console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); } } - if (quotaCache && quotaCacheChanged) { - await saveQuotaCache(quotaCache); + if (workingQuotaCache && quotaCacheChanged) { + await saveQuotaCache(workingQuotaCache); } if (changed && options.dryRun) { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 986fcfc9..ccfb763d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -630,6 +630,83 @@ describe("codex manager cli commands", () => { }); }); + it("persists the working quota cache for live forecast display mode", async () => { + const now = Date.now(); + const originalQuotaCache = { + byAccountId: {}, + byEmail: {}, + }; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "forecast@example.com", + accountId: "acc_forecast", + refreshToken: "refresh-forecast", + accessToken: "access-forecast", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 15, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "forecast", "--live"]); + + expect(exitCode).toBe(0); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + acc_forecast: { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 15, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + byEmail: {}, + }); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Best-account preview"), + ), + ).toBe(true); + }); + it("prints implemented 41-feature matrix", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -4894,6 +4971,84 @@ describe("codex manager cli commands", () => { expect(saveQuotaCacheMock).not.toHaveBeenCalled(); }); + it("persists the working quota cache for live fix display mode", async () => { + const now = Date.now(); + const originalQuotaCache = { + byAccountId: {}, + byEmail: {}, + }; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "live-fix@example.com", + accountId: "acc_live_fix", + refreshToken: "refresh-live-fix", + accessToken: "access-live-fix", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 35, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 22, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--live"]); + + expect(exitCode).toBe(0); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + acc_live_fix: { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 35, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 22, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + byEmail: {}, + }); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Auto-fix scan"), + ), + ).toBe(true); + }); + it("recomputes live quota fallback state after refresh changes a shared-workspace email", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -5025,6 +5180,170 @@ describe("codex manager cli commands", () => { ).toHaveLength(2); }); + it("recomputes live quota fallback state after refresh changes accountId for the same email", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "owner@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha-stale", + expiresAt: now - 5_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + { + email: "owner@example.com", + accountId: "workspace-beta", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "owner@example.com": { + updatedAt: now - 5_000, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 95, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 95, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-alpha-refreshed", + refresh: "refresh-alpha-next", + expires: now + 7_200_000, + idToken: "id-token-alpha", + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountId).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "workspace-alpha"; + if (accessToken === "access-beta") return "workspace-beta"; + return "acc_test"; + }, + ); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }, + ); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--live", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + "workspace-alpha": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + "workspace-beta": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }, + }, + byEmail: {}, + }); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.accountId).toBe( + "workspace-alpha", + ); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect( + payload.reports.filter((report) => report.outcome === "healthy"), + ).toHaveLength(2); + }); + it("deletes an account from manage mode and persists storage", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ From 26cbaabccb1848f3fb0bd3013f2b0516e1ae5ae0 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 17:56:42 +0800 Subject: [PATCH 09/11] test(cli): close quota review gaps --- test/codex-manager-cli.test.ts | 290 +++++++++++++++++++++++++++++++++ test/quota-cache.test.ts | 48 +++--- 2 files changed, 318 insertions(+), 20 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index ccfb763d..8680d4d4 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -630,6 +630,83 @@ describe("codex manager cli commands", () => { }); }); + it("does not mutate loaded quota cache when live forecast display save fails", async () => { + const now = Date.now(); + const originalQuotaCache = { + byAccountId: {}, + byEmail: {}, + }; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "forecast@example.com", + accountId: "acc_forecast", + refreshToken: "refresh-forecast", + accessToken: "access-forecast", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 15, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + saveQuotaCacheMock.mockRejectedValueOnce( + makeErrnoError("save failed", "EBUSY"), + ); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "forecast", "--live"]), + ).rejects.toMatchObject({ + code: "EBUSY", + message: "save failed", + }); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + acc_forecast: { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 15, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + byEmail: {}, + }); + }); + it("persists the working quota cache for live forecast display mode", async () => { const now = Date.now(); const originalQuotaCache = { @@ -1358,6 +1435,131 @@ describe("codex manager cli commands", () => { extractAccountIdMock.mockImplementation(() => "acc_test"); }); + it("recomputes live quota fallback state during auth check after refresh changes a shared-workspace email", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha-stale", + expiresAt: now - 5_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + { + email: "owner@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "owner@example.com": { + updatedAt: now - 5_000, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 95, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 95, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-alpha-refreshed", + refresh: "refresh-alpha-next", + expires: now + 7_200_000, + idToken: "id-token-alpha", + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountId).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }, + ); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }, + ); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "check"]); + + expect(exitCode).toBe(0); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( + "owner@example.com", + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Result: 2 working"), + ), + ).toBe(true); + }); + it("treats fresh access tokens as healthy without forcing refresh", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -3084,6 +3286,16 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenNthCalledWith(1, { + accountId: "workspace-alpha", + accessToken: "access-alpha", + model: "gpt-5-codex", + }); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenNthCalledWith(2, { + accountId: "workspace-beta", + accessToken: "access-beta", + model: "gpt-5-codex", + }); expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); expect(saveQuotaCacheMock).toHaveBeenCalledWith({ byAccountId: { @@ -4971,6 +5183,84 @@ describe("codex manager cli commands", () => { expect(saveQuotaCacheMock).not.toHaveBeenCalled(); }); + it("does not mutate loaded quota cache when live fix display save fails", async () => { + const now = Date.now(); + const originalQuotaCache = { + byAccountId: {}, + byEmail: {}, + }; + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "live-fix@example.com", + accountId: "acc_live_fix", + refreshToken: "refresh-live-fix", + accessToken: "access-live-fix", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache); + fetchCodexQuotaSnapshotMock.mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 35, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 22, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }); + saveQuotaCacheMock.mockRejectedValueOnce( + makeErrnoError("save failed", "EBUSY"), + ); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "fix", "--live"]), + ).rejects.toMatchObject({ + code: "EBUSY", + message: "save failed", + }); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + acc_live_fix: { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 35, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 22, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + byEmail: {}, + }); + }); + it("persists the working quota cache for live fix display mode", async () => { const now = Date.now(); const originalQuotaCache = { diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index aa7d9da0..ed22a340 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -176,8 +176,11 @@ describe("quota cache", () => { ); it("keeps the cache file valid across concurrent save retries", async () => { - const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = - await import("../lib/quota-cache.js"); + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); const payload = { byAccountId: { acc_1: { @@ -200,25 +203,28 @@ describe("quota cache", () => { }, }, }; - const realRename = fs.rename.bind(fs); - const renameSpy = vi.spyOn(fs, "rename"); - let attempts = 0; - const retryableAttempts = new Map([ - [1, "EBUSY"], - [2, "EPERM"], - [4, "EBUSY"], - [5, "EPERM"], - ]); - renameSpy.mockImplementation(async (...args) => { - attempts += 1; - const code = retryableAttempts.get(attempts); - if (code) { - throw makeErrnoError(`rename failed: ${code}`, code); - } - return realRename(...args); - }); try { + const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = + await import("../lib/quota-cache.js"); + const realRename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename"); + let attempts = 0; + const retryableAttempts = new Map([ + [1, "EBUSY"], + [2, "EPERM"], + [4, "EBUSY"], + [5, "EPERM"], + ]); + renameSpy.mockImplementation(async (...args) => { + attempts += 1; + const code = retryableAttempts.get(attempts); + if (code) { + throw makeErrnoError(`rename failed: ${code}`, code); + } + return realRename(...args); + }); + await Promise.all( Array.from({ length: 4 }, () => saveQuotaCache(payload)), ); @@ -232,11 +238,13 @@ describe("quota cache", () => { }); await expect(loadQuotaCache()).resolves.toEqual(payload); expect(attempts).toBeGreaterThan(4); + expect(warnMock).not.toHaveBeenCalled(); const entries = await fs.readdir(tempDir); expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); - } finally { renameSpy.mockRestore(); + } finally { + vi.doUnmock("../lib/logger.js"); } }); From c073b9cfe9b50def3b05250eb64bc068dc03b780 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 19:04:17 +0800 Subject: [PATCH 10/11] fix(quota): prune stale live email fallbacks --- lib/codex-manager.ts | 40 +++++ test/codex-manager-cli.test.ts | 303 +++++++++++++++++++++++++++++++++ test/quota-cache.test.ts | 5 +- 3 files changed, 346 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index eaff2204..3df5b7f7 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -640,6 +640,28 @@ function cloneQuotaCacheData(cache: QuotaCacheData): QuotaCacheData { }; } +function pruneUnsafeQuotaEmailCacheEntry( + cache: QuotaCacheData, + email: string | undefined, + accounts: readonly Pick[], + emailFallbackState = buildQuotaEmailFallbackState(accounts), +): boolean { + const normalizedEmail = normalizeQuotaEmail(email); + if (!normalizedEmail || !cache.byEmail[normalizedEmail]) { + return false; + } + const hasSafeFallbackAccount = accounts.some( + (account) => + normalizeQuotaEmail(account.email) === normalizedEmail && + hasSafeQuotaEmailFallback(emailFallbackState, account), + ); + if (hasSafeFallbackAccount) { + return false; + } + delete cache.byEmail[normalizedEmail]; + return true; +} + const DEFAULT_MENU_QUOTA_REFRESH_TTL_MS = 5 * 60_000; const MENU_QUOTA_REFRESH_MODEL = "gpt-5-codex"; @@ -675,6 +697,8 @@ function resolveMenuQuotaProbeInput( return null; } + // Menu auto-refresh is cache-backed, so only probe when the result can be + // written behind a safe lookup key for later reuse. const canStore = (normalizeQuotaAccountId(account.accountId) !== null && hasUniqueQuotaAccountId(accounts, account)) || @@ -1723,6 +1747,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { if (result.type === "success") { const tokenAccountId = extractAccountId(result.access); const nextEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + const previousEmail = account.email; let accountIdentityChanged = false; if (account.refreshToken !== result.refresh) { account.refreshToken = result.refresh; @@ -1751,6 +1776,13 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } if (accountIdentityChanged && liveProbe && workingQuotaCache) { quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaCacheChanged = + pruneUnsafeQuotaEmailCacheEntry( + workingQuotaCache, + previousEmail, + storage.accounts, + quotaEmailFallbackState, + ) || quotaCacheChanged; } account.lastUsed = Date.now(); if (i === activeIndex) { @@ -3184,6 +3216,7 @@ async function runFix(args: string[]): Promise { if (refreshResult.type === "success") { const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); const nextAccountId = extractAccountId(refreshResult.access); + const previousEmail = account.email; let accountChanged = false; let accountIdentityChanged = false; @@ -3212,6 +3245,13 @@ async function runFix(args: string[]): Promise { if (accountChanged) changed = true; if (accountIdentityChanged && options.live && workingQuotaCache) { quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaCacheChanged = + pruneUnsafeQuotaEmailCacheEntry( + workingQuotaCache, + previousEmail, + storage.accounts, + quotaEmailFallbackState, + ) || quotaCacheChanged; } if (options.live) { const probeAccountId = account.accountId ?? nextAccountId; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 8680d4d4..21da4725 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1560,6 +1560,151 @@ describe("codex manager cli commands", () => { ).toBe(true); }); + it("prunes stale quota email cache entries during auth check after refresh changes a shared-workspace email", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha-stale", + expiresAt: now - 5_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: now - 10_000, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 15, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 5, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-alpha-refreshed", + refresh: "refresh-alpha-next", + expires: now + 7_200_000, + idToken: "id-token-alpha", + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountId).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }, + ); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }, + ); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "check"]); + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: { + "beta@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }, + "owner@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + }); + it("treats fresh access tokens as healthy without forcing refresh", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ @@ -5470,6 +5615,164 @@ describe("codex manager cli commands", () => { ).toHaveLength(2); }); + it("prunes stale quota email cache entries during auth fix after refresh changes a shared-workspace email", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha-stale", + expiresAt: now - 5_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + { + email: "beta@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + ], + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "alpha@example.com": { + updatedAt: now - 10_000, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 15, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 5, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-alpha-refreshed", + refresh: "refresh-alpha-next", + expires: now + 7_200_000, + idToken: "id-token-alpha", + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountId).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-stale") return "shared-workspace"; + if (accessToken === "access-alpha-refreshed") return "shared-workspace"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }, + ); + vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + (accessToken?: string) => { + if (accessToken === "access-alpha-refreshed") return "owner@example.com"; + return undefined; + }, + ); + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }) + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--live", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: { + "beta@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, + }, + "owner@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, + }, + }); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string }>; + }; + expect( + payload.reports.filter((report) => report.outcome === "healthy"), + ).toHaveLength(2); + }); + it("recomputes live quota fallback state after refresh changes accountId for the same email", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index ed22a340..2066d2f2 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -203,12 +203,13 @@ describe("quota cache", () => { }, }, }; + let renameSpy: ReturnType | undefined; try { const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = await import("../lib/quota-cache.js"); const realRename = fs.rename.bind(fs); - const renameSpy = vi.spyOn(fs, "rename"); + renameSpy = vi.spyOn(fs, "rename"); let attempts = 0; const retryableAttempts = new Map([ [1, "EBUSY"], @@ -242,8 +243,8 @@ describe("quota cache", () => { const entries = await fs.readdir(tempDir); expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); - renameSpy.mockRestore(); } finally { + renameSpy?.mockRestore(); vi.doUnmock("../lib/logger.js"); } }); From 147d549363a2b9ab73d4a6d748b90dadbfec1df7 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 18 Mar 2026 19:58:20 +0800 Subject: [PATCH 11/11] test(quota): clean up scoped account mock resets --- test/codex-manager-cli.test.ts | 347 +++++++++++++++++---------------- 1 file changed, 179 insertions(+), 168 deletions(-) diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 21da4725..be2f116a 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -1432,7 +1432,6 @@ describe("codex manager cli commands", () => { expiresAt: now + 3_600_000, }), ); - extractAccountIdMock.mockImplementation(() => "acc_test"); }); it("recomputes live quota fallback state during auth check after refresh changes a shared-workspace email", async () => { @@ -1492,7 +1491,9 @@ describe("codex manager cli commands", () => { idToken: "id-token-alpha", }); const accountsModule = await import("../lib/accounts.js"); - vi.mocked(accountsModule.extractAccountId).mockImplementation( + const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); + const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); + extractAccountIdMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha-stale") return "shared-workspace"; if (accessToken === "access-alpha-refreshed") return "shared-workspace"; @@ -1500,7 +1501,7 @@ describe("codex manager cli commands", () => { return "acc_test"; }, ); - vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + extractAccountEmailMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha-refreshed") return "owner@example.com"; return undefined; @@ -1538,26 +1539,33 @@ describe("codex manager cli commands", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "check"]); + try { + const exitCode = await runCodexMultiAuthCli(["auth", "check"]); - expect(exitCode).toBe(0); - expect(queuedRefreshMock).toHaveBeenCalledTimes(1); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: {}, - byEmail: {}, - }); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( - "owner@example.com", - ); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); - expect( - logSpy.mock.calls.some((call) => - String(call[0]).includes("Result: 2 working"), - ), - ).toBe(true); + expect(exitCode).toBe(0); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: {}, + }); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe( + "owner@example.com", + ); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("Result: 2 working"), + ), + ).toBe(true); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + extractAccountEmailMock.mockReset(); + extractAccountEmailMock.mockImplementation(() => undefined); + } }); it("prunes stale quota email cache entries during auth check after refresh changes a shared-workspace email", async () => { @@ -1617,7 +1625,9 @@ describe("codex manager cli commands", () => { idToken: "id-token-alpha", }); const accountsModule = await import("../lib/accounts.js"); - vi.mocked(accountsModule.extractAccountId).mockImplementation( + const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); + const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); + extractAccountIdMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha-stale") return "shared-workspace"; if (accessToken === "access-alpha-refreshed") return "shared-workspace"; @@ -1625,7 +1635,7 @@ describe("codex manager cli commands", () => { return "acc_test"; }, ); - vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + extractAccountEmailMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha-refreshed") return "owner@example.com"; return undefined; @@ -1662,47 +1672,54 @@ describe("codex manager cli commands", () => { }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "check"]); + try { + const exitCode = await runCodexMultiAuthCli(["auth", "check"]); - expect(exitCode).toBe(0); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: {}, - byEmail: { - "beta@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - planType: undefined, - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }, - "owner@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - planType: undefined, - primary: { - usedPercent: 25, - windowMinutes: 300, - resetAtMs: now + 1_000, + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: { + "beta@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, + "owner@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, }, }, - }, - }); + }); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + extractAccountEmailMock.mockReset(); + extractAccountEmailMock.mockImplementation(() => undefined); + } }); it("treats fresh access tokens as healthy without forcing refresh", async () => { @@ -3539,50 +3556,28 @@ describe("codex manager cli commands", () => { }, }); const accountsModule = await import("../lib/accounts.js"); - vi.mocked(accountsModule.extractAccountId).mockImplementation( + const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); + extractAccountIdMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha") return "workspace-alpha"; if (accessToken === "access-beta") return "workspace-beta"; return "acc_test"; }, ); - fetchCodexQuotaSnapshotMock - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, - }, - }) - .mockResolvedValueOnce({ - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); - expect(saveQuotaCacheMock).not.toHaveBeenCalled(); + try { + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).not.toHaveBeenCalled(); + expect(saveQuotaCacheMock).not.toHaveBeenCalled(); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + } }); it("does not reuse email-scoped quota cache entries for mixed same-email accountId rows", async () => { @@ -3646,7 +3641,8 @@ describe("codex manager cli commands", () => { }, }); const accountsModule = await import("../lib/accounts.js"); - vi.mocked(accountsModule.extractAccountId).mockImplementation( + const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); + extractAccountIdMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha") return "workspace-alpha"; if (accessToken === "access-beta") return "workspace-beta"; @@ -3685,31 +3681,37 @@ describe("codex manager cli commands", () => { promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: { - "workspace-alpha": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - primary: { - usedPercent: 20, - windowMinutes: 300, - resetAtMs: now + 1_000, - }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, + try { + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: { + "workspace-alpha": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, }, }, - }, - byEmail: {}, - }); + byEmail: {}, + }); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + } }); it("keeps login loop running when settings action is selected", async () => { @@ -5672,7 +5674,9 @@ describe("codex manager cli commands", () => { idToken: "id-token-alpha", }); const accountsModule = await import("../lib/accounts.js"); - vi.mocked(accountsModule.extractAccountId).mockImplementation( + const extractAccountIdMock = vi.mocked(accountsModule.extractAccountId); + const extractAccountEmailMock = vi.mocked(accountsModule.extractAccountEmail); + extractAccountIdMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha-stale") return "shared-workspace"; if (accessToken === "access-alpha-refreshed") return "shared-workspace"; @@ -5680,7 +5684,7 @@ describe("codex manager cli commands", () => { return "acc_test"; }, ); - vi.mocked(accountsModule.extractAccountEmail).mockImplementation( + extractAccountEmailMock.mockImplementation( (accessToken?: string) => { if (accessToken === "access-alpha-refreshed") return "owner@example.com"; return undefined; @@ -5718,59 +5722,66 @@ describe("codex manager cli commands", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli([ - "auth", - "fix", - "--live", - "--json", - ]); + try { + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--live", + "--json", + ]); - expect(exitCode).toBe(0); - expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); - expect(saveQuotaCacheMock).toHaveBeenCalledWith({ - byAccountId: {}, - byEmail: { - "beta@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - planType: undefined, - primary: { - usedPercent: 70, - windowMinutes: 300, - resetAtMs: now + 3_000, - }, - secondary: { - usedPercent: 40, - windowMinutes: 10080, - resetAtMs: now + 4_000, - }, - }, - "owner@example.com": { - updatedAt: expect.any(Number), - status: 200, - model: "gpt-5-codex", - planType: undefined, - primary: { - usedPercent: 25, - windowMinutes: 300, - resetAtMs: now + 1_000, + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledWith({ + byAccountId: {}, + byEmail: { + "beta@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3_000, + }, + secondary: { + usedPercent: 40, + windowMinutes: 10080, + resetAtMs: now + 4_000, + }, }, - secondary: { - usedPercent: 10, - windowMinutes: 10080, - resetAtMs: now + 2_000, + "owner@example.com": { + updatedAt: expect.any(Number), + status: 200, + model: "gpt-5-codex", + planType: undefined, + primary: { + usedPercent: 25, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, }, }, - }, - }); + }); - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - reports: Array<{ outcome: string }>; - }; - expect( - payload.reports.filter((report) => report.outcome === "healthy"), - ).toHaveLength(2); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string }>; + }; + expect( + payload.reports.filter((report) => report.outcome === "healthy"), + ).toHaveLength(2); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + extractAccountEmailMock.mockReset(); + extractAccountEmailMock.mockImplementation(() => undefined); + } }); it("recomputes live quota fallback state after refresh changes accountId for the same email", async () => {