diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 486afea7..3df5b7f7 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -440,6 +440,54 @@ 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; + // 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; +} + function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { return { status: entry.status, @@ -514,12 +562,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 +573,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 +588,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 +609,59 @@ function updateQuotaCacheForAccount( }; let changed = false; + const accountId = normalizeQuotaAccountId(account.accountId); + const hasUniqueAccountId = + accountId !== null && hasUniqueQuotaAccountId(accounts, account); + if (hasUniqueAccountId) { + cache.byAccountId[accountId] = nextEntry; + changed = true; + } const email = normalizeQuotaEmail(account.email); - if (email) { + if ( + email && + hasSafeQuotaEmailFallback(emailFallbackState, account) && + !hasUniqueAccountId + ) { cache.byEmail[email] = nextEntry; changed = true; - return changed; - } - const accountId = normalizeQuotaAccountId(account.accountId); - if (accountId && hasUniqueQuotaAccountId(accounts, account)) { - cache.byAccountId[accountId] = nextEntry; + } else if (email && cache.byEmail[email]) { + delete cache.byEmail[email]; changed = true; } return changed; } +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 }, + }; +} + +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"; @@ -583,12 +676,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" && @@ -598,6 +697,14 @@ 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)) || + hasSafeQuotaEmailFallback(emailFallbackState, account); + if (!canStore) return null; + const accessToken = account.accessToken; const accountId = accessToken ? (account.accountId ?? extractAccountId(accessToken)) @@ -611,6 +718,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 +728,7 @@ function collectMenuQuotaRefreshTargets( maxAgeMs, now, storage.accounts, + emailFallbackState, ); if (!probeInput) continue; targets.push({ @@ -637,9 +746,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 +775,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 +800,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 +956,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, @@ -1517,6 +1655,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(); @@ -1524,6 +1663,10 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { console.log("No accounts configured."); return; } + let quotaEmailFallbackState = + liveProbe && quotaCache + ? buildQuotaEmailFallbackState(storage.accounts) + : null; let changed = false; let ok = 0; @@ -1568,13 +1711,14 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { accessToken: currentAccessToken, model: probeModel, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } healthDetail = formatQuotaSnapshotForDashboard(snapshot, display); @@ -1603,6 +1747,8 @@ 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; changed = true; @@ -1618,14 +1764,26 @@ 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 && workingQuotaCache) { + quotaEmailFallbackState = buildQuotaEmailFallbackState(storage.accounts); + quotaCacheChanged = + pruneUnsafeQuotaEmailCacheEntry( + workingQuotaCache, + previousEmail, + storage.accounts, + quotaEmailFallbackState, + ) || quotaCacheChanged; + } account.lastUsed = Date.now(); if (i === activeIndex) { activeAccountRefreshed = true; @@ -1644,13 +1802,14 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { accessToken: result.access, model: probeModel, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } healthyMessage = formatQuotaSnapshotForDashboard(snapshot, display); @@ -1692,8 +1851,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) { @@ -2167,6 +2326,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); @@ -2175,6 +2335,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"); @@ -2214,15 +2378,16 @@ 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, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } } @@ -2248,8 +2413,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( @@ -2337,8 +2502,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; @@ -2952,6 +3117,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); @@ -2960,6 +3126,10 @@ async function runFix(args: string[]): Promise { console.log("No accounts configured."); return 0; } + let quotaEmailFallbackState = + options.live && quotaCache + ? buildQuotaEmailFallbackState(storage.accounts) + : null; const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); @@ -2996,13 +3166,14 @@ async function runFix(args: string[]): Promise { accessToken: currentAccessToken, model: options.model, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } reports.push({ @@ -3045,7 +3216,9 @@ 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; if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; @@ -3062,14 +3235,24 @@ 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"; + if (applyTokenAccountIdentity(account, nextAccountId)) { accountChanged = true; + accountIdentityChanged = true; } 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; if (probeAccountId) { @@ -3079,13 +3262,14 @@ async function runFix(args: string[]): Promise { accessToken: refreshResult.access, model: options.model, }); - if (quotaCache) { + if (workingQuotaCache) { quotaCacheChanged = updateQuotaCacheForAccount( - quotaCache, + workingQuotaCache, account, snapshot, storage.accounts, + quotaEmailFallbackState ?? undefined, ) || quotaCacheChanged; } reports.push({ @@ -3187,8 +3371,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( @@ -3264,8 +3448,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 bf9fbef6..be2f116a 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -567,6 +567,223 @@ 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( + makeErrnoError("save failed", "EBUSY"), + ); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "forecast", "--live", "--json"]), + ).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: undefined, + windowMinutes: undefined, + resetAtMs: undefined, + }, + secondary: { + usedPercent: undefined, + windowMinutes: undefined, + resetAtMs: undefined, + }, + }, + }, + byEmail: {}, + }); + }); + + 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 = { + 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(() => {}); @@ -1215,10 +1432,9 @@ describe("codex manager cli commands", () => { expiresAt: now + 3_600_000, }), ); - extractAccountIdMock.mockImplementation(() => "acc_test"); }); - it("treats fresh access tokens as healthy without forcing refresh", async () => { + 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, @@ -1226,34 +1442,383 @@ describe("codex manager cli commands", () => { 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, + 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"); + 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"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }, + ); + extractAccountEmailMock.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).not.toHaveBeenCalled(); - expect(saveAccountsMock).not.toHaveBeenCalled(); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); - expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); - expect( - logSpy.mock.calls.some((call) => - String(call[0]).includes("live session OK"), - ), - ).toBe(true); + 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); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + extractAccountEmailMock.mockReset(); + extractAccountEmailMock.mockImplementation(() => undefined); + } }); - it("runs fix apply mode and returns a switch recommendation", async () => { + 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"); + 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"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }, + ); + extractAccountEmailMock.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"); + + 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, + }, + 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 () => { + const now = Date.now(); + 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: true, + }, + ], + }); + 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).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(1); + expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("live session OK"), + ), + ).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( + makeErrnoError("save failed", "EBUSY"), + ); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject({ + code: "EBUSY", + message: "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({ version: 3, @@ -2384,6 +2949,109 @@ 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).mockResolvedValueOnce({ + 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).mockResolvedValueOnce({ + ready: true, + waitForCode: vi.fn(async () => ({ code: "oauth-code" })), + close: vi.fn(), + }); + const accountsModule = await import("../lib/accounts.js"); + vi.mocked(accountsModule.extractAccountEmail).mockImplementationOnce( + () => "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); + }); + it("updates a unique shared-accountId login when the email claim is missing", async () => { const now = Date.now(); let storageState: { @@ -2425,12 +3093,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", @@ -2439,7 +3107,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(), @@ -2496,7 +3164,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"), @@ -2531,7 +3199,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(); }); @@ -2574,6 +3242,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 () => { @@ -2691,170 +3371,132 @@ describe("codex manager cli commands", () => { }); }); - it("keeps login loop running when settings action is selected", async () => { + it("writes multi-workspace quota cache entries by accountId when one email spans multiple workspaces", async () => { const now = Date.now(); - const storage = { + loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "a@example.com", - accountId: "acc_a", - refreshToken: "refresh-a", - accessToken: "access-a", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], - }; - loadAccountsMock.mockResolvedValue(storage); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "settings" }) - .mockResolvedValueOnce({ mode: "cancel" }); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(promptLoginModeMock).toHaveBeenCalledTimes(2); - }); - - it("passes smart-sorted accounts to auth menu while preserving source index mapping", async () => { - const now = Date.now(); - const storage = { - version: 3, - activeIndex: 2, - activeIndexByFamily: { codex: 2 }, - accounts: [ - { - email: "a@example.com", - accountId: "acc_a", - refreshToken: "refresh-a", - accessToken: "access-a", - expiresAt: now + 3_600_000, - addedAt: now - 3_000, - lastUsed: now - 3_000, - enabled: true, - }, - { - email: "b@example.com", - accountId: "acc_b", - refreshToken: "refresh-b", - accessToken: "access-b", - expiresAt: now + 3_600_000, + 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: "c@example.com", - accountId: "acc_c", - refreshToken: "refresh-c", - accessToken: "access-c", - expiresAt: now + 3_600_000, + 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, }, ], - }; - loadAccountsMock.mockResolvedValue(storage); + }); loadDashboardDisplaySettingsMock.mockResolvedValue({ showPerAccountRows: true, showQuotaDetails: true, showForecastReasons: true, showRecommendations: true, showLiveProbeNotes: true, - menuAutoFetchLimits: false, - menuSortEnabled: true, - menuSortMode: "ready-first", + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", menuSortPinCurrent: true, menuSortQuickSwitchVisibleRow: true, }); - loadQuotaCacheMock.mockResolvedValue({ - byAccountId: {}, - byEmail: { - "a@example.com": { - 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, - }, + fetchCodexQuotaSnapshotMock + .mockResolvedValueOnce({ + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 20, + windowMinutes: 300, + resetAtMs: now + 1_000, }, - "b@example.com": { - updatedAt: now, + 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(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: { + "workspace-alpha": { + updatedAt: expect.any(Number), status: 200, model: "gpt-5-codex", primary: { - usedPercent: 0, + usedPercent: 20, windowMinutes: 300, resetAtMs: now + 1_000, }, secondary: { - usedPercent: 0, + usedPercent: 10, windowMinutes: 10080, resetAtMs: now + 2_000, }, }, - "c@example.com": { - updatedAt: now, + "workspace-beta": { + updatedAt: expect.any(Number), status: 200, model: "gpt-5-codex", primary: { - usedPercent: 60, + usedPercent: 70, windowMinutes: 300, - resetAtMs: now + 1_000, + resetAtMs: now + 3_000, }, secondary: { - usedPercent: 60, + usedPercent: 40, windowMinutes: 10080, - resetAtMs: now + 2_000, + resetAtMs: now + 4_000, }, }, }, + byEmail: {}, }); - 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<{ - email?: string; - index: number; - sourceIndex?: number; - quickSwitchNumber?: number; - isCurrentAccount?: boolean; - }>; - expect(firstCallAccounts.map((account) => account.email)).toEqual([ - "b@example.com", - "c@example.com", - "a@example.com", - ]); - expect(firstCallAccounts.map((account) => account.index)).toEqual([ - 0, 1, 2, - ]); - expect(firstCallAccounts.map((account) => account.sourceIndex)).toEqual([ - 1, 2, 0, - ]); - expect( - firstCallAccounts.map((account) => account.quickSwitchNumber), - ).toEqual([1, 2, 3]); - expect(firstCallAccounts[0]?.isCurrentAccount).toBe(false); - expect(firstCallAccounts[1]?.isCurrentAccount).toBe(true); }); - it("prefers email-scoped quota cache entries for shared workspace accounts", async () => { + it("skips live probe when same-email workspaces still lack stored accountIds", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, @@ -2862,21 +3504,19 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "alpha@example.com", - accountId: "shared-workspace", + email: "owner@example.com", refreshToken: "refresh-alpha", accessToken: "access-alpha", - expiresAt: now + 3_600_000, + expiresAt: now + 60 * 60 * 1000, addedAt: now - 2_000, lastUsed: now - 2_000, enabled: true, }, { - email: "beta@example.com", - accountId: "shared-workspace", + email: "owner@example.com", refreshToken: "refresh-beta", accessToken: "access-beta", - expiresAt: now + 3_600_000, + expiresAt: now + 60 * 60 * 1000, addedAt: now - 1_000, lastUsed: now - 1_000, enabled: true, @@ -2889,16 +3529,17 @@ describe("codex manager cli commands", () => { showForecastReasons: true, showRecommendations: true, showLiveProbeNotes: true, - menuAutoFetchLimits: false, - menuSortEnabled: true, - menuSortMode: "ready-first", - menuSortPinCurrent: false, + menuAutoFetchLimits: true, + menuSortEnabled: false, + menuSortMode: "manual", + menuSortPinCurrent: true, menuSortQuickSwitchVisibleRow: true, }); loadQuotaCacheMock.mockResolvedValue({ - byAccountId: { - "shared-workspace": { - updatedAt: now, + byAccountId: {}, + byEmail: { + "owner@example.com": { + updatedAt: now - 5_000, status: 200, model: "gpt-5-codex", primary: { @@ -2913,55 +3554,33 @@ describe("codex manager cli commands", () => { }, }, }, - byEmail: { - "alpha@example.com": { - 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, - }, - }, - "beta@example.com": { - 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, - }, - }, - }, }); + const accountsModule = await import("../lib/accounts.js"); + 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"; + }, + ); 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<{ - email?: string; - }>; - expect(firstCallAccounts.map((account) => account.email)).toEqual([ - "beta@example.com", - "alpha@example.com", - ]); + 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("uses source-number quick switch mapping when visible-row quick switch is disabled", async () => { + it("does not reuse email-scoped quota cache entries for mixed same-email accountId rows", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ version: 3, @@ -2969,16 +3588,180 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "a@example.com", - accountId: "acc_a", - refreshToken: "refresh-a", - accessToken: "access-a", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - { + 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"); + 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"); + + 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: {}, + }); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + } + }); + + it("keeps login loop running when settings action is selected", async () => { + const now = Date.now(); + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }; + loadAccountsMock.mockResolvedValue(storage); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + expect(exitCode).toBe(0); + expect(promptLoginModeMock).toHaveBeenCalledTimes(2); + }); + + it("passes smart-sorted accounts to auth menu while preserving source index mapping", async () => { + const now = Date.now(); + const storage = { + version: 3, + activeIndex: 2, + activeIndexByFamily: { codex: 2 }, + accounts: [ + { + email: "a@example.com", + accountId: "acc_a", + refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, + addedAt: now - 3_000, + lastUsed: now - 3_000, + enabled: true, + }, + { email: "b@example.com", accountId: "acc_b", refreshToken: "refresh-b", @@ -2988,8 +3771,19 @@ describe("codex manager cli commands", () => { lastUsed: now - 2_000, enabled: true, }, + { + email: "c@example.com", + accountId: "acc_c", + refreshToken: "refresh-c", + accessToken: "access-c", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, ], - }); + }; + loadAccountsMock.mockResolvedValue(storage); loadDashboardDisplaySettingsMock.mockResolvedValue({ showPerAccountRows: true, showQuotaDetails: true, @@ -2999,8 +3793,8 @@ describe("codex manager cli commands", () => { menuAutoFetchLimits: false, menuSortEnabled: true, menuSortMode: "ready-first", - menuSortPinCurrent: false, - menuSortQuickSwitchVisibleRow: false, + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, }); loadQuotaCacheMock.mockResolvedValue({ byAccountId: {}, @@ -3035,6 +3829,21 @@ describe("codex manager cli commands", () => { resetAtMs: now + 2_000, }, }, + "c@example.com": { + updatedAt: now, + status: 200, + model: "gpt-5-codex", + primary: { + usedPercent: 60, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 60, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, + }, }, }); promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); @@ -3045,204 +3854,529 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); const firstCallAccounts = promptLoginModeMock.mock.calls[0]?.[0] as Array<{ email?: string; + index: number; + sourceIndex?: number; quickSwitchNumber?: number; + isCurrentAccount?: boolean; }>; expect(firstCallAccounts.map((account) => account.email)).toEqual([ "b@example.com", + "c@example.com", "a@example.com", ]); + expect(firstCallAccounts.map((account) => account.index)).toEqual([ + 0, 1, 2, + ]); + expect(firstCallAccounts.map((account) => account.sourceIndex)).toEqual([ + 1, 2, 0, + ]); expect( firstCallAccounts.map((account) => account.quickSwitchNumber), - ).toEqual([2, 1]); + ).toEqual([1, 2, 3]); + expect(firstCallAccounts[0]?.isCurrentAccount).toBe(false); + expect(firstCallAccounts[1]?.isCurrentAccount).toBe(true); }); - it("runs doctor command in json mode", async () => { + it("prefers email-scoped quota cache entries for shared workspace accounts", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ + loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "real@example.net", - refreshToken: "refresh-a", - addedAt: now - 1_000, - lastUsed: now - 1_000, + email: "alpha@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-alpha", + accessToken: "access-alpha", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, }, - ], - }); - - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - - const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); - expect(exitCode).toBe(0); - - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - command: string; - summary: { ok: number; warn: number; error: number }; - checks: Array<{ key: string }>; - }; - expect(payload.command).toBe("doctor"); - expect(payload.summary.error).toBe(0); - expect(payload.checks.some((check) => check.key === "active-index")).toBe( - true, - ); - }); - - it("runs doctor command in json mode with malformed token rows", async () => { - const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ { - email: "real@example.net", - refreshToken: "refresh-a", + email: "beta@example.com", + accountId: "shared-workspace", + refreshToken: "refresh-beta", + accessToken: "access-beta", + expiresAt: now + 3_600_000, addedAt: now - 1_000, lastUsed: now - 1_000, - }, - { - email: "broken@example.net", - refreshToken: null as unknown as string, - addedAt: now - 500, - lastUsed: now - 500, + enabled: true, }, ], }); - - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - - const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); - expect(exitCode).toBe(0); - - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - command: string; - summary: { ok: number; warn: number; error: number }; - checks: Array<{ key: string; severity: string }>; - }; - expect(payload.command).toBe("doctor"); - expect(payload.summary.error).toBe(0); - expect(payload.checks).toContainEqual( - expect.objectContaining({ - key: "duplicate-refresh-token", - severity: "ok", - }), - ); + 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: { + "shared-workspace": { + 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, + }, + }, + }, + byEmail: { + "alpha@example.com": { + 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, + }, + }, + "beta@example.com": { + 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, + }, + }, + }, + }); + 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<{ + email?: string; + }>; + expect(firstCallAccounts.map((account) => account.email)).toEqual([ + "beta@example.com", + "alpha@example.com", + ]); }); - it("runs doctor --fix in dry-run mode", async () => { + it("prefers accountId-scoped quota cache entries when one email spans multiple workspaces", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ + loadAccountsMock.mockResolvedValue({ version: 3, - activeIndex: 4, - activeIndexByFamily: { codex: 4 }, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "account1@example.com", - accessToken: "access-a", - refreshToken: "refresh-a", - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: false, + 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: "account2@example.com", - accessToken: "access-b", - refreshToken: "refresh-a", + 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: false, + 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 logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli([ - "auth", - "doctor", - "--fix", - "--dry-run", - "--json", - ]); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(saveAccountsMock).not.toHaveBeenCalled(); - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - fix: { - enabled: boolean; - dryRun: boolean; - changed: boolean; - actions: Array<{ key: string }>; - }; - }; - expect(payload.fix.enabled).toBe(true); - expect(payload.fix.dryRun).toBe(true); - expect(payload.fix.changed).toBe(true); - expect(payload.fix.actions.length).toBeGreaterThan(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("runs report command in json mode", async () => { + it("uses source-number quick switch mapping when visible-row quick switch is disabled", async () => { const now = Date.now(); - loadAccountsMock.mockResolvedValueOnce({ + loadAccountsMock.mockResolvedValue({ version: 3, activeIndex: 0, activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "real@example.net", + email: "a@example.com", + accountId: "acc_a", refreshToken: "refresh-a", + accessToken: "access-a", + expiresAt: now + 3_600_000, addedAt: now - 1_000, lastUsed: now - 1_000, enabled: true, }, { - email: "other@example.net", + email: "b@example.com", + accountId: "acc_b", refreshToken: "refresh-b", - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: false, + accessToken: "access-b", + expiresAt: now + 3_600_000, + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, }, ], }); - - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "report", "--json"]); - - expect(exitCode).toBe(0); - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - command: string; - accounts: { total: number; enabled: number; disabled: number }; - }; - expect(payload.command).toBe("report"); - expect(payload.accounts.total).toBe(2); - expect(payload.accounts.enabled).toBe(1); - expect(payload.accounts.disabled).toBe(1); - }); - - it("drives interactive settings hub across sections and persists dashboard/backend changes", async () => { - const now = Date.now(); - setupInteractiveSettingsLogin(createSettingsStorage(now)); - - const selectSequence = queueSettingsSelectSequence([ - { type: "account-list" }, - { type: "toggle", key: "menuShowStatusBadge" }, - { type: "cycle-sort-mode" }, - { type: "cycle-layout-mode" }, - { type: "save" }, - { type: "summary-fields" }, - { type: "move-down", key: "last-used" }, - { type: "toggle", key: "status" }, - { type: "save" }, - { type: "behavior" }, - { type: "toggle-pause" }, - { type: "toggle-menu-limit-fetch" }, - { type: "set-menu-quota-ttl", ttlMs: 300_000 }, - { type: "set-delay", delayMs: 1_000 }, + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: false, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: false, + menuSortQuickSwitchVisibleRow: false, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: { + "a@example.com": { + 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, + }, + }, + "b@example.com": { + 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, + }, + }, + }, + }); + 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<{ + email?: string; + quickSwitchNumber?: number; + }>; + expect(firstCallAccounts.map((account) => account.email)).toEqual([ + "b@example.com", + "a@example.com", + ]); + expect( + firstCallAccounts.map((account) => account.quickSwitchNumber), + ).toEqual([2, 1]); + }); + + it("runs doctor command in json mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "real@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + command: string; + summary: { ok: number; warn: number; error: number }; + checks: Array<{ key: string }>; + }; + expect(payload.command).toBe("doctor"); + expect(payload.summary.error).toBe(0); + expect(payload.checks.some((check) => check.key === "active-index")).toBe( + true, + ); + }); + + it("runs doctor command in json mode with malformed token rows", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "real@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + }, + { + email: "broken@example.net", + refreshToken: null as unknown as string, + addedAt: now - 500, + lastUsed: now - 500, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--json"]); + expect(exitCode).toBe(0); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + command: string; + summary: { ok: number; warn: number; error: number }; + checks: Array<{ key: string; severity: string }>; + }; + expect(payload.command).toBe("doctor"); + expect(payload.summary.error).toBe(0); + expect(payload.checks).toContainEqual( + expect.objectContaining({ + key: "duplicate-refresh-token", + severity: "ok", + }), + ); + }); + + it("runs doctor --fix in dry-run mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 4, + activeIndexByFamily: { codex: 4 }, + accounts: [ + { + email: "account1@example.com", + accessToken: "access-a", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + { + email: "account2@example.com", + accessToken: "access-b", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "doctor", + "--fix", + "--dry-run", + "--json", + ]); + + expect(exitCode).toBe(0); + expect(saveAccountsMock).not.toHaveBeenCalled(); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + fix: { + enabled: boolean; + dryRun: boolean; + changed: boolean; + actions: Array<{ key: string }>; + }; + }; + expect(payload.fix.enabled).toBe(true); + expect(payload.fix.dryRun).toBe(true); + expect(payload.fix.changed).toBe(true); + expect(payload.fix.actions.length).toBeGreaterThan(0); + }); + + it("runs report command in json mode", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "real@example.net", + refreshToken: "refresh-a", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + { + email: "other@example.net", + refreshToken: "refresh-b", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: false, + }, + ], + }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "report", "--json"]); + + expect(exitCode).toBe(0); + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + command: string; + accounts: { total: number; enabled: number; disabled: number }; + }; + expect(payload.command).toBe("report"); + expect(payload.accounts.total).toBe(2); + expect(payload.accounts.enabled).toBe(1); + expect(payload.accounts.disabled).toBe(1); + }); + + it("drives interactive settings hub across sections and persists dashboard/backend changes", async () => { + const now = Date.now(); + setupInteractiveSettingsLogin(createSettingsStorage(now)); + + const selectSequence = queueSettingsSelectSequence([ + { type: "account-list" }, + { type: "toggle", key: "menuShowStatusBadge" }, + { type: "cycle-sort-mode" }, + { type: "cycle-layout-mode" }, + { type: "save" }, + { type: "summary-fields" }, + { type: "move-down", key: "last-used" }, + { type: "toggle", key: "status" }, + { type: "save" }, + { type: "behavior" }, + { type: "toggle-pause" }, + { type: "toggle-menu-limit-fetch" }, + { type: "set-menu-quota-ttl", ttlMs: 300_000 }, + { type: "set-delay", delayMs: 1_000 }, { type: "save" }, { type: "theme" }, { type: "set-palette", palette: "blue" }, @@ -4003,28 +5137,487 @@ describe("codex manager cli commands", () => { uiAccentColor: "cyan", }); - queueSettingsSelectSequence([ - { type: "account-list" }, - { type: "reset" }, - { type: "save" }, - { type: "back" }, + queueSettingsSelectSequence([ + { type: "account-list" }, + { type: "reset" }, + { type: "save" }, + { type: "back" }, + ]); + + saveDashboardDisplaySettingsMock.mockResolvedValue(undefined); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + uiThemePreset: "blue", + uiAccentColor: "cyan", + }), + ); + }); + + it("keeps last account enabled during fix to avoid lockout", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "solo@example.com", + refreshToken: "refresh-solo", + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "failed", + reason: "http_error", + statusCode: 401, + message: "unauthorized", + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--json"]); + expect(exitCode).toBe(0); + expect(saveAccountsMock).toHaveBeenCalledTimes(1); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe( + true, + ); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect(payload.reports[0]?.outcome).toBe("warning-soft-failure"); + expect(payload.reports[0]?.message).toContain("avoid lockout"); + }); + + it("runs live fix path with probe success and probe fallback warning", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "live-ok@example.com", + accountId: "acc_live_ok", + refreshToken: "refresh-live-ok", + accessToken: "access-live-ok", + expiresAt: now + 3_600_000, + addedAt: now - 5_000, + lastUsed: now - 5_000, + enabled: true, + }, + { + email: "live-warn@example.com", + accountId: "acc_live_warn", + refreshToken: "refresh-live-warn", + accessToken: "access-live-warn", + expiresAt: now - 5_000, + addedAt: now - 4_000, + lastUsed: now - 4_000, + enabled: true, + }, + ], + }); + queuedRefreshMock.mockResolvedValueOnce({ + type: "success", + access: "access-live-warn-next", + refresh: "refresh-live-warn-next", + expires: now + 7_200_000, + }); + 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, + }, + }) + .mockRejectedValueOnce(new Error("live probe temporary failure")); + + 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(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); + expect(queuedRefreshMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + reports: Array<{ outcome: string; message: string }>; + }; + expect( + payload.reports.some( + (report) => + report.outcome === "healthy" && + report.message.includes("live session OK"), + ), + ).toBe(true); + expect( + payload.reports.some( + (report) => + report.outcome === "warning-soft-failure" && + report.message.includes("refresh succeeded but live probe failed"), + ), + ).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( + makeErrnoError("save failed", "EBUSY"), + ); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + await expect( + runCodexMultiAuthCli(["auth", "fix", "--live", "--json"]), + ).rejects.toMatchObject({ + code: "EBUSY", + message: "save failed", + }); + expect(originalQuotaCache).toEqual({ + byAccountId: {}, + byEmail: {}, + }); + 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 = { + 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({ + 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", ]); - saveDashboardDisplaySettingsMock.mockResolvedValue(undefined); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - expect(exitCode).toBe(0); - expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledWith( - expect.objectContaining({ - uiThemePreset: "blue", - uiAccentColor: "cyan", - }), + 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("keeps last account enabled during fix to avoid lockout", async () => { + 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, @@ -4032,38 +5625,166 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "solo@example.com", - refreshToken: "refresh-solo", - addedAt: now - 1_000, - lastUsed: now - 1_000, + 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: "failed", - reason: "http_error", - statusCode: 401, - message: "unauthorized", + 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"); + 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"; + if (accessToken === "access-beta") return "shared-workspace"; + return "acc_test"; + }, + ); + extractAccountEmailMock.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", "--json"]); - expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe( - true, - ); + try { + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--live", + "--json", + ]); - const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { - reports: Array<{ outcome: string; message: string }>; - }; - expect(payload.reports[0]?.outcome).toBe("warning-soft-failure"); - expect(payload.reports[0]?.message).toContain("avoid lockout"); + 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); + } finally { + extractAccountIdMock.mockReset(); + extractAccountIdMock.mockImplementation(() => "acc_test"); + extractAccountEmailMock.mockReset(); + extractAccountEmailMock.mockImplementation(() => undefined); + } }); - it("runs live fix path with probe success and probe fallback warning", async () => { + it("recomputes live quota fallback state after refresh changes accountId for the same email", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ version: 3, @@ -4071,33 +5792,69 @@ describe("codex manager cli commands", () => { activeIndexByFamily: { codex: 0 }, accounts: [ { - email: "live-ok@example.com", - accountId: "acc_live_ok", - refreshToken: "refresh-live-ok", - accessToken: "access-live-ok", - expiresAt: now + 3_600_000, + 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: "live-warn@example.com", - accountId: "acc_live_warn", - refreshToken: "refresh-live-warn", - accessToken: "access-live-warn", - expiresAt: now - 5_000, + 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-live-warn-next", - refresh: "refresh-live-warn-next", + 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, @@ -4113,10 +5870,23 @@ describe("codex manager cli commands", () => { resetAtMs: now + 2_000, }, }) - .mockRejectedValueOnce(new Error("live probe temporary failure")); - + .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", @@ -4125,27 +5895,57 @@ describe("codex manager cli commands", () => { ]); expect(exitCode).toBe(0); - expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); 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.some( - (report) => - report.outcome === "healthy" && - report.message.includes("live session OK"), - ), - ).toBe(true); - expect( - payload.reports.some( - (report) => - report.outcome === "warning-soft-failure" && - report.message.includes("refresh succeeded but live probe failed"), - ), - ).toBe(true); + payload.reports.filter((report) => report.outcome === "healthy"), + ).toHaveLength(2); }); it("deletes an account from manage mode and persists storage", async () => { 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/quota-cache.test.ts b/test/quota-cache.test.ts index 54b5ffb6..2066d2f2 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,80 @@ describe("quota cache", () => { }, ); + it("keeps the cache file valid across concurrent save retries", async () => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + 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 }, + }, + }, + }; + let renameSpy: ReturnType | undefined; + + try { + const { getQuotaCachePath, loadQuotaCache, saveQuotaCache } = + await import("../lib/quota-cache.js"); + const realRename = fs.rename.bind(fs); + 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)), + ); + + 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); + expect(warnMock).not.toHaveBeenCalled(); + + const entries = await fs.readdir(tempDir); + expect(entries.some((entry) => entry.endsWith(".tmp"))).toBe(false); + } finally { + renameSpy?.mockRestore(); + vi.doUnmock("../lib/logger.js"); + } + }); + it("cleans up temp files when rename keeps failing", async () => { const { saveQuotaCache } = await import("../lib/quota-cache.js"); const renameSpy = vi.spyOn(fs, "rename"); 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 = [ {