From 7dcef36a89fb8dcbb50ede0a84a2fd30f6c37ed2 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 12 Mar 2026 13:23:04 +0800 Subject: [PATCH 01/11] fix(auth): define reset and delete safety flows --- docs/troubleshooting.md | 36 +- index.ts | 7303 ++++++++++++++++++-------------- lib/cli.ts | 107 +- lib/codex-manager.ts | 1103 +++-- lib/destructive-actions.ts | 126 + lib/quota-cache.ts | 32 +- lib/ui/auth-menu.ts | 370 +- lib/ui/copy.ts | 34 +- test/codex-manager-cli.test.ts | 629 ++- test/storage.test.ts | 4068 ++++++++++-------- 10 files changed, 8101 insertions(+), 5707 deletions(-) create mode 100644 lib/destructive-actions.ts diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ad0a67e5..fbbcf1cc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -89,24 +89,44 @@ codex auth doctor --json --- -## Soft Reset +## Reset Options -PowerShell: +- Delete a single saved account: `codex auth login` → pick account → **Delete Account** +- Delete saved accounts: `codex auth login` → Danger Zone → **Delete Saved Accounts** +- Reset local state: `codex auth login` → Danger Zone → **Reset Local State** + +Exact effects: + +| Action | Saved accounts | Flagged/problem accounts | Settings | Codex CLI sync state | Quota cache | +| --- | --- | --- | --- | --- | --- | +| Delete Account | Delete the selected saved account | Delete the matching flagged/problem entry for that refresh token | Keep | Keep | Keep | +| Delete Saved Accounts | Delete all saved accounts | Keep | Keep | Keep | Keep | +| Reset Local State | Delete all saved accounts | Delete all flagged/problem accounts | Keep | Keep | Clear | + +To perform the same actions manually: + +Delete saved accounts only: ```powershell Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue -Remove-Item "$HOME\.codex\multi-auth\settings.json" -Force -ErrorAction SilentlyContinue -codex auth login ``` -Bash: +```bash +rm -f ~/.codex/multi-auth/openai-codex-accounts.json +``` + +Reset local state (also clears flagged/problem accounts and quota cache; preserves settings and Codex CLI sync state): + +```powershell +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\quota-cache.json" -Force -ErrorAction SilentlyContinue +``` ```bash rm -f ~/.codex/multi-auth/openai-codex-accounts.json rm -f ~/.codex/multi-auth/openai-codex-flagged-accounts.json -rm -f ~/.codex/multi-auth/settings.json -codex auth login +rm -f ~/.codex/multi-auth/quota-cache.json ``` --- diff --git a/index.ts b/index.ts index 19fced58..109f1cbe 100644 --- a/index.ts +++ b/index.ts @@ -23,167 +23,185 @@ */ -import { tool } from "@codex-ai/plugin/tool"; import type { Plugin, PluginInput } from "@codex-ai/plugin"; +import { tool } from "@codex-ai/plugin/tool"; import type { Auth } from "@codex-ai/sdk"; import { - createAuthorizationFlow, - exchangeAuthorizationCode, - parseAuthorizationInput, - redactOAuthUrlForLog, - REDIRECT_URI, + AccountManager, + extractAccountEmail, + extractAccountId, + formatAccountLabel, + formatCooldown, + formatWaitTime, + getAccountIdCandidates, + isCodexCliSyncEnabled, + lookupCodexCliTokensByEmail, + parseRateLimitReason, + resolveRequestAccountId, + sanitizeEmail, + selectBestAccountCandidate, + shouldUpdateAccountIdFromToken, +} from "./lib/accounts.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, + redactOAuthUrlForLog, } from "./lib/auth/auth.js"; -import { queuedRefresh } from "./lib/refresh-queue.js"; import { openBrowserUrl } from "./lib/auth/browser.js"; import { startLocalOAuthServer } from "./lib/auth/server.js"; +import { checkAndNotify } from "./lib/auto-update-checker.js"; +import { CapabilityPolicyStore } from "./lib/capability-policy.js"; import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js"; import { - getCodexMode, - getFastSession, - getFastSessionStrategy, - getFastSessionMaxInputItems, - getRateLimitToastDebounceMs, - getRetryAllAccountsMaxRetries, - getRetryAllAccountsMaxWaitMs, - getRetryAllAccountsRateLimited, - getFallbackToGpt52OnUnsupportedGpt53, - getUnsupportedCodexPolicy, - getUnsupportedCodexFallbackChain, - getTokenRefreshSkewMs, - getSessionRecovery, getAutoResume, - getToastDurationMs, - getPerProjectAccounts, + getCodexMode, + getCodexTuiColorProfile, + getCodexTuiGlyphMode, + getCodexTuiV2, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, - getPidOffsetEnabled, + getFallbackToGpt52OnUnsupportedGpt53, + getFastSession, + getFastSessionMaxInputItems, + getFastSessionStrategy, getFetchTimeoutMs, - getStreamStallTimeoutMs, - getCodexTuiV2, - getCodexTuiColorProfile, - getCodexTuiGlyphMode, getLiveAccountSync, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, - getSessionAffinity, - getSessionAffinityTtlMs, - getSessionAffinityMaxEntries, - getProactiveRefreshGuardian, - getProactiveRefreshIntervalMs, - getProactiveRefreshBufferMs, getNetworkErrorCooldownMs, - getServerErrorCooldownMs, - getStorageBackupEnabled, + getPerProjectAccounts, + getPidOffsetEnabled, getPreemptiveQuotaEnabled, + getPreemptiveQuotaMaxDeferralMs, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, - getPreemptiveQuotaMaxDeferralMs, + getProactiveRefreshBufferMs, + getProactiveRefreshGuardian, + getProactiveRefreshIntervalMs, + getRateLimitToastDebounceMs, + getRetryAllAccountsMaxRetries, + getRetryAllAccountsMaxWaitMs, + getRetryAllAccountsRateLimited, + getServerErrorCooldownMs, + getSessionAffinity, + getSessionAffinityMaxEntries, + getSessionAffinityTtlMs, + getSessionRecovery, + getStorageBackupEnabled, + getStreamStallTimeoutMs, + getToastDurationMs, + getTokenRefreshSkewMs, + getUnsupportedCodexFallbackChain, + getUnsupportedCodexPolicy, loadPluginConfig, } from "./lib/config.js"; import { - AUTH_LABELS, - CODEX_BASE_URL, - DUMMY_API_KEY, - LOG_STAGES, - PLUGIN_NAME, - PROVIDER_ID, - ACCOUNT_LIMITS, + ACCOUNT_LIMITS, + AUTH_LABELS, + CODEX_BASE_URL, + DUMMY_API_KEY, + LOG_STAGES, + PLUGIN_NAME, + PROVIDER_ID, } from "./lib/constants.js"; +import { handleContextOverflow } from "./lib/context-overflow.js"; +import { + clampActiveIndices, + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./lib/destructive-actions.js"; +import { + EntitlementCache, + resolveEntitlementAccountKey, +} from "./lib/entitlement-cache.js"; +import { LiveAccountSync } from "./lib/live-account-sync.js"; import { + clearCorrelationId, initLogger, - logRequest, logDebug, + logError, logInfo, + logRequest, logWarn, - logError, setCorrelationId, - clearCorrelationId, } from "./lib/logger.js"; -import { checkAndNotify } from "./lib/auto-update-checker.js"; -import { handleContextOverflow } from "./lib/context-overflow.js"; import { - AccountManager, - getAccountIdCandidates, - extractAccountEmail, - extractAccountId, - formatAccountLabel, - formatCooldown, - formatWaitTime, - sanitizeEmail, - selectBestAccountCandidate, - shouldUpdateAccountIdFromToken, - resolveRequestAccountId, - parseRateLimitReason, - lookupCodexCliTokensByEmail, - isCodexCliSyncEnabled, -} from "./lib/accounts.js"; + PreemptiveQuotaScheduler, + readQuotaSchedulerSnapshot, +} from "./lib/preemptive-quota-scheduler.js"; import { - getStoragePath, - loadAccounts, - saveAccounts, - withAccountStorageTransaction, - clearAccounts, - setStoragePath, - exportAccounts, - importAccounts, - loadFlaggedAccounts, - saveFlaggedAccounts, - clearFlaggedAccounts, - normalizeEmailKey, - StorageError, - formatStorageErrorHint, - setStorageBackupEnabled, - type AccountStorageV3, - type FlaggedAccountMetadataV1, -} from "./lib/storage.js"; + getCodexInstructions, + getModelFamily, + MODEL_FAMILIES, + type ModelFamily, + prewarmCodexInstructions, +} from "./lib/prompts/codex.js"; +import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; +import { + createSessionRecoveryHook, + detectErrorType, + getRecoveryToastContent, + isRecoverableError, +} from "./lib/recovery.js"; +import { RefreshGuardian } from "./lib/refresh-guardian.js"; +import { queuedRefresh } from "./lib/refresh-queue.js"; +import { + evaluateFailurePolicy, + type FailoverMode, +} from "./lib/request/failure-policy.js"; import { createCodexHeaders, extractRequestUrl, - handleErrorResponse, - handleSuccessResponse, getUnsupportedCodexModelInfo, + handleErrorResponse, + handleSuccessResponse, + refreshAndUpdateToken, resolveUnsupportedCodexFallbackModel, - refreshAndUpdateToken, - rewriteUrlForCodex, + rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js"; -import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js"; +import { applyFastSessionDefaults } from "./lib/request/request-transformer.js"; import { isEmptyResponse } from "./lib/request/response-handler.js"; +import { withStreamingFailover } from "./lib/request/stream-failover.js"; import { addJitter } from "./lib/rotation.js"; import { SessionAffinityStore } from "./lib/session-affinity.js"; -import { LiveAccountSync } from "./lib/live-account-sync.js"; -import { RefreshGuardian } from "./lib/refresh-guardian.js"; -import { - evaluateFailurePolicy, - type FailoverMode, -} from "./lib/request/failure-policy.js"; +import { registerCleanup } from "./lib/shutdown.js"; import { - EntitlementCache, - resolveEntitlementAccountKey, -} from "./lib/entitlement-cache.js"; + type AccountStorageV3, + exportAccounts, + type FlaggedAccountMetadataV1, + formatStorageErrorHint, + getStoragePath, + importAccounts, + loadAccounts, + loadFlaggedAccounts, + normalizeEmailKey, + StorageError, + saveAccounts, + saveFlaggedAccounts, + setStorageBackupEnabled, + setStoragePath, + withAccountStorageTransaction, +} from "./lib/storage.js"; import { - PreemptiveQuotaScheduler, - readQuotaSchedulerSnapshot, -} from "./lib/preemptive-quota-scheduler.js"; -import { CapabilityPolicyStore } from "./lib/capability-policy.js"; -import { withStreamingFailover } from "./lib/request/stream-failover.js"; -import { buildTableHeader, buildTableRow, type TableOptions } from "./lib/table-formatter.js"; -import { setUiRuntimeOptions, type UiRuntimeOptions } from "./lib/ui/runtime.js"; -import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js"; + buildTableHeader, + buildTableRow, + type TableOptions, +} from "./lib/table-formatter.js"; import { - getModelFamily, - getCodexInstructions, - MODEL_FAMILIES, - prewarmCodexInstructions, - type ModelFamily, -} from "./lib/prompts/codex.js"; -import { prewarmHostCodexPrompt } from "./lib/prompts/host-codex-prompt.js"; + createHashlineEditTool, + createHashlineReadTool, +} from "./lib/tools/hashline-tools.js"; import type { AccountIdSource, OAuthAuthDetails, @@ -192,16 +210,17 @@ import type { UserConfig, } from "./lib/types.js"; import { - createSessionRecoveryHook, - isRecoverableError, - detectErrorType, - getRecoveryToastContent, -} from "./lib/recovery.js"; + formatUiBadge, + formatUiHeader, + formatUiItem, + formatUiKeyValue, + formatUiSection, + paintUiText, +} from "./lib/ui/format.js"; import { - createHashlineEditTool, - createHashlineReadTool, -} from "./lib/tools/hashline-tools.js"; -import { registerCleanup } from "./lib/shutdown.js"; + setUiRuntimeOptions, + type UiRuntimeOptions, +} from "./lib/ui/runtime.js"; /** * OpenAI Codex OAuth authentication plugin for Codex CLI host runtime @@ -232,7 +251,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let liveAccountSyncPath: string | null = null; let refreshGuardian: RefreshGuardian | null = null; let refreshGuardianConfigKey: string | null = null; - let sessionAffinityStore: SessionAffinityStore | null = new SessionAffinityStore(); + let sessionAffinityStore: SessionAffinityStore | null = + new SessionAffinityStore(); let sessionAffinityConfigKey: string | null = null; const entitlementCache = new EntitlementCache(); const preemptiveQuotaScheduler = new PreemptiveQuotaScheduler(); @@ -259,48 +279,50 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return "balanced"; }; - const parseEnvInt = (value: string | undefined): number | undefined => { - if (value === undefined) return undefined; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const parseEnvInt = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; + }; - const MAX_RETRY_HINT_MS = 5 * 60 * 1000; - const clampRetryHintMs = (value: number): number | null => { - if (!Number.isFinite(value)) return null; - const normalized = Math.floor(value); - if (normalized <= 0) return null; - return Math.min(normalized, MAX_RETRY_HINT_MS); - }; + const MAX_RETRY_HINT_MS = 5 * 60 * 1000; + const clampRetryHintMs = (value: number): number | null => { + if (!Number.isFinite(value)) return null; + const normalized = Math.floor(value); + if (normalized <= 0) return null; + return Math.min(normalized, MAX_RETRY_HINT_MS); + }; - const parseRetryAfterHintMs = (headers: Headers): number | null => { - const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); - if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { - return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); - } + const parseRetryAfterHintMs = (headers: Headers): number | null => { + const retryAfterMsHeader = headers.get("retry-after-ms")?.trim(); + if (retryAfterMsHeader && /^\d+$/.test(retryAfterMsHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterMsHeader, 10)); + } - const retryAfterHeader = headers.get("retry-after")?.trim(); - if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) { - return clampRetryHintMs(Number.parseInt(retryAfterHeader, 10) * 1000); - } - if (retryAfterHeader) { - const retryAtMs = Date.parse(retryAfterHeader); - if (Number.isFinite(retryAtMs)) { - return clampRetryHintMs(retryAtMs - Date.now()); - } + const retryAfterHeader = headers.get("retry-after")?.trim(); + if (retryAfterHeader && /^\d+$/.test(retryAfterHeader)) { + return clampRetryHintMs(Number.parseInt(retryAfterHeader, 10) * 1000); + } + if (retryAfterHeader) { + const retryAtMs = Date.parse(retryAfterHeader); + if (Number.isFinite(retryAtMs)) { + return clampRetryHintMs(retryAtMs - Date.now()); } + } - const resetAtHeader = headers.get("x-ratelimit-reset")?.trim(); - if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { - const resetRaw = Number.parseInt(resetAtHeader, 10); - const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; - return clampRetryHintMs(resetAtMs - Date.now()); - } + const resetAtHeader = headers.get("x-ratelimit-reset")?.trim(); + if (resetAtHeader && /^\d+$/.test(resetAtHeader)) { + const resetRaw = Number.parseInt(resetAtHeader, 10); + const resetAtMs = resetRaw < 10_000_000_000 ? resetRaw * 1000 : resetRaw; + return clampRetryHintMs(resetAtMs - Date.now()); + } - return null; - }; + return null; + }; - const sanitizeResponseHeadersForLog = (headers: Headers): Record => { + const sanitizeResponseHeadersForLog = ( + headers: Headers, + ): Record => { const allowed = new Set([ "content-type", "x-request-id", @@ -370,522 +392,551 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { lastError: null, }; - type TokenSuccess = Extract; - type TokenSuccessWithAccount = TokenSuccess & { - accountIdOverride?: string; - accountIdSource?: AccountIdSource; - accountLabel?: string; - }; - - const resolveAccountSelection = ( - tokens: TokenSuccess, - ): TokenSuccessWithAccount => { - const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); - if (override) { - const suffix = override.length > 6 ? override.slice(-6) : override; - logInfo(`Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`); - return { - ...tokens, - accountIdOverride: override, - accountIdSource: "manual", - accountLabel: `Override [id:${suffix}]`, - }; - } - - const candidates = getAccountIdCandidates(tokens.access, tokens.idToken); - if (candidates.length === 0) { - return tokens; - } - - if (candidates.length === 1) { - const [candidate] = candidates; - if (candidate) { - return { - ...tokens, - accountIdOverride: candidate.accountId, - accountIdSource: candidate.source, - accountLabel: candidate.label, - }; - } + type TokenSuccess = Extract; + type TokenSuccessWithAccount = TokenSuccess & { + accountIdOverride?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; + }; + + const resolveAccountSelection = ( + tokens: TokenSuccess, + ): TokenSuccessWithAccount => { + const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); + if (override) { + const suffix = override.length > 6 ? override.slice(-6) : override; + logInfo( + `Using account override from CODEX_AUTH_ACCOUNT_ID (id:${suffix}).`, + ); + return { + ...tokens, + accountIdOverride: override, + accountIdSource: "manual", + accountLabel: `Override [id:${suffix}]`, + }; + } + + const candidates = getAccountIdCandidates(tokens.access, tokens.idToken); + if (candidates.length === 0) { + return tokens; + } + + if (candidates.length === 1) { + const [candidate] = candidates; + if (candidate) { + return { + ...tokens, + accountIdOverride: candidate.accountId, + accountIdSource: candidate.source, + accountLabel: candidate.label, + }; } + } + + // Auto-select the best workspace candidate without prompting. + // This honors org/default/id-token signals and avoids forcing personal token IDs. + const choice = selectBestAccountCandidate(candidates); + if (!choice) return tokens; + + return { + ...tokens, + accountIdOverride: choice.accountId, + accountIdSource: choice.source ?? "token", + accountLabel: choice.label, + }; + }; - // Auto-select the best workspace candidate without prompting. - // This honors org/default/id-token signals and avoids forcing personal token IDs. - const choice = selectBestAccountCandidate(candidates); - if (!choice) return tokens; - - return { - ...tokens, - accountIdOverride: choice.accountId, - accountIdSource: choice.source ?? "token", - accountLabel: choice.label, - }; - }; - - const buildManualOAuthFlow = ( - pkce: { verifier: string }, - url: string, - expectedState: string, - onSuccess?: (tokens: TokenSuccessWithAccount) => Promise, - ) => ({ - url, - method: "code" as const, - instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, - validate: (input: string): string | undefined => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code) { - return `No authorization code found. Paste the full callback URL (e.g., ${REDIRECT_URI}?code=...)`; - } - if (!parsed.state) { - return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; - } - if (parsed.state !== expectedState) { - return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; - } - return undefined; - }, - callback: async (input: string) => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code || !parsed.state) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "Missing authorization code or OAuth state", - }; - } - if (parsed.state !== expectedState) { - return { - type: "failed" as const, - reason: "invalid_response" as const, - message: "OAuth state mismatch. Restart login and try again.", - }; - } - const tokens = await exchangeAuthorizationCode( - parsed.code, - pkce.verifier, - REDIRECT_URI, - ); - if (tokens?.type === "success") { - const resolved = resolveAccountSelection(tokens); - if (onSuccess) { - await onSuccess(resolved); - } - return resolved; - } - return tokens?.type === "failed" - ? tokens - : { type: "failed" as const }; - }, - }); + const buildManualOAuthFlow = ( + pkce: { verifier: string }, + url: string, + expectedState: string, + onSuccess?: (tokens: TokenSuccessWithAccount) => Promise, + ) => ({ + url, + method: "code" as const, + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + validate: (input: string): string | undefined => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code) { + return `No authorization code found. Paste the full callback URL (e.g., ${REDIRECT_URI}?code=...)`; + } + if (!parsed.state) { + return "Missing OAuth state. Paste the full callback URL including both code and state parameters."; + } + if (parsed.state !== expectedState) { + return "OAuth state mismatch. Restart login and paste the callback URL generated for this login attempt."; + } + return undefined; + }, + callback: async (input: string) => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code || !parsed.state) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "Missing authorization code or OAuth state", + }; + } + if (parsed.state !== expectedState) { + return { + type: "failed" as const, + reason: "invalid_response" as const, + message: "OAuth state mismatch. Restart login and try again.", + }; + } + const tokens = await exchangeAuthorizationCode( + parsed.code, + pkce.verifier, + REDIRECT_URI, + ); + if (tokens?.type === "success") { + const resolved = resolveAccountSelection(tokens); + if (onSuccess) { + await onSuccess(resolved); + } + return resolved; + } + return tokens?.type === "failed" ? tokens : { type: "failed" as const }; + }, + }); const runOAuthFlow = async ( forceNewLogin: boolean = false, ): Promise => { - const { pkce, state, url } = await createAuthorizationFlow({ forceNewLogin }); + const { pkce, state, url } = await createAuthorizationFlow({ + forceNewLogin, + }); logInfo(`OAuth URL: ${redactOAuthUrlForLog(url)}`); - let serverInfo: Awaited> | null = null; - try { - serverInfo = await startLocalOAuthServer({ state }); - } catch (err) { - logDebug(`[${PLUGIN_NAME}] Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`); - serverInfo = null; - } - openBrowserUrl(url); - - if (!serverInfo || !serverInfo.ready) { - serverInfo?.close(); - const message = - `\n[${PLUGIN_NAME}] OAuth callback server failed to start. ` + - `Please retry with "${AUTH_LABELS.OAUTH_MANUAL}".\n`; - logWarn(message); - return { type: "failed" as const }; - } - - const result = await serverInfo.waitForCode(state); - serverInfo.close(); + let serverInfo: Awaited> | null = + null; + try { + serverInfo = await startLocalOAuthServer({ state }); + } catch (err) { + logDebug( + `[${PLUGIN_NAME}] Failed to start OAuth server: ${(err as Error)?.message ?? String(err)}`, + ); + serverInfo = null; + } + openBrowserUrl(url); + + if (!serverInfo || !serverInfo.ready) { + serverInfo?.close(); + const message = + `\n[${PLUGIN_NAME}] OAuth callback server failed to start. ` + + `Please retry with "${AUTH_LABELS.OAUTH_MANUAL}".\n`; + logWarn(message); + return { type: "failed" as const }; + } + + const result = await serverInfo.waitForCode(state); + serverInfo.close(); if (!result) { - return { type: "failed" as const, reason: "unknown" as const, message: "OAuth callback timeout or cancelled" }; + return { + type: "failed" as const, + reason: "unknown" as const, + message: "OAuth callback timeout or cancelled", + }; } - return await exchangeAuthorizationCode( - result.code, - pkce.verifier, - REDIRECT_URI, - ); - }; - - const persistAccountPool = async ( - results: TokenSuccessWithAccount[], - replaceAll: boolean = false, - ): Promise => { - if (results.length === 0) return; - await withAccountStorageTransaction(async (loadedStorage, persist) => { - const now = Date.now(); - const stored = replaceAll ? null : loadedStorage; - const accounts = stored?.accounts ? [...stored.accounts] : []; - - const indexByRefreshToken = new Map(); - const indexByAccountId = new Map(); - const indexByEmail = new Map(); - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account) continue; - if (account.refreshToken) { - indexByRefreshToken.set(account.refreshToken, i); - } - if (account.accountId) { - indexByAccountId.set(account.accountId, i); - } - const emailKey = normalizeEmailKey(account.email); - if (emailKey) { - indexByEmail.set(emailKey, i); - } - } + return await exchangeAuthorizationCode( + result.code, + pkce.verifier, + REDIRECT_URI, + ); + }; - for (const result of results) { - const accountId = result.accountIdOverride ?? extractAccountId(result.access); - const accountIdSource = - accountId - ? result.accountIdSource ?? - (result.accountIdOverride ? "manual" : "token") - : undefined; - const accountLabel = result.accountLabel; - const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); - const existingByEmail = - accountEmail && indexByEmail.has(accountEmail) - ? indexByEmail.get(accountEmail) - : undefined; - const existingById = - accountId && indexByAccountId.has(accountId) - ? indexByAccountId.get(accountId) - : undefined; - const existingByToken = indexByRefreshToken.get(result.refresh); - const existingIndex = existingById ?? existingByEmail ?? existingByToken; - - if (existingIndex === undefined) { - const newIndex = accounts.length; - accounts.push({ - accountId, - accountIdSource, - accountLabel, - email: accountEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - addedAt: now, - lastUsed: now, - }); - indexByRefreshToken.set(result.refresh, newIndex); - if (accountId) { - indexByAccountId.set(accountId, newIndex); - } - if (accountEmail) { - indexByEmail.set(accountEmail, newIndex); - } - continue; - } + const persistAccountPool = async ( + results: TokenSuccessWithAccount[], + replaceAll: boolean = false, + ): Promise => { + if (results.length === 0) return; + await withAccountStorageTransaction(async (loadedStorage, persist) => { + const now = Date.now(); + const stored = replaceAll ? null : loadedStorage; + const accounts = stored?.accounts ? [...stored.accounts] : []; + + const indexByRefreshToken = new Map(); + const indexByAccountId = new Map(); + const indexByEmail = new Map(); + for (let i = 0; i < accounts.length; i += 1) { + const account = accounts[i]; + if (!account) continue; + if (account.refreshToken) { + indexByRefreshToken.set(account.refreshToken, i); + } + if (account.accountId) { + indexByAccountId.set(account.accountId, i); + } + const emailKey = normalizeEmailKey(account.email); + if (emailKey) { + indexByEmail.set(emailKey, i); + } + } - const existing = accounts[existingIndex]; - if (!existing) continue; - - const oldToken = existing.refreshToken; - const oldEmail = existing.email; - const nextEmail = accountEmail ?? sanitizeEmail(existing.email); - const nextAccountId = accountId ?? existing.accountId; - const nextAccountIdSource = - accountId ? accountIdSource ?? existing.accountIdSource : existing.accountIdSource; - const nextAccountLabel = accountLabel ?? existing.accountLabel; - accounts[existingIndex] = { - ...existing, - accountId: nextAccountId, - accountIdSource: nextAccountIdSource, - accountLabel: nextAccountLabel, - email: nextEmail, - refreshToken: result.refresh, - accessToken: result.access, - expiresAt: result.expires, - lastUsed: now, - }; - if (oldToken !== result.refresh) { - indexByRefreshToken.delete(oldToken); - indexByRefreshToken.set(result.refresh, existingIndex); - } - if (accountId) { - indexByAccountId.set(accountId, existingIndex); - } - const oldEmailKey = normalizeEmailKey(oldEmail); - const nextEmailKey = normalizeEmailKey(nextEmail); - if (oldEmailKey && oldEmailKey !== nextEmailKey) { - indexByEmail.delete(oldEmailKey); - } - if (nextEmailKey) { - indexByEmail.set(nextEmailKey, existingIndex); - } + for (const result of results) { + const accountId = + result.accountIdOverride ?? extractAccountId(result.access); + const accountIdSource = accountId + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) + : undefined; + const accountLabel = result.accountLabel; + const accountEmail = sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ); + const existingByEmail = + accountEmail && indexByEmail.has(accountEmail) + ? indexByEmail.get(accountEmail) + : undefined; + const existingById = + accountId && indexByAccountId.has(accountId) + ? indexByAccountId.get(accountId) + : undefined; + const existingByToken = indexByRefreshToken.get(result.refresh); + const existingIndex = + existingById ?? existingByEmail ?? existingByToken; + + if (existingIndex === undefined) { + const newIndex = accounts.length; + accounts.push({ + accountId, + accountIdSource, + accountLabel, + email: accountEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + addedAt: now, + lastUsed: now, + }); + indexByRefreshToken.set(result.refresh, newIndex); + if (accountId) { + indexByAccountId.set(accountId, newIndex); + } + if (accountEmail) { + indexByEmail.set(accountEmail, newIndex); } + continue; + } - if (accounts.length === 0) return; + const existing = accounts[existingIndex]; + if (!existing) continue; + + const oldToken = existing.refreshToken; + const oldEmail = existing.email; + const nextEmail = accountEmail ?? sanitizeEmail(existing.email); + const nextAccountId = accountId ?? existing.accountId; + const nextAccountIdSource = accountId + ? (accountIdSource ?? existing.accountIdSource) + : existing.accountIdSource; + const nextAccountLabel = accountLabel ?? existing.accountLabel; + accounts[existingIndex] = { + ...existing, + accountId: nextAccountId, + accountIdSource: nextAccountIdSource, + accountLabel: nextAccountLabel, + email: nextEmail, + refreshToken: result.refresh, + accessToken: result.access, + expiresAt: result.expires, + lastUsed: now, + }; + if (oldToken !== result.refresh) { + indexByRefreshToken.delete(oldToken); + indexByRefreshToken.set(result.refresh, existingIndex); + } + if (accountId) { + indexByAccountId.set(accountId, existingIndex); + } + const oldEmailKey = normalizeEmailKey(oldEmail); + const nextEmailKey = normalizeEmailKey(nextEmail); + if (oldEmailKey && oldEmailKey !== nextEmailKey) { + indexByEmail.delete(oldEmailKey); + } + if (nextEmailKey) { + indexByEmail.set(nextEmailKey, existingIndex); + } + } - const activeIndex = replaceAll - ? 0 - : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) - ? stored.activeIndex - : 0; + if (accounts.length === 0) return; - const clampedActiveIndex = Math.max(0, Math.min(activeIndex, accounts.length - 1)); - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; - const rawFamilyIndex = replaceAll - ? 0 - : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex) - ? storedFamilyIndex - : clampedActiveIndex; - activeIndexByFamily[family] = Math.max( - 0, - Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), - ); - } + const activeIndex = replaceAll + ? 0 + : typeof stored?.activeIndex === "number" && + Number.isFinite(stored.activeIndex) + ? stored.activeIndex + : 0; - await persist({ - version: 3, - accounts, - activeIndex: clampedActiveIndex, - activeIndexByFamily, - }); - }); - }; - - const showToast = async ( - message: string, - variant: "info" | "success" | "warning" | "error" = "success", - options?: { title?: string; duration?: number }, - ): Promise => { - try { - await client.tui.showToast({ - body: { - message, - variant, - ...(options?.title && { title: options.title }), - ...(options?.duration && { duration: options.duration }), - }, - }); - } catch { - // Ignore when TUI is not available. - } - }; - - const resolveActiveIndex = ( - storage: { - activeIndex: number; - activeIndexByFamily?: Partial>; - accounts: unknown[]; + const clampedActiveIndex = Math.max( + 0, + Math.min(activeIndex, accounts.length - 1), + ); + const activeIndexByFamily: Partial> = {}; + for (const family of MODEL_FAMILIES) { + const storedFamilyIndex = stored?.activeIndexByFamily?.[family]; + const rawFamilyIndex = replaceAll + ? 0 + : typeof storedFamilyIndex === "number" && + Number.isFinite(storedFamilyIndex) + ? storedFamilyIndex + : clampedActiveIndex; + activeIndexByFamily[family] = Math.max( + 0, + Math.min(Math.floor(rawFamilyIndex), accounts.length - 1), + ); + } + + await persist({ + version: 3, + accounts, + activeIndex: clampedActiveIndex, + activeIndexByFamily, + }); + }); + }; + + const showToast = async ( + message: string, + variant: "info" | "success" | "warning" | "error" = "success", + options?: { title?: string; duration?: number }, + ): Promise => { + try { + await client.tui.showToast({ + body: { + message, + variant, + ...(options?.title && { title: options.title }), + ...(options?.duration && { duration: options.duration }), }, - family: ModelFamily = "codex", - ): number => { - const total = storage.accounts.length; - if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; - const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; - return Math.max(0, Math.min(raw, total - 1)); - }; + }); + } catch { + // Ignore when TUI is not available. + } + }; + + const resolveActiveIndex = ( + storage: { + activeIndex: number; + activeIndexByFamily?: Partial>; + accounts: unknown[]; + }, + family: ModelFamily = "codex", + ): number => { + const total = storage.accounts.length; + if (total === 0) return 0; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; + return Math.max(0, Math.min(raw, total - 1)); + }; const hydrateEmails = async ( - storage: AccountStorageV3 | null, + storage: AccountStorageV3 | null, ): Promise => { - if (!storage) return storage; - const skipHydrate = - process.env.VITEST_WORKER_ID !== undefined || - process.env.NODE_ENV === "test" || - process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; - if (skipHydrate) return storage; - - const accountsCopy = storage.accounts.map((account) => - account ? { ...account } : account, - ); - const accountsToHydrate = accountsCopy.filter( - (account) => account && !account.email, - ); - if (accountsToHydrate.length === 0) return storage; - - let changed = false; - await Promise.all( - accountsToHydrate.map(async (account) => { - try { - const refreshed = await queuedRefresh(account.refreshToken); - if (refreshed.type !== "success") return; - const id = extractAccountId(refreshed.access); - const email = sanitizeEmail(extractAccountEmail(refreshed.access, refreshed.idToken)); - if ( - id && - id !== account.accountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) - ) { - account.accountId = id; - account.accountIdSource = "token"; - changed = true; - } - if (email && email !== account.email) { - account.email = email; - changed = true; - } + if (!storage) return storage; + const skipHydrate = + process.env.VITEST_WORKER_ID !== undefined || + process.env.NODE_ENV === "test" || + process.env.CODEX_SKIP_EMAIL_HYDRATE === "1"; + if (skipHydrate) return storage; + + const accountsCopy = storage.accounts.map((account) => + account ? { ...account } : account, + ); + const accountsToHydrate = accountsCopy.filter( + (account) => account && !account.email, + ); + if (accountsToHydrate.length === 0) return storage; + + let changed = false; + await Promise.all( + accountsToHydrate.map(async (account) => { + try { + const refreshed = await queuedRefresh(account.refreshToken); + if (refreshed.type !== "success") return; + const id = extractAccountId(refreshed.access); + const email = sanitizeEmail( + extractAccountEmail(refreshed.access, refreshed.idToken), + ); + if ( + id && + id !== account.accountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) + ) { + account.accountId = id; + account.accountIdSource = "token"; + changed = true; + } + if (email && email !== account.email) { + account.email = email; + changed = true; + } if (refreshed.access && refreshed.access !== account.accessToken) { account.accessToken = refreshed.access; changed = true; } - if (typeof refreshed.expires === "number" && refreshed.expires !== account.expiresAt) { + if ( + typeof refreshed.expires === "number" && + refreshed.expires !== account.expiresAt + ) { account.expiresAt = refreshed.expires; changed = true; } - if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { - account.refreshToken = refreshed.refresh; - changed = true; - } + if (refreshed.refresh && refreshed.refresh !== account.refreshToken) { + account.refreshToken = refreshed.refresh; + changed = true; + } } catch { logWarn(`[${PLUGIN_NAME}] Failed to hydrate email for account`); } - }), - ); - - if (changed) { - storage.accounts = accountsCopy; - await saveAccounts(storage); - } - return storage; - }; - - const getRateLimitResetTimeForFamily = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily, - ): number | null => { - const times = account.rateLimitResetTimes; - if (!times) return null; - - let minReset: number | null = null; - const prefix = `${family}:`; - for (const [key, value] of Object.entries(times)) { - if (typeof value !== "number") continue; - if (value <= now) continue; - if (key !== family && !key.startsWith(prefix)) continue; - if (minReset === null || value < minReset) { - minReset = value; - } - } + }), + ); - return minReset; - }; + if (changed) { + storage.accounts = accountsCopy; + await saveAccounts(storage); + } + return storage; + }; - const formatRateLimitEntry = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily = "codex", - ): string | null => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return null; - const remaining = resetAt - now; - if (remaining <= 0) return null; - return `resets in ${formatWaitTime(remaining)}`; - }; + const getRateLimitResetTimeForFamily = ( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily, + ): number | null => { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let minReset: number | null = null; + const prefix = `${family}:`; + for (const [key, value] of Object.entries(times)) { + if (typeof value !== "number") continue; + if (value <= now) continue; + if (key !== family && !key.startsWith(prefix)) continue; + if (minReset === null || value < minReset) { + minReset = value; + } + } - const applyUiRuntimeFromConfig = ( - pluginConfig: ReturnType, - ): UiRuntimeOptions => { - return setUiRuntimeOptions({ - v2Enabled: getCodexTuiV2(pluginConfig), - colorProfile: getCodexTuiColorProfile(pluginConfig), - glyphMode: getCodexTuiGlyphMode(pluginConfig), - }); - }; + return minReset; + }; - const resolveUiRuntime = (): UiRuntimeOptions => { - return applyUiRuntimeFromConfig(loadPluginConfig()); - }; + const formatRateLimitEntry = ( + account: { rateLimitResetTimes?: Record }, + now: number, + family: ModelFamily = "codex", + ): string | null => { + const resetAt = getRateLimitResetTimeForFamily(account, now, family); + if (typeof resetAt !== "number") return null; + const remaining = resetAt - now; + if (remaining <= 0) return null; + return `resets in ${formatWaitTime(remaining)}`; + }; - const getStatusMarker = ( - ui: UiRuntimeOptions, - status: "ok" | "warning" | "error", - ): string => { - if (!ui.v2Enabled) { - if (status === "ok") return "✓"; - if (status === "warning") return "!"; - return "✗"; - } - if (status === "ok") return ui.theme.glyphs.check; + const applyUiRuntimeFromConfig = ( + pluginConfig: ReturnType, + ): UiRuntimeOptions => { + return setUiRuntimeOptions({ + v2Enabled: getCodexTuiV2(pluginConfig), + colorProfile: getCodexTuiColorProfile(pluginConfig), + glyphMode: getCodexTuiGlyphMode(pluginConfig), + }); + }; + + const resolveUiRuntime = (): UiRuntimeOptions => { + return applyUiRuntimeFromConfig(loadPluginConfig()); + }; + + const getStatusMarker = ( + ui: UiRuntimeOptions, + status: "ok" | "warning" | "error", + ): string => { + if (!ui.v2Enabled) { + if (status === "ok") return "✓"; if (status === "warning") return "!"; - return ui.theme.glyphs.cross; - }; + return "✗"; + } + if (status === "ok") return ui.theme.glyphs.check; + if (status === "warning") return "!"; + return ui.theme.glyphs.cross; + }; - const invalidateAccountManagerCache = (): void => { - cachedAccountManager = null; - accountManagerPromise = null; - }; + const invalidateAccountManagerCache = (): void => { + cachedAccountManager = null; + accountManagerPromise = null; + }; - const reloadAccountManagerFromDisk = async ( - authFallback?: OAuthAuthDetails, - ): Promise => { - if (accountReloadInFlight) { - return accountReloadInFlight; - } - accountReloadInFlight = (async () => { - const reloaded = await AccountManager.loadFromDisk(authFallback); - cachedAccountManager = reloaded; - accountManagerPromise = Promise.resolve(reloaded); - return reloaded; - })(); - try { - return await accountReloadInFlight; - } finally { - accountReloadInFlight = null; - } - }; + const reloadAccountManagerFromDisk = async ( + authFallback?: OAuthAuthDetails, + ): Promise => { + if (accountReloadInFlight) { + return accountReloadInFlight; + } + accountReloadInFlight = (async () => { + const reloaded = await AccountManager.loadFromDisk(authFallback); + cachedAccountManager = reloaded; + accountManagerPromise = Promise.resolve(reloaded); + return reloaded; + })(); + try { + return await accountReloadInFlight; + } finally { + accountReloadInFlight = null; + } + }; - const applyAccountStorageScope = (pluginConfig: ReturnType): void => { - const perProjectAccounts = getPerProjectAccounts(pluginConfig); - setStorageBackupEnabled(getStorageBackupEnabled(pluginConfig)); - if (isCodexCliSyncEnabled()) { - if (perProjectAccounts && !perProjectStorageWarningShown) { - perProjectStorageWarningShown = true; - logWarn( - `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, - ); - } - setStoragePath(null); - return; + const applyAccountStorageScope = ( + pluginConfig: ReturnType, + ): void => { + const perProjectAccounts = getPerProjectAccounts(pluginConfig); + setStorageBackupEnabled(getStorageBackupEnabled(pluginConfig)); + if (isCodexCliSyncEnabled()) { + if (perProjectAccounts && !perProjectStorageWarningShown) { + perProjectStorageWarningShown = true; + logWarn( + `[${PLUGIN_NAME}] CODEX_AUTH_PER_PROJECT_ACCOUNTS is ignored while Codex CLI sync is enabled. Using global account storage.`, + ); } + setStoragePath(null); + return; + } - setStoragePath(perProjectAccounts ? process.cwd() : null); - }; + setStoragePath(perProjectAccounts ? process.cwd() : null); + }; - const ensureLiveAccountSync = async ( - pluginConfig: ReturnType, - authFallback?: OAuthAuthDetails, - ): Promise => { - if (!getLiveAccountSync(pluginConfig)) { - if (liveAccountSync) { - liveAccountSync.stop(); - liveAccountSync = null; - liveAccountSyncPath = null; - } - return; + const ensureLiveAccountSync = async ( + pluginConfig: ReturnType, + authFallback?: OAuthAuthDetails, + ): Promise => { + if (!getLiveAccountSync(pluginConfig)) { + if (liveAccountSync) { + liveAccountSync.stop(); + liveAccountSync = null; + liveAccountSyncPath = null; } + return; + } - const targetPath = getStoragePath(); - if (!liveAccountSync) { - liveAccountSync = new LiveAccountSync( - async () => { - await reloadAccountManagerFromDisk(authFallback); - }, - { - debounceMs: getLiveAccountSyncDebounceMs(pluginConfig), - pollIntervalMs: getLiveAccountSyncPollMs(pluginConfig), - }, - ); - registerCleanup(() => { - liveAccountSync?.stop(); - }); - } + const targetPath = getStoragePath(); + if (!liveAccountSync) { + liveAccountSync = new LiveAccountSync( + async () => { + await reloadAccountManagerFromDisk(authFallback); + }, + { + debounceMs: getLiveAccountSyncDebounceMs(pluginConfig), + pollIntervalMs: getLiveAccountSyncPollMs(pluginConfig), + }, + ); + registerCleanup(() => { + liveAccountSync?.stop(); + }); + } if (liveAccountSyncPath !== targetPath) { let switched = false; @@ -900,7 +951,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (code !== "EBUSY" && code !== "EPERM") { throw error; } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + await new Promise((resolve) => + setTimeout(resolve, 25 * 2 ** attempt), + ); } } if (!switched) { @@ -911,164 +964,180 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; - const ensureRefreshGuardian = ( - pluginConfig: ReturnType, - ): void => { - if (!getProactiveRefreshGuardian(pluginConfig)) { - if (refreshGuardian) { - refreshGuardian.stop(); - refreshGuardian = null; - refreshGuardianConfigKey = null; - } - return; - } - - const intervalMs = getProactiveRefreshIntervalMs(pluginConfig); - const bufferMs = getProactiveRefreshBufferMs(pluginConfig); - const configKey = `${intervalMs}:${bufferMs}`; - if (refreshGuardian && refreshGuardianConfigKey === configKey) return; - + const ensureRefreshGuardian = ( + pluginConfig: ReturnType, + ): void => { + if (!getProactiveRefreshGuardian(pluginConfig)) { if (refreshGuardian) { refreshGuardian.stop(); + refreshGuardian = null; + refreshGuardianConfigKey = null; } - refreshGuardian = new RefreshGuardian( - () => cachedAccountManager, - { intervalMs, bufferMs }, - ); - refreshGuardianConfigKey = configKey; - refreshGuardian.start(); - registerCleanup(() => { - refreshGuardian?.stop(); - }); - }; + return; + } - const ensureSessionAffinity = ( - pluginConfig: ReturnType, - ): void => { - if (!getSessionAffinity(pluginConfig)) { - sessionAffinityStore = null; - sessionAffinityConfigKey = null; - return; - } + const intervalMs = getProactiveRefreshIntervalMs(pluginConfig); + const bufferMs = getProactiveRefreshBufferMs(pluginConfig); + const configKey = `${intervalMs}:${bufferMs}`; + if (refreshGuardian && refreshGuardianConfigKey === configKey) return; - const ttlMs = getSessionAffinityTtlMs(pluginConfig); - const maxEntries = getSessionAffinityMaxEntries(pluginConfig); - const configKey = `${ttlMs}:${maxEntries}`; - if (sessionAffinityStore && sessionAffinityConfigKey === configKey) return; - sessionAffinityStore = new SessionAffinityStore({ ttlMs, maxEntries }); - sessionAffinityConfigKey = configKey; - }; + if (refreshGuardian) { + refreshGuardian.stop(); + } + refreshGuardian = new RefreshGuardian(() => cachedAccountManager, { + intervalMs, + bufferMs, + }); + refreshGuardianConfigKey = configKey; + refreshGuardian.start(); + registerCleanup(() => { + refreshGuardian?.stop(); + }); + }; - const applyPreemptiveQuotaSettings = ( - pluginConfig: ReturnType, - ): void => { - preemptiveQuotaScheduler.configure({ - enabled: getPreemptiveQuotaEnabled(pluginConfig), - remainingPercentThresholdPrimary: getPreemptiveQuotaRemainingPercent5h(pluginConfig), - remainingPercentThresholdSecondary: getPreemptiveQuotaRemainingPercent7d(pluginConfig), - maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), - }); - }; + const ensureSessionAffinity = ( + pluginConfig: ReturnType, + ): void => { + if (!getSessionAffinity(pluginConfig)) { + sessionAffinityStore = null; + sessionAffinityConfigKey = null; + return; + } - // Event handler for session recovery and account selection - const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { - try { - const { event } = input; - // Handle TUI account selection events - // Accepts generic selection events with an index property - if ( - event.type === "account.select" || - event.type === "openai.account.select" - ) { - const props = event.properties as { index?: number; accountIndex?: number; provider?: string }; - // Filter by provider if specified - if (props.provider && props.provider !== "openai" && props.provider !== PROVIDER_ID) { - return; - } - - const index = props.index ?? props.accountIndex; - if (typeof index === "number") { - const storage = await loadAccounts(); - if (!storage || index < 0 || index >= storage.accounts.length) { - return; - } - - const now = Date.now(); - const account = storage.accounts[index]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } - - await saveAccounts(storage); - if (cachedAccountManager) { - await cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); - } - lastCodexCliActiveSyncIndex = index; + const ttlMs = getSessionAffinityTtlMs(pluginConfig); + const maxEntries = getSessionAffinityMaxEntries(pluginConfig); + const configKey = `${ttlMs}:${maxEntries}`; + if (sessionAffinityStore && sessionAffinityConfigKey === configKey) return; + sessionAffinityStore = new SessionAffinityStore({ ttlMs, maxEntries }); + sessionAffinityConfigKey = configKey; + }; + + const applyPreemptiveQuotaSettings = ( + pluginConfig: ReturnType, + ): void => { + preemptiveQuotaScheduler.configure({ + enabled: getPreemptiveQuotaEnabled(pluginConfig), + remainingPercentThresholdPrimary: + getPreemptiveQuotaRemainingPercent5h(pluginConfig), + remainingPercentThresholdSecondary: + getPreemptiveQuotaRemainingPercent7d(pluginConfig), + maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig), + }); + }; - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state. - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); + // Event handler for session recovery and account selection + const eventHandler = async (input: { + event: { type: string; properties?: unknown }; + }) => { + try { + const { event } = input; + // Handle TUI account selection events + // Accepts generic selection events with an index property + if ( + event.type === "account.select" || + event.type === "openai.account.select" + ) { + const props = event.properties as { + index?: number; + accountIndex?: number; + provider?: string; + }; + // Filter by provider if specified + if ( + props.provider && + props.provider !== "openai" && + props.provider !== PROVIDER_ID + ) { + return; } - await showToast(`Switched to account ${index + 1}`, "info"); - } - } - } catch (error) { - logDebug(`[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`); - } - }; + const index = props.index ?? props.accountIndex; + if (typeof index === "number") { + const storage = await loadAccounts(); + if (!storage || index < 0 || index >= storage.accounts.length) { + return; + } + + const now = Date.now(); + const account = storage.accounts[index]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + storage.activeIndexByFamily[family] = index; + } + + await saveAccounts(storage); + if (cachedAccountManager) { + await cachedAccountManager.syncCodexCliActiveSelectionForIndex( + index, + ); + } + lastCodexCliActiveSyncIndex = index; + + // Reload manager from disk so we don't overwrite newer rotated + // refresh tokens with stale in-memory state. + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } + + await showToast(`Switched to account ${index + 1}`, "info"); + } + } + } catch (error) { + logDebug( + `[${PLUGIN_NAME}] Event handler error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; - // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. - resolveUiRuntime(); + // Initialize runtime UI settings once on plugin load; auth/tools refresh this dynamically. + resolveUiRuntime(); - return { - event: eventHandler, - auth: { + return { + event: eventHandler, + auth: { provider: PROVIDER_ID, /** * Loader function that configures OAuth authentication and request handling * * This function: - * 1. Validates OAuth authentication - * 2. Loads multi-account pool from disk (fallback to current auth) - * 3. Loads user configuration from runtime model config - * 4. Fetches Codex system instructions from GitHub (cached) - * 5. Returns SDK configuration with custom fetch implementation + * 1. Validates OAuth authentication + * 2. Loads multi-account pool from disk (fallback to current auth) + * 3. Loads user configuration from runtime model config + * 4. Fetches Codex system instructions from GitHub (cached) + * 5. Returns SDK configuration with custom fetch implementation * * @param getAuth - Function to retrieve current auth state * @param provider - Provider configuration from runtime model config * @returns SDK configuration object or empty object for non-OAuth auth */ - async loader(getAuth: () => Promise, provider: unknown) { - const auth = await getAuth(); - const pluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(pluginConfig); - applyAccountStorageScope(pluginConfig); - ensureSessionAffinity(pluginConfig); - ensureRefreshGuardian(pluginConfig); - applyPreemptiveQuotaSettings(pluginConfig); - - // Only handle OAuth auth type, skip API key auth - if (auth.type !== "oauth") { - return {}; - } + async loader(getAuth: () => Promise, provider: unknown) { + const auth = await getAuth(); + const pluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(pluginConfig); + applyAccountStorageScope(pluginConfig); + ensureSessionAffinity(pluginConfig); + ensureRefreshGuardian(pluginConfig); + applyPreemptiveQuotaSettings(pluginConfig); + + // Only handle OAuth auth type, skip API key auth + if (auth.type !== "oauth") { + return {}; + } - // Prefer multi-account auth metadata when available, but still handle - // plain OAuth credentials (for legacy runtime versions that inject internal - // Codex auth first and omit the multiAccount marker). - const authWithMulti = auth as typeof auth & { multiAccount?: boolean }; - if (!authWithMulti.multiAccount) { - logDebug( - `[${PLUGIN_NAME}] Auth is missing multiAccount marker; continuing with single-account compatibility mode`, - ); - } + // Prefer multi-account auth metadata when available, but still handle + // plain OAuth credentials (for legacy runtime versions that inject internal + // Codex auth first and omit the multiAccount marker). + const authWithMulti = auth as typeof auth & { multiAccount?: boolean }; + if (!authWithMulti.multiAccount) { + logDebug( + `[${PLUGIN_NAME}] Auth is missing multiAccount marker; continuing with single-account compatibility mode`, + ); + } // Acquire mutex for thread-safe initialization // Use while loop to handle multiple concurrent waiters correctly while (loaderMutex) { @@ -1089,11 +1158,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { reloadAccountManagerFromDisk(auth as OAuthAuthDetails); let accountManager = await managerPromise; cachedAccountManager = accountManager; - const refreshToken = - auth.type === "oauth" ? auth.refresh : ""; + const refreshToken = auth.type === "oauth" ? auth.refresh : ""; const needsPersist = - refreshToken && - !accountManager.hasRefreshToken(refreshToken); + refreshToken && !accountManager.hasRefreshToken(refreshToken); if (needsPersist) { await accountManager.saveToDisk(); } @@ -1104,138 +1171,161 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); return {}; } - // Extract user configuration (global + per-model options) - const providerConfig = provider as - | { options?: Record; models?: UserConfig["models"] } - | undefined; - const userConfig: UserConfig = { - global: providerConfig?.options || {}, - models: providerConfig?.models || {}, - }; - - // Load plugin configuration and determine CODEX_MODE - // Priority: CODEX_MODE env var > config file > default (true) - const codexMode = getCodexMode(pluginConfig); - const fastSessionEnabled = getFastSession(pluginConfig); - const fastSessionStrategy = getFastSessionStrategy(pluginConfig); - const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig); - const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); - const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig); - const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig); - const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig); - const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig); - const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig); - const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback"; - const fallbackToGpt52OnUnsupportedGpt53 = - getFallbackToGpt52OnUnsupportedGpt53(pluginConfig); - const unsupportedCodexFallbackChain = - getUnsupportedCodexFallbackChain(pluginConfig); - const toastDurationMs = getToastDurationMs(pluginConfig); - const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); - const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); - const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig); - const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); - const failoverMode = parseFailoverMode(process.env.CODEX_AUTH_FAILOVER_MODE); - const streamFailoverMax = Math.max( - 0, - parseEnvInt(process.env.CODEX_AUTH_STREAM_FAILOVER_MAX) ?? - STREAM_FAILOVER_MAX_BY_MODE[failoverMode], - ); - const streamFailoverSoftTimeoutMs = Math.max( - 1_000, - parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_SOFT_TIMEOUT_MS) ?? - STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE[failoverMode], - ); - const streamFailoverHardTimeoutMs = Math.max( - streamFailoverSoftTimeoutMs, - parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_HARD_TIMEOUT_MS) ?? - streamStallTimeoutMs, - ); - const maxSameAccountRetries = - failoverMode === "conservative" ? 2 : failoverMode === "balanced" ? 1 : 0; - - const sessionRecoveryEnabled = getSessionRecovery(pluginConfig); - const autoResumeEnabled = getAutoResume(pluginConfig); - const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig); - const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig); - const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig); - const effectiveUserConfig = fastSessionEnabled - ? applyFastSessionDefaults(userConfig) - : userConfig; - if (fastSessionEnabled) { - logDebug("Fast session mode enabled", { - reasoningEffort: "none/low", - reasoningSummary: "auto", - textVerbosity: "low", - fastSessionStrategy, - fastSessionMaxInputItems, - }); - } + // Extract user configuration (global + per-model options) + const providerConfig = provider as + | { + options?: Record; + models?: UserConfig["models"]; + } + | undefined; + const userConfig: UserConfig = { + global: providerConfig?.options || {}, + models: providerConfig?.models || {}, + }; - const prewarmEnabled = - process.env.CODEX_AUTH_PREWARM !== "0" && - process.env.VITEST !== "true" && - process.env.NODE_ENV !== "test"; - - if (!startupPrewarmTriggered && prewarmEnabled) { - startupPrewarmTriggered = true; - const configuredModels = Object.keys(userConfig.models ?? {}); - prewarmCodexInstructions(configuredModels); - if (codexMode) { - prewarmHostCodexPrompt(); + // Load plugin configuration and determine CODEX_MODE + // Priority: CODEX_MODE env var > config file > default (true) + const codexMode = getCodexMode(pluginConfig); + const fastSessionEnabled = getFastSession(pluginConfig); + const fastSessionStrategy = getFastSessionStrategy(pluginConfig); + const fastSessionMaxInputItems = + getFastSessionMaxInputItems(pluginConfig); + const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig); + const rateLimitToastDebounceMs = + getRateLimitToastDebounceMs(pluginConfig); + const retryAllAccountsRateLimited = + getRetryAllAccountsRateLimited(pluginConfig); + const retryAllAccountsMaxWaitMs = + getRetryAllAccountsMaxWaitMs(pluginConfig); + const retryAllAccountsMaxRetries = + getRetryAllAccountsMaxRetries(pluginConfig); + const unsupportedCodexPolicy = + getUnsupportedCodexPolicy(pluginConfig); + const fallbackOnUnsupportedCodexModel = + unsupportedCodexPolicy === "fallback"; + const fallbackToGpt52OnUnsupportedGpt53 = + getFallbackToGpt52OnUnsupportedGpt53(pluginConfig); + const unsupportedCodexFallbackChain = + getUnsupportedCodexFallbackChain(pluginConfig); + const toastDurationMs = getToastDurationMs(pluginConfig); + const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig); + const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig); + const networkErrorCooldownMs = + getNetworkErrorCooldownMs(pluginConfig); + const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig); + const failoverMode = parseFailoverMode( + process.env.CODEX_AUTH_FAILOVER_MODE, + ); + const streamFailoverMax = Math.max( + 0, + parseEnvInt(process.env.CODEX_AUTH_STREAM_FAILOVER_MAX) ?? + STREAM_FAILOVER_MAX_BY_MODE[failoverMode], + ); + const streamFailoverSoftTimeoutMs = Math.max( + 1_000, + parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_SOFT_TIMEOUT_MS) ?? + STREAM_FAILOVER_SOFT_TIMEOUT_BY_MODE[failoverMode], + ); + const streamFailoverHardTimeoutMs = Math.max( + streamFailoverSoftTimeoutMs, + parseEnvInt(process.env.CODEX_AUTH_STREAM_STALL_HARD_TIMEOUT_MS) ?? + streamStallTimeoutMs, + ); + const maxSameAccountRetries = + failoverMode === "conservative" + ? 2 + : failoverMode === "balanced" + ? 1 + : 0; + + const sessionRecoveryEnabled = getSessionRecovery(pluginConfig); + const autoResumeEnabled = getAutoResume(pluginConfig); + const emptyResponseMaxRetries = + getEmptyResponseMaxRetries(pluginConfig); + const emptyResponseRetryDelayMs = + getEmptyResponseRetryDelayMs(pluginConfig); + const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig); + const effectiveUserConfig = fastSessionEnabled + ? applyFastSessionDefaults(userConfig) + : userConfig; + if (fastSessionEnabled) { + logDebug("Fast session mode enabled", { + reasoningEffort: "none/low", + reasoningSummary: "auto", + textVerbosity: "low", + fastSessionStrategy, + fastSessionMaxInputItems, + }); } - } - const recoveryHook = sessionRecoveryEnabled - ? createSessionRecoveryHook( - { client, directory: process.cwd() }, - { sessionRecovery: true, autoResume: autoResumeEnabled } - ) - : null; + const prewarmEnabled = + process.env.CODEX_AUTH_PREWARM !== "0" && + process.env.VITEST !== "true" && + process.env.NODE_ENV !== "test"; + + if (!startupPrewarmTriggered && prewarmEnabled) { + startupPrewarmTriggered = true; + const configuredModels = Object.keys(userConfig.models ?? {}); + prewarmCodexInstructions(configuredModels); + if (codexMode) { + prewarmHostCodexPrompt(); + } + } - checkAndNotify(async (message, variant) => { - await showToast(message, variant); - }).catch((err) => { - logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); - }); + const recoveryHook = sessionRecoveryEnabled + ? createSessionRecoveryHook( + { client, directory: process.cwd() }, + { sessionRecovery: true, autoResume: autoResumeEnabled }, + ) + : null; + checkAndNotify(async (message, variant) => { + await showToast(message, variant); + }).catch((err) => { + logDebug( + `Update check failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); - // Return SDK configuration - return { - apiKey: DUMMY_API_KEY, - baseURL: CODEX_BASE_URL, - /** - * Custom fetch implementation for Codex API - * - * Handles: - * - Token refresh when expired - * - URL rewriting for Codex backend - * - Request body transformation - * - OAuth header injection - * - SSE to JSON conversion for non-tool requests - * - Error handling and logging - * - * @param input - Request URL or Request object - * @param init - Request options - * @returns Response from Codex API - */ - async fetch( - input: Request | string | URL, - init?: RequestInit, - ): Promise { - try { - if (cachedAccountManager && cachedAccountManager !== accountManager) { - accountManager = cachedAccountManager; - } + // Return SDK configuration + return { + apiKey: DUMMY_API_KEY, + baseURL: CODEX_BASE_URL, + /** + * Custom fetch implementation for Codex API + * + * Handles: + * - Token refresh when expired + * - URL rewriting for Codex backend + * - Request body transformation + * - OAuth header injection + * - SSE to JSON conversion for non-tool requests + * - Error handling and logging + * + * @param input - Request URL or Request object + * @param init - Request options + * @returns Response from Codex API + */ + async fetch( + input: Request | string | URL, + init?: RequestInit, + ): Promise { + try { + if ( + cachedAccountManager && + cachedAccountManager !== accountManager + ) { + accountManager = cachedAccountManager; + } - // Step 1: Extract and rewrite URL for Codex backend - const originalUrl = extractRequestUrl(input); - const url = rewriteUrlForCodex(originalUrl); + // Step 1: Extract and rewrite URL for Codex backend + const originalUrl = extractRequestUrl(input); + const url = rewriteUrlForCodex(originalUrl); - // Step 3: Transform request body with model-specific Codex instructions - // Instructions are fetched per model family (codex-max, codex, gpt-5.1) - // Capture original stream value before transformation - // generateText() sends no stream field, streamText() sends stream=true + // Step 3: Transform request body with model-specific Codex instructions + // Instructions are fetched per model family (codex-max, codex, gpt-5.1) + // Capture original stream value before transformation + // generateText() sends no stream field, streamText() sends stream=true const normalizeRequestInit = async ( requestInput: Request | string | URL, requestInit: RequestInit | undefined, @@ -1274,11 +1364,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } if (body instanceof Uint8Array) { - return JSON.parse(new TextDecoder().decode(body)) as Record; + return JSON.parse( + new TextDecoder().decode(body), + ) as Record; } if (body instanceof ArrayBuffer) { - return JSON.parse(new TextDecoder().decode(new Uint8Array(body))) as Record; + return JSON.parse( + new TextDecoder().decode(new Uint8Array(body)), + ) as Record; } if (ArrayBuffer.isView(body)) { @@ -1287,11 +1381,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { body.byteOffset, body.byteLength, ); - return JSON.parse(new TextDecoder().decode(view)) as Record; + return JSON.parse( + new TextDecoder().decode(view), + ) as Record; } if (typeof Blob !== "undefined" && body instanceof Blob) { - return JSON.parse(await body.text()) as Record; + return JSON.parse(await body.text()) as Record< + string, + unknown + >; } } catch { logWarn("Failed to parse request body, using empty object"); @@ -1301,10 +1400,14 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const baseInit = await normalizeRequestInit(input, init); - const originalBody = await parseRequestBodyFromInit(baseInit?.body); + const originalBody = await parseRequestBodyFromInit( + baseInit?.body, + ); const isStreaming = originalBody.stream === true; const parsedBody = - Object.keys(originalBody).length > 0 ? originalBody : undefined; + Object.keys(originalBody).length > 0 + ? originalBody + : undefined; const transformation = await transformRequestForCodex( baseInit, @@ -1318,1564 +1421,1864 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { fastSessionMaxInputItems, }, ); - let requestInit = transformation?.updatedInit ?? baseInit; - let transformedBody: RequestBody | undefined = transformation?.body; - const promptCacheKey = transformedBody?.prompt_cache_key; - let model = transformedBody?.model; - let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; - let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; - const threadIdCandidate = - (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") - .toString() - .trim() || undefined; - const sessionAffinityKey = threadIdCandidate ?? promptCacheKey ?? null; - const effectivePromptCacheKey = - (sessionAffinityKey ?? promptCacheKey ?? "").toString().trim() || undefined; - const preferredSessionAccountIndex = sessionAffinityStore?.getPreferredAccountIndex( - sessionAffinityKey, - ); - sessionAffinityStore?.prune(); - const requestCorrelationId = setCorrelationId( - threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined, - ); - runtimeMetrics.lastRequestAt = Date.now(); + let requestInit = transformation?.updatedInit ?? baseInit; + let transformedBody: RequestBody | undefined = + transformation?.body; + const promptCacheKey = transformedBody?.prompt_cache_key; + let model = transformedBody?.model; + let modelFamily = model ? getModelFamily(model) : "gpt-5.1"; + let quotaKey = model ? `${modelFamily}:${model}` : modelFamily; + const threadIdCandidate = + (process.env.CODEX_THREAD_ID ?? promptCacheKey ?? "") + .toString() + .trim() || undefined; + const sessionAffinityKey = + threadIdCandidate ?? promptCacheKey ?? null; + const effectivePromptCacheKey = + (sessionAffinityKey ?? promptCacheKey ?? "") + .toString() + .trim() || undefined; + const preferredSessionAccountIndex = + sessionAffinityStore?.getPreferredAccountIndex( + sessionAffinityKey, + ); + sessionAffinityStore?.prune(); + const requestCorrelationId = setCorrelationId( + threadIdCandidate + ? `${threadIdCandidate}:${Date.now()}` + : undefined, + ); + runtimeMetrics.lastRequestAt = Date.now(); - const abortSignal = requestInit?.signal ?? init?.signal ?? null; - const sleep = (ms: number): Promise => - new Promise((resolve, reject) => { - if (abortSignal?.aborted) { - reject(new Error("Aborted")); - return; - } + const abortSignal = requestInit?.signal ?? init?.signal ?? null; + const sleep = (ms: number): Promise => + new Promise((resolve, reject) => { + if (abortSignal?.aborted) { + reject(new Error("Aborted")); + return; + } - const timeout = setTimeout(() => { - cleanup(); - resolve(); - }, ms); + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, ms); - const onAbort = () => { - cleanup(); - reject(new Error("Aborted")); - }; + const onAbort = () => { + cleanup(); + reject(new Error("Aborted")); + }; - const cleanup = () => { - clearTimeout(timeout); - abortSignal?.removeEventListener("abort", onAbort); - }; + const cleanup = () => { + clearTimeout(timeout); + abortSignal?.removeEventListener("abort", onAbort); + }; - abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); + abortSignal?.addEventListener("abort", onAbort, { + once: true, + }); + }); - const sleepWithCountdown = async ( - totalMs: number, - message: string, - intervalMs: number = 5000, - ): Promise => { - const startTime = Date.now(); - const endTime = startTime + totalMs; - - while (Date.now() < endTime) { - if (abortSignal?.aborted) { - throw new Error("Aborted"); - } - - const remaining = Math.max(0, endTime - Date.now()); - const waitLabel = formatWaitTime(remaining); - await showToast( - `${message} (${waitLabel} remaining)`, - "warning", - { duration: Math.min(intervalMs + 1000, toastDurationMs) }, - ); - - const sleepTime = Math.min(intervalMs, remaining); - if (sleepTime > 0) { - await sleep(sleepTime); - } else { - break; - } - } - }; + const sleepWithCountdown = async ( + totalMs: number, + message: string, + intervalMs: number = 5000, + ): Promise => { + const startTime = Date.now(); + const endTime = startTime + totalMs; - let allRateLimitedRetries = 0; - let emptyResponseRetries = 0; - const attemptedUnsupportedFallbackModels = new Set(); - if (model) { - attemptedUnsupportedFallbackModels.add(model); - } + while (Date.now() < endTime) { + if (abortSignal?.aborted) { + throw new Error("Aborted"); + } - while (true) { - const accountCount = accountManager.getAccountCount(); - const attempted = new Set(); - let restartAccountTraversalWithFallback = false; - let usedPreferredSessionAccount = false; - const capabilityBoostByAccount: Record = {}; - type AccountSnapshotCandidate = { - index: number; - accountId?: string; - email?: string; - }; - const accountSnapshotSource = accountManager as { - getAccountsSnapshot?: () => AccountSnapshotCandidate[]; - getAccountByIndex?: (index: number) => AccountSnapshotCandidate | null; - }; - const accountSnapshotList = - typeof accountSnapshotSource.getAccountsSnapshot === "function" - ? accountSnapshotSource.getAccountsSnapshot() ?? [] - : []; - if ( - accountSnapshotList.length === 0 && - typeof accountSnapshotSource.getAccountByIndex === "function" + const remaining = Math.max(0, endTime - Date.now()); + const waitLabel = formatWaitTime(remaining); + await showToast( + `${message} (${waitLabel} remaining)`, + "warning", + { + duration: Math.min(intervalMs + 1000, toastDurationMs), + }, + ); + + const sleepTime = Math.min(intervalMs, remaining); + if (sleepTime > 0) { + await sleep(sleepTime); + } else { + break; + } + } + }; + + let allRateLimitedRetries = 0; + let emptyResponseRetries = 0; + const attemptedUnsupportedFallbackModels = new Set(); + if (model) { + attemptedUnsupportedFallbackModels.add(model); + } + + while (true) { + const accountCount = accountManager.getAccountCount(); + const attempted = new Set(); + let restartAccountTraversalWithFallback = false; + let usedPreferredSessionAccount = false; + const capabilityBoostByAccount: Record = {}; + type AccountSnapshotCandidate = { + index: number; + accountId?: string; + email?: string; + }; + const accountSnapshotSource = accountManager as { + getAccountsSnapshot?: () => AccountSnapshotCandidate[]; + getAccountByIndex?: ( + index: number, + ) => AccountSnapshotCandidate | null; + }; + const accountSnapshotList = + typeof accountSnapshotSource.getAccountsSnapshot === + "function" + ? (accountSnapshotSource.getAccountsSnapshot() ?? []) + : []; + if ( + accountSnapshotList.length === 0 && + typeof accountSnapshotSource.getAccountByIndex === + "function" + ) { + for ( + let accountSnapshotIndex = 0; + accountSnapshotIndex < accountCount; + accountSnapshotIndex += 1 ) { - for ( - let accountSnapshotIndex = 0; - accountSnapshotIndex < accountCount; - accountSnapshotIndex += 1 - ) { - const candidate = accountSnapshotSource.getAccountByIndex( + const candidate = + accountSnapshotSource.getAccountByIndex( accountSnapshotIndex, ); - if (candidate) { - accountSnapshotList.push(candidate); - } + if (candidate) { + accountSnapshotList.push(candidate); } } - for (const candidate of accountSnapshotList) { - const accountKey = resolveEntitlementAccountKey(candidate); - capabilityBoostByAccount[candidate.index] = capabilityPolicyStore.getBoost( + } + for (const candidate of accountSnapshotList) { + const accountKey = resolveEntitlementAccountKey(candidate); + capabilityBoostByAccount[candidate.index] = + capabilityPolicyStore.getBoost( accountKey, model ?? modelFamily, ); - } + } -while (attempted.size < Math.max(1, accountCount)) { - let account = null; - if ( - !usedPreferredSessionAccount && - typeof preferredSessionAccountIndex === "number" - ) { - usedPreferredSessionAccount = true; - if ( - accountManager.isAccountAvailableForFamily( - preferredSessionAccountIndex, - modelFamily, - model, - ) - ) { - account = accountManager.getAccountByIndex(preferredSessionAccountIndex); - if (account) { - account.lastUsed = Date.now(); - accountManager.markSwitched(account, "rotation", modelFamily); - } - } else { - sessionAffinityStore?.forgetSession(sessionAffinityKey); - } - } + while (attempted.size < Math.max(1, accountCount)) { + let account = null; + if ( + !usedPreferredSessionAccount && + typeof preferredSessionAccountIndex === "number" + ) { + usedPreferredSessionAccount = true; + if ( + accountManager.isAccountAvailableForFamily( + preferredSessionAccountIndex, + modelFamily, + model, + ) + ) { + account = accountManager.getAccountByIndex( + preferredSessionAccountIndex, + ); + if (account) { + account.lastUsed = Date.now(); + accountManager.markSwitched( + account, + "rotation", + modelFamily, + ); + } + } else { + sessionAffinityStore?.forgetSession(sessionAffinityKey); + } + } - if (!account) { - account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, { - pidOffsetEnabled, - scoreBoostByAccount: capabilityBoostByAccount, - }); - } - if (!account || attempted.has(account.index)) { - break; - } - attempted.add(account.index); - // Log account selection for debugging rotation - logDebug( - `Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`, - ); + if (!account) { + account = accountManager.getCurrentOrNextForFamilyHybrid( + modelFamily, + model, + { + pidOffsetEnabled, + scoreBoostByAccount: capabilityBoostByAccount, + }, + ); + } + if (!account || attempted.has(account.index)) { + break; + } + attempted.add(account.index); + // Log account selection for debugging rotation + logDebug( + `Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`, + ); - let accountAuth = accountManager.toAuthDetails(account) as OAuthAuthDetails; - try { - if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) { - accountAuth = (await refreshAndUpdateToken( - accountAuth, - client, - )) as OAuthAuthDetails; - accountManager.updateFromAuth(account, accountAuth); - accountManager.clearAuthFailures(account); - accountManager.saveToDiskDebounced(); - } - } catch (err) { - logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`); - runtimeMetrics.authRefreshFailures++; - runtimeMetrics.failedRequests++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = (err as Error)?.message ?? String(err); - const failures = accountManager.incrementAuthFailures(account); - const accountLabel = formatAccountLabel(account, account.index); - - const authFailurePolicy = evaluateFailurePolicy({ - kind: "auth-refresh", - consecutiveAuthFailures: failures, - }); - sessionAffinityStore?.forgetSession(sessionAffinityKey); - - if (authFailurePolicy.removeAccount) { - const removedIndex = account.index; - sessionAffinityStore?.forgetAccount(removedIndex); - accountManager.removeAccount(account); - sessionAffinityStore?.reindexAfterRemoval(removedIndex); - accountManager.saveToDiskDebounced(); - await showToast( - `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, - "error", - { duration: toastDurationMs * 2 }, - ); - continue; - } - - if ( - typeof authFailurePolicy.cooldownMs === "number" && - authFailurePolicy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - authFailurePolicy.cooldownMs, - authFailurePolicy.cooldownReason, - ); - } - accountManager.saveToDiskDebounced(); - continue; - } - - const hadAccountId = !!account.accountId; - const tokenAccountId = extractAccountId(accountAuth.access); - const accountId = resolveRequestAccountId( - account.accountId, - account.accountIdSource, - tokenAccountId, - ); - const entitlementAccountKey = resolveEntitlementAccountKey({ - accountId: hadAccountId ? account.accountId : undefined, - email: account.email, - index: account.index, - }); - if (!accountId) { - accountManager.markAccountCoolingDown( - account, - ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, - "auth-failure", - ); - accountManager.saveToDiskDebounced(); - continue; - } - account.accountId = accountId; - if (!hadAccountId && tokenAccountId && accountId === tokenAccountId) { - account.accountIdSource = account.accountIdSource ?? "token"; + let accountAuth = accountManager.toAuthDetails( + account, + ) as OAuthAuthDetails; + try { + if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) { + accountAuth = (await refreshAndUpdateToken( + accountAuth, + client, + )) as OAuthAuthDetails; + accountManager.updateFromAuth(account, accountAuth); + accountManager.clearAuthFailures(account); + accountManager.saveToDiskDebounced(); } - account.email = - extractAccountEmail(accountAuth.access) ?? account.email; - const entitlementBlock = entitlementCache.isBlocked( - entitlementAccountKey, - model ?? modelFamily, + } catch (err) { + logDebug( + `[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`, ); - if (entitlementBlock.blocked) { - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `Entitlement cached block for account ${account.index + 1}`; - logWarn( - `Skipping account ${account.index + 1} due to cached entitlement block (${formatWaitTime(entitlementBlock.waitMs)} remaining).`, + runtimeMetrics.authRefreshFailures++; + runtimeMetrics.failedRequests++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = + (err as Error)?.message ?? String(err); + const failures = + accountManager.incrementAuthFailures(account); + const accountLabel = formatAccountLabel( + account, + account.index, + ); + + const authFailurePolicy = evaluateFailurePolicy({ + kind: "auth-refresh", + consecutiveAuthFailures: failures, + }); + sessionAffinityStore?.forgetSession(sessionAffinityKey); + + if (authFailurePolicy.removeAccount) { + const removedIndex = account.index; + sessionAffinityStore?.forgetAccount(removedIndex); + accountManager.removeAccount(account); + sessionAffinityStore?.reindexAfterRemoval(removedIndex); + accountManager.saveToDiskDebounced(); + await showToast( + `Removed ${accountLabel} after ${failures} consecutive auth failures. Run 'codex login' to re-add.`, + "error", + { duration: toastDurationMs * 2 }, ); continue; } if ( - accountCount > 1 && - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) + typeof authFailurePolicy.cooldownMs === "number" && + authFailurePolicy.cooldownReason ) { - const accountLabel = formatAccountLabel(account, account.index); - await showToast( - `Using ${accountLabel} (${account.index + 1}/${accountCount})`, - "info", + accountManager.markAccountCoolingDown( + account, + authFailurePolicy.cooldownMs, + authFailurePolicy.cooldownReason, ); - accountManager.markToastShown(account.index); } + accountManager.saveToDiskDebounced(); + continue; + } - const headers = createCodexHeaders( - requestInit, - accountId, - accountAuth.access, - { - model, - promptCacheKey: effectivePromptCacheKey, - }, - ); - const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; - const capabilityModelKey = model ?? modelFamily; - const quotaDeferral = preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); - if (quotaDeferral.defer && quotaDeferral.waitMs > 0) { - accountManager.markRateLimitedWithReason( - account, - quotaDeferral.waitMs, - modelFamily, - "quota", - model, - ); - accountManager.recordRateLimit(account, modelFamily, model); - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `Preemptive quota deferral for account ${account.index + 1}`; - accountManager.saveToDiskDebounced(); - continue; - } - - // Consume a token before making the request for proactive rate limiting - const tokenConsumed = accountManager.consumeToken(account, modelFamily, model); - if (!tokenConsumed) { - accountManager.recordRateLimit(account, modelFamily, model); - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = - `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`; - logWarn( - `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, - ); - continue; - } - - let sameAccountRetryCount = 0; - let successAccountForResponse = account; - while (true) { - let response: Response; - const fetchStart = performance.now(); - - // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any) - const fetchController = new AbortController(); - const requestTimeoutMs = fetchTimeoutMs; - let requestTimedOut = false; - const timeoutReason = new Error("Request timeout"); - const fetchTimeoutId = setTimeout(() => { - requestTimedOut = true; - fetchController.abort(timeoutReason); - }, requestTimeoutMs); - - const onUserAbort = abortSignal - ? () => fetchController.abort(abortSignal.reason ?? new Error("Aborted by user")) - : null; - - if (abortSignal?.aborted) { - clearTimeout(fetchTimeoutId); - fetchController.abort(abortSignal.reason ?? new Error("Aborted by user")); - } else if (abortSignal && onUserAbort) { - abortSignal.addEventListener("abort", onUserAbort, { once: true }); - } - - try { - runtimeMetrics.totalRequests++; - response = await fetch(url, { - ...requestInit, - headers, - signal: fetchController.signal, - }); - } catch (networkError) { - const fetchAbortReason = fetchController.signal.reason; - const isTimeoutAbort = - requestTimedOut || - (fetchAbortReason instanceof Error && - fetchAbortReason.message === timeoutReason.message); - const isUserAbort = Boolean(abortSignal?.aborted) && !isTimeoutAbort; - if (isUserAbort) { - accountManager.refundToken(account, modelFamily, model); - runtimeMetrics.userAborts++; - runtimeMetrics.lastError = "request aborted by user"; - sessionAffinityStore?.forgetSession(sessionAffinityKey); - throw ( - fetchAbortReason instanceof Error - ? fetchAbortReason - : new Error("Aborted by user") + const hadAccountId = !!account.accountId; + const tokenAccountId = extractAccountId(accountAuth.access); + const accountId = resolveRequestAccountId( + account.accountId, + account.accountIdSource, + tokenAccountId, ); - } - const errorMsg = networkError instanceof Error ? networkError.message : String(networkError); - logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`); - runtimeMetrics.failedRequests++; - runtimeMetrics.networkErrors++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = errorMsg; - const policy = evaluateFailurePolicy( - { kind: "network", failoverMode }, - { networkCooldownMs: networkErrorCooldownMs }, - ); - if (policy.refundToken) { - accountManager.refundToken(account, modelFamily, model); - } - if (policy.recordFailure) { - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - if ( - policy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - MIN_BACKOFF_MS, - Math.floor(policy.retryDelayMs ?? 250), - ); - await sleep(addJitter(retryDelayMs, 0.2)); - continue; - } - if ( - typeof policy.cooldownMs === "number" && - policy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - policy.cooldownMs, - policy.cooldownReason, - ); - accountManager.saveToDiskDebounced(); - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - break; - } finally { - clearTimeout(fetchTimeoutId); - if (abortSignal && onUserAbort) { - abortSignal.removeEventListener("abort", onUserAbort); - } - } - const fetchLatencyMs = Math.round(performance.now() - fetchStart); - - logRequest(LOG_STAGES.RESPONSE, { - status: response.status, - ok: response.ok, - statusText: response.statusText, - latencyMs: fetchLatencyMs, - headers: sanitizeResponseHeadersForLog(response.headers), - }); - const quotaSnapshot = readQuotaSchedulerSnapshot( - response.headers, - response.status, - ); - if (quotaSnapshot) { - preemptiveQuotaScheduler.update(quotaScheduleKey, quotaSnapshot); - } - - if (!response.ok) { - const contextOverflowResult = await handleContextOverflow(response, model); - if (contextOverflowResult.handled) { - return contextOverflowResult.response; - } - - const { response: errorResponse, rateLimit, errorBody } = - await handleErrorResponse(response, { - requestCorrelationId, - threadId: threadIdCandidate, + const entitlementAccountKey = resolveEntitlementAccountKey({ + accountId: hadAccountId ? account.accountId : undefined, + email: account.email, + index: account.index, }); - - const unsupportedModelInfo = getUnsupportedCodexModelInfo(errorBody); - const hasRemainingAccounts = attempted.size < Math.max(1, accountCount); - const blockedModel = - unsupportedModelInfo.unsupportedModel ?? model ?? "requested model"; - const blockedModelNormalized = blockedModel.toLowerCase(); - const shouldForceSparkFallback = - unsupportedModelInfo.isUnsupported && - (blockedModelNormalized === "gpt-5.3-codex-spark" || - blockedModelNormalized.includes("gpt-5.3-codex-spark")); - const allowUnsupportedFallback = - fallbackOnUnsupportedCodexModel || shouldForceSparkFallback; - - // Entitlements can differ by account/workspace, so try remaining - // accounts before degrading the model via fallback. - // Spark entitlement is commonly unavailable on non-Pro/Business workspaces; - // force direct fallback instead of traversing every account/workspace first. - if ( - unsupportedModelInfo.isUnsupported && - hasRemainingAccounts && - !shouldForceSparkFallback - ) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - accountManager.refundToken(account, modelFamily, model); - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - sessionAffinityStore?.forgetSession(sessionAffinityKey); - account.lastSwitchReason = "rotation"; - runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`; - logWarn( - `Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, - { - unsupportedCodexPolicy, - requestedModel: blockedModel, - effectiveModel: blockedModel, - fallbackApplied: false, - fallbackReason: "unsupported-model-entitlement", - }, - ); - break; - } - - const fallbackModel = resolveUnsupportedCodexFallbackModel({ - requestedModel: model, - errorBody, - attemptedModels: attemptedUnsupportedFallbackModels, - fallbackOnUnsupportedCodexModel: allowUnsupportedFallback, - fallbackToGpt52OnUnsupportedGpt53, - customChain: unsupportedCodexFallbackChain, - }); - - if (fallbackModel) { - const previousModel = model ?? "gpt-5-codex"; - const previousModelFamily = modelFamily; - attemptedUnsupportedFallbackModels.add(previousModel); - attemptedUnsupportedFallbackModels.add(fallbackModel); - entitlementCache.markBlocked( - entitlementAccountKey, - previousModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - previousModel, - ); - accountManager.refundToken(account, previousModelFamily, previousModel); - - model = fallbackModel; - modelFamily = getModelFamily(model); - quotaKey = `${modelFamily}:${model}`; - - if (transformedBody && typeof transformedBody === "object") { - transformedBody = { ...transformedBody, model }; - } else { - let fallbackBody: Record = { model }; - if (requestInit?.body && typeof requestInit.body === "string") { - try { - const parsed = JSON.parse(requestInit.body) as Record; - fallbackBody = { ...parsed, model }; - } catch { - // Keep minimal fallback body if parsing fails. - } - } - transformedBody = fallbackBody as RequestBody; - } - - requestInit = { - ...(requestInit ?? {}), - body: JSON.stringify(transformedBody), - }; - runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`; - logWarn( - `Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, - { - unsupportedCodexPolicy, - requestedModel: previousModel, - effectiveModel: model, - fallbackApplied: true, - fallbackReason: "unsupported-model-entitlement", - }, - ); - await showToast( - `Model ${previousModel} is not available for this account. Retrying with ${model}.`, - "warning", - { duration: toastDurationMs }, - ); - restartAccountTraversalWithFallback = true; - break; - } - - if (unsupportedModelInfo.isUnsupported && !allowUnsupportedFallback) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`; - logWarn( - `Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, - { - unsupportedCodexPolicy, - requestedModel: blockedModel, - effectiveModel: blockedModel, - fallbackApplied: false, - fallbackReason: "unsupported-model-entitlement", - }, - ); - await showToast( - `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, - "warning", - { duration: toastDurationMs }, - ); - } - if ( - unsupportedModelInfo.isUnsupported && - allowUnsupportedFallback && - !hasRemainingAccounts && - !fallbackModel - ) { - entitlementCache.markBlocked( - entitlementAccountKey, - blockedModel, - "unsupported-model", - ); - capabilityPolicyStore.recordUnsupported( - entitlementAccountKey, - blockedModel, - ); - } - if (errorResponse.status === 403 && !unsupportedModelInfo.isUnsupported) { - entitlementCache.markBlocked( - entitlementAccountKey, - model ?? modelFamily, - "plan-entitlement", - ); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - - if (recoveryHook && errorBody && isRecoverableError(errorBody)) { - const errorType = detectErrorType(errorBody); - const toastContent = getRecoveryToastContent(errorType); - await showToast( - `${toastContent.title}: ${toastContent.message}`, - "warning", - { duration: toastDurationMs }, - ); - logDebug(`[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`); - } - - // Handle 5xx server errors by rotating to another account - if (response.status >= 500 && response.status < 600) { - logWarn(`Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`); - runtimeMetrics.failedRequests++; - runtimeMetrics.serverErrors++; - runtimeMetrics.accountRotations++; - runtimeMetrics.lastError = `HTTP ${response.status}`; - const serverRetryAfterMs = parseRetryAfterHintMs(response.headers); - const policy = evaluateFailurePolicy( - { kind: "server", failoverMode, serverRetryAfterMs: serverRetryAfterMs ?? undefined }, - { serverCooldownMs: serverErrorCooldownMs }, - ); - if (policy.refundToken) { - accountManager.refundToken(account, modelFamily, model); - } - if (policy.recordFailure) { - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - if ( - policy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - MIN_BACKOFF_MS, - Math.floor(policy.retryDelayMs ?? 500), - ); - await sleep(addJitter(retryDelayMs, 0.2)); - continue; - } - if ( - typeof policy.cooldownMs === "number" && - policy.cooldownReason - ) { - accountManager.markAccountCoolingDown( - account, - policy.cooldownMs, - policy.cooldownReason, - ); - accountManager.saveToDiskDebounced(); - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - break; - } - - if (rateLimit) { - runtimeMetrics.rateLimitedResponses++; - const { attempt, delayMs } = getRateLimitBackoff( - account.index, - quotaKey, - rateLimit.retryAfterMs, - ); - preemptiveQuotaScheduler.markRateLimited( - quotaScheduleKey, - delayMs, - ); - const waitLabel = formatWaitTime(delayMs); - - if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) { - if ( - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) - ) { - await showToast( - `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.markToastShown(account.index); - } - - await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2)); - continue; - } - - accountManager.markRateLimitedWithReason( - account, - delayMs, - modelFamily, - parseRateLimitReason(rateLimit.code), - model, - ); - accountManager.recordRateLimit(account, modelFamily, model); - account.lastSwitchReason = "rate-limit"; - sessionAffinityStore?.forgetSession(sessionAffinityKey); - runtimeMetrics.accountRotations++; - accountManager.saveToDiskDebounced(); - logWarn( - `Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`, - ); - - if ( - accountManager.getAccountCount() > 1 && - accountManager.shouldShowAccountToast( - account.index, - rateLimitToastDebounceMs, - ) - ) { - await showToast( - `Rate limited. Switching accounts (retry in ${waitLabel}).`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.markToastShown(account.index); - } - break; - } - if ( - !rateLimit && - !unsupportedModelInfo.isUnsupported && - errorResponse.status !== 403 - ) { - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - } - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = `HTTP ${response.status}`; - return errorResponse; - } - - resetRateLimitBackoff(account.index, quotaKey); - runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs; - let responseForSuccess = response; - if (isStreaming) { - const streamFallbackCandidateOrder = [ - account.index, - ...accountManager - .getAccountsSnapshot() - .map((candidate) => candidate.index) - .filter((index) => index !== account.index), - ]; - responseForSuccess = withStreamingFailover( - response, - async (failoverAttempt, emittedBytes) => { - if (abortSignal?.aborted) { - return null; - } - runtimeMetrics.streamFailoverAttempts += 1; - - for (const candidateIndex of streamFallbackCandidateOrder) { - if (abortSignal?.aborted) { - return null; + if (!accountId) { + accountManager.markAccountCoolingDown( + account, + ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, + "auth-failure", + ); + accountManager.saveToDiskDebounced(); + continue; } + account.accountId = accountId; if ( - !accountManager.isAccountAvailableForFamily( - candidateIndex, - modelFamily, - model, - ) + !hadAccountId && + tokenAccountId && + accountId === tokenAccountId ) { - continue; + account.accountIdSource = + account.accountIdSource ?? "token"; } - - const fallbackAccount = accountManager.getAccountByIndex(candidateIndex); - if (!fallbackAccount) continue; - - let fallbackAuth = accountManager.toAuthDetails(fallbackAccount) as OAuthAuthDetails; - try { - if (shouldRefreshToken(fallbackAuth, tokenRefreshSkewMs)) { - fallbackAuth = (await refreshAndUpdateToken( - fallbackAuth, - client, - )) as OAuthAuthDetails; - accountManager.updateFromAuth(fallbackAccount, fallbackAuth); - accountManager.clearAuthFailures(fallbackAccount); - accountManager.saveToDiskDebounced(); - } - } catch (refreshError) { + account.email = + extractAccountEmail(accountAuth.access) ?? account.email; + const entitlementBlock = entitlementCache.isBlocked( + entitlementAccountKey, + model ?? modelFamily, + ); + if (entitlementBlock.blocked) { + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Entitlement cached block for account ${account.index + 1}`; logWarn( - `Stream failover refresh failed for account ${fallbackAccount.index + 1}.`, - { - error: - refreshError instanceof Error - ? refreshError.message - : String(refreshError), - }, + `Skipping account ${account.index + 1} due to cached entitlement block (${formatWaitTime(entitlementBlock.waitMs)} remaining).`, ); continue; } - const fallbackTokenAccountId = extractAccountId(fallbackAuth.access); - const fallbackAccountId = resolveRequestAccountId( - fallbackAccount.accountId, - fallbackAccount.accountIdSource, - fallbackTokenAccountId, - ); - if (!fallbackAccountId) { - continue; - } - - if (!accountManager.consumeToken(fallbackAccount, modelFamily, model)) { - continue; + if ( + accountCount > 1 && + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + const accountLabel = formatAccountLabel( + account, + account.index, + ); + await showToast( + `Using ${accountLabel} (${account.index + 1}/${accountCount})`, + "info", + ); + accountManager.markToastShown(account.index); } - const fallbackHeaders = createCodexHeaders( + const headers = createCodexHeaders( requestInit, - fallbackAccountId, - fallbackAuth.access, + accountId, + accountAuth.access, { model, promptCacheKey: effectivePromptCacheKey, }, ); + const quotaScheduleKey = `${entitlementAccountKey}:${model ?? modelFamily}`; + const capabilityModelKey = model ?? modelFamily; + const quotaDeferral = + preemptiveQuotaScheduler.getDeferral(quotaScheduleKey); + if (quotaDeferral.defer && quotaDeferral.waitMs > 0) { + accountManager.markRateLimitedWithReason( + account, + quotaDeferral.waitMs, + modelFamily, + "quota", + model, + ); + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Preemptive quota deferral for account ${account.index + 1}`; + accountManager.saveToDiskDebounced(); + continue; + } - const fallbackController = new AbortController(); - const fallbackTimeoutId = setTimeout( - () => fallbackController.abort(new Error("Request timeout")), - fetchTimeoutMs, + // Consume a token before making the request for proactive rate limiting + const tokenConsumed = accountManager.consumeToken( + account, + modelFamily, + model, ); - const onFallbackAbort = abortSignal - ? () => - fallbackController.abort( - abortSignal.reason ?? new Error("Aborted by user"), - ) - : null; - if (abortSignal && onFallbackAbort) { - abortSignal.addEventListener("abort", onFallbackAbort, { - once: true, - }); + if (!tokenConsumed) { + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`; + logWarn( + `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, + ); + continue; } - try { - runtimeMetrics.totalRequests++; - const fallbackResponse = await fetch(url, { - ...requestInit, - headers: fallbackHeaders, - signal: fallbackController.signal, + let sameAccountRetryCount = 0; + let successAccountForResponse = account; + while (true) { + let response: Response; + const fetchStart = performance.now(); + + // Merge user AbortSignal with timeout (Node 18 compatible - no AbortSignal.any) + const fetchController = new AbortController(); + const requestTimeoutMs = fetchTimeoutMs; + let requestTimedOut = false; + const timeoutReason = new Error("Request timeout"); + const fetchTimeoutId = setTimeout(() => { + requestTimedOut = true; + fetchController.abort(timeoutReason); + }, requestTimeoutMs); + + const onUserAbort = abortSignal + ? () => + fetchController.abort( + abortSignal.reason ?? + new Error("Aborted by user"), + ) + : null; + + if (abortSignal?.aborted) { + clearTimeout(fetchTimeoutId); + fetchController.abort( + abortSignal.reason ?? new Error("Aborted by user"), + ); + } else if (abortSignal && onUserAbort) { + abortSignal.addEventListener("abort", onUserAbort, { + once: true, + }); + } + + try { + runtimeMetrics.totalRequests++; + response = await fetch(url, { + ...requestInit, + headers, + signal: fetchController.signal, + }); + } catch (networkError) { + const fetchAbortReason = fetchController.signal.reason; + const isTimeoutAbort = + requestTimedOut || + (fetchAbortReason instanceof Error && + fetchAbortReason.message === timeoutReason.message); + const isUserAbort = + Boolean(abortSignal?.aborted) && !isTimeoutAbort; + if (isUserAbort) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + runtimeMetrics.userAborts++; + runtimeMetrics.lastError = "request aborted by user"; + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + throw fetchAbortReason instanceof Error + ? fetchAbortReason + : new Error("Aborted by user"); + } + const errorMsg = + networkError instanceof Error + ? networkError.message + : String(networkError); + logWarn( + `Network error for account ${account.index + 1}: ${errorMsg}`, + ); + runtimeMetrics.failedRequests++; + runtimeMetrics.networkErrors++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = errorMsg; + const policy = evaluateFailurePolicy( + { kind: "network", failoverMode }, + { networkCooldownMs: networkErrorCooldownMs }, + ); + if (policy.refundToken) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + } + if (policy.recordFailure) { + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + if ( + policy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + MIN_BACKOFF_MS, + Math.floor(policy.retryDelayMs ?? 250), + ); + await sleep(addJitter(retryDelayMs, 0.2)); + continue; + } + if ( + typeof policy.cooldownMs === "number" && + policy.cooldownReason + ) { + accountManager.markAccountCoolingDown( + account, + policy.cooldownMs, + policy.cooldownReason, + ); + accountManager.saveToDiskDebounced(); + } + sessionAffinityStore?.forgetSession(sessionAffinityKey); + break; + } finally { + clearTimeout(fetchTimeoutId); + if (abortSignal && onUserAbort) { + abortSignal.removeEventListener("abort", onUserAbort); + } + } + const fetchLatencyMs = Math.round( + performance.now() - fetchStart, + ); + + logRequest(LOG_STAGES.RESPONSE, { + status: response.status, + ok: response.ok, + statusText: response.statusText, + latencyMs: fetchLatencyMs, + headers: sanitizeResponseHeadersForLog( + response.headers, + ), }); - const fallbackSnapshot = readQuotaSchedulerSnapshot( - fallbackResponse.headers, - fallbackResponse.status, + const quotaSnapshot = readQuotaSchedulerSnapshot( + response.headers, + response.status, ); - if (fallbackSnapshot) { + if (quotaSnapshot) { preemptiveQuotaScheduler.update( - `${resolveEntitlementAccountKey(fallbackAccount)}:${model ?? modelFamily}`, - fallbackSnapshot, + quotaScheduleKey, + quotaSnapshot, ); } - if (!fallbackResponse.ok) { - try { - await fallbackResponse.body?.cancel(); - } catch { - // Best effort cleanup before trying next fallback account. + + if (!response.ok) { + const contextOverflowResult = + await handleContextOverflow(response, model); + if (contextOverflowResult.handled) { + return contextOverflowResult.response; } - if (fallbackResponse.status === 429) { - const retryAfterMs = - parseRetryAfterHintMs(fallbackResponse.headers) ?? 60_000; - accountManager.markRateLimitedWithReason( - fallbackAccount, - retryAfterMs, + + const { + response: errorResponse, + rateLimit, + errorBody, + } = await handleErrorResponse(response, { + requestCorrelationId, + threadId: threadIdCandidate, + }); + + const unsupportedModelInfo = + getUnsupportedCodexModelInfo(errorBody); + const hasRemainingAccounts = + attempted.size < Math.max(1, accountCount); + const blockedModel = + unsupportedModelInfo.unsupportedModel ?? + model ?? + "requested model"; + const blockedModelNormalized = + blockedModel.toLowerCase(); + const shouldForceSparkFallback = + unsupportedModelInfo.isUnsupported && + (blockedModelNormalized === "gpt-5.3-codex-spark" || + blockedModelNormalized.includes( + "gpt-5.3-codex-spark", + )); + const allowUnsupportedFallback = + fallbackOnUnsupportedCodexModel || + shouldForceSparkFallback; + + // Entitlements can differ by account/workspace, so try remaining + // accounts before degrading the model via fallback. + // Spark entitlement is commonly unavailable on non-Pro/Business workspaces; + // force direct fallback instead of traversing every account/workspace first. + if ( + unsupportedModelInfo.isUnsupported && + hasRemainingAccounts && + !shouldForceSparkFallback + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + accountManager.refundToken( + account, modelFamily, - "quota", model, ); - accountManager.recordRateLimit(fallbackAccount, modelFamily, model); - } else { - accountManager.recordFailure(fallbackAccount, modelFamily, model); + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + account.lastSwitchReason = "rotation"; + runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`; + logWarn( + `Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, + { + unsupportedCodexPolicy, + requestedModel: blockedModel, + effectiveModel: blockedModel, + fallbackApplied: false, + fallbackReason: "unsupported-model-entitlement", + }, + ); + break; } - capabilityPolicyStore.recordFailure( - resolveEntitlementAccountKey(fallbackAccount), - capabilityModelKey, - ); - continue; + + const fallbackModel = + resolveUnsupportedCodexFallbackModel({ + requestedModel: model, + errorBody, + attemptedModels: attemptedUnsupportedFallbackModels, + fallbackOnUnsupportedCodexModel: + allowUnsupportedFallback, + fallbackToGpt52OnUnsupportedGpt53, + customChain: unsupportedCodexFallbackChain, + }); + + if (fallbackModel) { + const previousModel = model ?? "gpt-5-codex"; + const previousModelFamily = modelFamily; + attemptedUnsupportedFallbackModels.add(previousModel); + attemptedUnsupportedFallbackModels.add(fallbackModel); + entitlementCache.markBlocked( + entitlementAccountKey, + previousModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + previousModel, + ); + accountManager.refundToken( + account, + previousModelFamily, + previousModel, + ); + + model = fallbackModel; + modelFamily = getModelFamily(model); + quotaKey = `${modelFamily}:${model}`; + + if ( + transformedBody && + typeof transformedBody === "object" + ) { + transformedBody = { ...transformedBody, model }; + } else { + let fallbackBody: Record = { + model, + }; + if ( + requestInit?.body && + typeof requestInit.body === "string" + ) { + try { + const parsed = JSON.parse( + requestInit.body, + ) as Record; + fallbackBody = { ...parsed, model }; + } catch { + // Keep minimal fallback body if parsing fails. + } + } + transformedBody = fallbackBody as RequestBody; + } + + requestInit = { + ...(requestInit ?? {}), + body: JSON.stringify(transformedBody), + }; + runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`; + logWarn( + `Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, + { + unsupportedCodexPolicy, + requestedModel: previousModel, + effectiveModel: model, + fallbackApplied: true, + fallbackReason: "unsupported-model-entitlement", + }, + ); + await showToast( + `Model ${previousModel} is not available for this account. Retrying with ${model}.`, + "warning", + { duration: toastDurationMs }, + ); + restartAccountTraversalWithFallback = true; + break; + } + + if ( + unsupportedModelInfo.isUnsupported && + !allowUnsupportedFallback + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`; + logWarn( + `Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, + { + unsupportedCodexPolicy, + requestedModel: blockedModel, + effectiveModel: blockedModel, + fallbackApplied: false, + fallbackReason: "unsupported-model-entitlement", + }, + ); + await showToast( + `Model ${blockedModel} is not available for this account. Strict policy blocked automatic fallback.`, + "warning", + { duration: toastDurationMs }, + ); + } + if ( + unsupportedModelInfo.isUnsupported && + allowUnsupportedFallback && + !hasRemainingAccounts && + !fallbackModel + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + blockedModel, + "unsupported-model", + ); + capabilityPolicyStore.recordUnsupported( + entitlementAccountKey, + blockedModel, + ); + } + if ( + errorResponse.status === 403 && + !unsupportedModelInfo.isUnsupported + ) { + entitlementCache.markBlocked( + entitlementAccountKey, + model ?? modelFamily, + "plan-entitlement", + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + + if ( + recoveryHook && + errorBody && + isRecoverableError(errorBody) + ) { + const errorType = detectErrorType(errorBody); + const toastContent = + getRecoveryToastContent(errorType); + await showToast( + `${toastContent.title}: ${toastContent.message}`, + "warning", + { duration: toastDurationMs }, + ); + logDebug( + `[${PLUGIN_NAME}] Recoverable error detected: ${errorType}`, + ); + } + + // Handle 5xx server errors by rotating to another account + if (response.status >= 500 && response.status < 600) { + logWarn( + `Server error ${response.status} for account ${account.index + 1}. Rotating to next account.`, + ); + runtimeMetrics.failedRequests++; + runtimeMetrics.serverErrors++; + runtimeMetrics.accountRotations++; + runtimeMetrics.lastError = `HTTP ${response.status}`; + const serverRetryAfterMs = parseRetryAfterHintMs( + response.headers, + ); + const policy = evaluateFailurePolicy( + { + kind: "server", + failoverMode, + serverRetryAfterMs: + serverRetryAfterMs ?? undefined, + }, + { serverCooldownMs: serverErrorCooldownMs }, + ); + if (policy.refundToken) { + accountManager.refundToken( + account, + modelFamily, + model, + ); + } + if (policy.recordFailure) { + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + if ( + policy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + MIN_BACKOFF_MS, + Math.floor(policy.retryDelayMs ?? 500), + ); + await sleep(addJitter(retryDelayMs, 0.2)); + continue; + } + if ( + typeof policy.cooldownMs === "number" && + policy.cooldownReason + ) { + accountManager.markAccountCoolingDown( + account, + policy.cooldownMs, + policy.cooldownReason, + ); + accountManager.saveToDiskDebounced(); + } + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + break; + } + + if (rateLimit) { + runtimeMetrics.rateLimitedResponses++; + const { attempt, delayMs } = getRateLimitBackoff( + account.index, + quotaKey, + rateLimit.retryAfterMs, + ); + preemptiveQuotaScheduler.markRateLimited( + quotaScheduleKey, + delayMs, + ); + const waitLabel = formatWaitTime(delayMs); + + if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) { + if ( + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + await showToast( + `Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.markToastShown(account.index); + } + + await sleep( + addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2), + ); + continue; + } + + accountManager.markRateLimitedWithReason( + account, + delayMs, + modelFamily, + parseRateLimitReason(rateLimit.code), + model, + ); + accountManager.recordRateLimit( + account, + modelFamily, + model, + ); + account.lastSwitchReason = "rate-limit"; + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + runtimeMetrics.accountRotations++; + accountManager.saveToDiskDebounced(); + logWarn( + `Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`, + ); + + if ( + accountManager.getAccountCount() > 1 && + accountManager.shouldShowAccountToast( + account.index, + rateLimitToastDebounceMs, + ) + ) { + await showToast( + `Rate limited. Switching accounts (retry in ${waitLabel}).`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.markToastShown(account.index); + } + break; + } + if ( + !rateLimit && + !unsupportedModelInfo.isUnsupported && + errorResponse.status !== 403 + ) { + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + } + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = `HTTP ${response.status}`; + return errorResponse; } - successAccountForResponse = fallbackAccount; - runtimeMetrics.streamFailoverRecoveries += 1; - if (fallbackAccount.index !== account.index) { - runtimeMetrics.streamFailoverCrossAccountRecoveries += 1; - runtimeMetrics.accountRotations += 1; - sessionAffinityStore?.remember( - sessionAffinityKey, - fallbackAccount.index, + resetRateLimitBackoff(account.index, quotaKey); + runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs; + let responseForSuccess = response; + if (isStreaming) { + const streamFallbackCandidateOrder = [ + account.index, + ...accountManager + .getAccountsSnapshot() + .map((candidate) => candidate.index) + .filter((index) => index !== account.index), + ]; + responseForSuccess = withStreamingFailover( + response, + async (failoverAttempt, emittedBytes) => { + if (abortSignal?.aborted) { + return null; + } + runtimeMetrics.streamFailoverAttempts += 1; + + for (const candidateIndex of streamFallbackCandidateOrder) { + if (abortSignal?.aborted) { + return null; + } + if ( + !accountManager.isAccountAvailableForFamily( + candidateIndex, + modelFamily, + model, + ) + ) { + continue; + } + + const fallbackAccount = + accountManager.getAccountByIndex( + candidateIndex, + ); + if (!fallbackAccount) continue; + + let fallbackAuth = accountManager.toAuthDetails( + fallbackAccount, + ) as OAuthAuthDetails; + try { + if ( + shouldRefreshToken( + fallbackAuth, + tokenRefreshSkewMs, + ) + ) { + fallbackAuth = (await refreshAndUpdateToken( + fallbackAuth, + client, + )) as OAuthAuthDetails; + accountManager.updateFromAuth( + fallbackAccount, + fallbackAuth, + ); + accountManager.clearAuthFailures( + fallbackAccount, + ); + accountManager.saveToDiskDebounced(); + } + } catch (refreshError) { + logWarn( + `Stream failover refresh failed for account ${fallbackAccount.index + 1}.`, + { + error: + refreshError instanceof Error + ? refreshError.message + : String(refreshError), + }, + ); + continue; + } + + const fallbackTokenAccountId = extractAccountId( + fallbackAuth.access, + ); + const fallbackAccountId = resolveRequestAccountId( + fallbackAccount.accountId, + fallbackAccount.accountIdSource, + fallbackTokenAccountId, + ); + if (!fallbackAccountId) { + continue; + } + + if ( + !accountManager.consumeToken( + fallbackAccount, + modelFamily, + model, + ) + ) { + continue; + } + + const fallbackHeaders = createCodexHeaders( + requestInit, + fallbackAccountId, + fallbackAuth.access, + { + model, + promptCacheKey: effectivePromptCacheKey, + }, + ); + + const fallbackController = new AbortController(); + const fallbackTimeoutId = setTimeout( + () => + fallbackController.abort( + new Error("Request timeout"), + ), + fetchTimeoutMs, + ); + const onFallbackAbort = abortSignal + ? () => + fallbackController.abort( + abortSignal.reason ?? + new Error("Aborted by user"), + ) + : null; + if (abortSignal && onFallbackAbort) { + abortSignal.addEventListener( + "abort", + onFallbackAbort, + { + once: true, + }, + ); + } + + try { + runtimeMetrics.totalRequests++; + const fallbackResponse = await fetch(url, { + ...requestInit, + headers: fallbackHeaders, + signal: fallbackController.signal, + }); + const fallbackSnapshot = + readQuotaSchedulerSnapshot( + fallbackResponse.headers, + fallbackResponse.status, + ); + if (fallbackSnapshot) { + preemptiveQuotaScheduler.update( + `${resolveEntitlementAccountKey(fallbackAccount)}:${model ?? modelFamily}`, + fallbackSnapshot, + ); + } + if (!fallbackResponse.ok) { + try { + await fallbackResponse.body?.cancel(); + } catch { + // Best effort cleanup before trying next fallback account. + } + if (fallbackResponse.status === 429) { + const retryAfterMs = + parseRetryAfterHintMs( + fallbackResponse.headers, + ) ?? 60_000; + accountManager.markRateLimitedWithReason( + fallbackAccount, + retryAfterMs, + modelFamily, + "quota", + model, + ); + accountManager.recordRateLimit( + fallbackAccount, + modelFamily, + model, + ); + } else { + accountManager.recordFailure( + fallbackAccount, + modelFamily, + model, + ); + } + capabilityPolicyStore.recordFailure( + resolveEntitlementAccountKey( + fallbackAccount, + ), + capabilityModelKey, + ); + continue; + } + + successAccountForResponse = fallbackAccount; + runtimeMetrics.streamFailoverRecoveries += 1; + if (fallbackAccount.index !== account.index) { + runtimeMetrics.streamFailoverCrossAccountRecoveries += 1; + runtimeMetrics.accountRotations += 1; + sessionAffinityStore?.remember( + sessionAffinityKey, + fallbackAccount.index, + ); + } + + logInfo( + `Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, + { emittedBytes }, + ); + return fallbackResponse; + } catch (streamFailoverError) { + accountManager.refundToken( + fallbackAccount, + modelFamily, + model, + ); + accountManager.recordFailure( + fallbackAccount, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + resolveEntitlementAccountKey(fallbackAccount), + capabilityModelKey, + ); + logWarn( + `Stream failover attempt ${failoverAttempt} failed for account ${fallbackAccount.index + 1}.`, + { + emittedBytes, + error: + streamFailoverError instanceof Error + ? streamFailoverError.message + : String(streamFailoverError), + }, + ); + } finally { + clearTimeout(fallbackTimeoutId); + if (abortSignal && onFallbackAbort) { + abortSignal.removeEventListener( + "abort", + onFallbackAbort, + ); + } + } + } + + return null; + }, + { + maxFailovers: streamFailoverMax, + softTimeoutMs: streamFailoverSoftTimeoutMs, + hardTimeoutMs: streamFailoverHardTimeoutMs, + requestInstanceId: + requestCorrelationId ?? undefined, + }, ); } + const successResponse = await handleSuccessResponse( + responseForSuccess, + isStreaming, + { + streamStallTimeoutMs, + }, + ); + + if (!isStreaming && emptyResponseMaxRetries > 0) { + const clonedResponse = successResponse.clone(); + try { + const bodyText = await clonedResponse.text(); + const parsedBody = bodyText + ? (JSON.parse(bodyText) as unknown) + : null; + if (isEmptyResponse(parsedBody)) { + if ( + emptyResponseRetries < emptyResponseMaxRetries + ) { + emptyResponseRetries++; + runtimeMetrics.emptyResponseRetries++; + logWarn( + `Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`, + ); + await showToast( + `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, + "warning", + { duration: toastDurationMs }, + ); + accountManager.refundToken( + account, + modelFamily, + model, + ); + accountManager.recordFailure( + account, + modelFamily, + model, + ); + capabilityPolicyStore.recordFailure( + entitlementAccountKey, + capabilityModelKey, + ); + const emptyPolicy = evaluateFailurePolicy({ + kind: "empty-response", + failoverMode, + }); + if ( + emptyPolicy.retrySameAccount && + sameAccountRetryCount < maxSameAccountRetries + ) { + sameAccountRetryCount += 1; + runtimeMetrics.sameAccountRetries += 1; + const retryDelayMs = Math.max( + 0, + Math.floor( + emptyPolicy.retryDelayMs ?? + emptyResponseRetryDelayMs, + ), + ); + if (retryDelayMs > 0) { + await sleep(addJitter(retryDelayMs, 0.2)); + } + continue; + } + sessionAffinityStore?.forgetSession( + sessionAffinityKey, + ); + await sleep( + addJitter(emptyResponseRetryDelayMs, 0.2), + ); + break; + } + logWarn( + `Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`, + ); + } + } catch { + // Intentionally empty: non-JSON response bodies should be returned as-is + } + } - logInfo( - `Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, - { emittedBytes }, + if (successAccountForResponse.index !== account.index) { + accountManager.markSwitched( + successAccountForResponse, + "rotation", + modelFamily, + ); + } + const successAccountKey = resolveEntitlementAccountKey( + successAccountForResponse, + ); + accountManager.recordSuccess( + successAccountForResponse, + modelFamily, + model, ); - return fallbackResponse; - } catch (streamFailoverError) { - accountManager.refundToken(fallbackAccount, modelFamily, model); - accountManager.recordFailure(fallbackAccount, modelFamily, model); - capabilityPolicyStore.recordFailure( - resolveEntitlementAccountKey(fallbackAccount), + capabilityPolicyStore.recordSuccess( + successAccountKey, capabilityModelKey, ); - logWarn( - `Stream failover attempt ${failoverAttempt} failed for account ${fallbackAccount.index + 1}.`, - { - emittedBytes, - error: - streamFailoverError instanceof Error - ? streamFailoverError.message - : String(streamFailoverError), - }, + entitlementCache.clear( + successAccountKey, + capabilityModelKey, ); - continue; - } finally { - clearTimeout(fallbackTimeoutId); - if (abortSignal && onFallbackAbort) { - abortSignal.removeEventListener("abort", onFallbackAbort); + sessionAffinityStore?.remember( + sessionAffinityKey, + successAccountForResponse.index, + ); + runtimeMetrics.successfulRequests++; + runtimeMetrics.lastError = null; + if ( + lastCodexCliActiveSyncIndex !== + successAccountForResponse.index + ) { + void accountManager.syncCodexCliActiveSelectionForIndex( + successAccountForResponse.index, + ); + lastCodexCliActiveSyncIndex = + successAccountForResponse.index; } + return successResponse; } - } - - return null; - }, - { - maxFailovers: streamFailoverMax, - softTimeoutMs: streamFailoverSoftTimeoutMs, - hardTimeoutMs: streamFailoverHardTimeoutMs, - requestInstanceId: requestCorrelationId ?? undefined, - }, - ); - } - const successResponse = await handleSuccessResponse(responseForSuccess, isStreaming, { - streamStallTimeoutMs, - }); - - if (!isStreaming && emptyResponseMaxRetries > 0) { - const clonedResponse = successResponse.clone(); - try { - const bodyText = await clonedResponse.text(); - const parsedBody = bodyText ? JSON.parse(bodyText) as unknown : null; - if (isEmptyResponse(parsedBody)) { - if (emptyResponseRetries < emptyResponseMaxRetries) { - emptyResponseRetries++; - runtimeMetrics.emptyResponseRetries++; - logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`); - await showToast( - `Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, - "warning", - { duration: toastDurationMs }, - ); - accountManager.refundToken(account, modelFamily, model); - accountManager.recordFailure(account, modelFamily, model); - capabilityPolicyStore.recordFailure( - entitlementAccountKey, - capabilityModelKey, - ); - const emptyPolicy = evaluateFailurePolicy({ - kind: "empty-response", - failoverMode, - }); - if ( - emptyPolicy.retrySameAccount && - sameAccountRetryCount < maxSameAccountRetries - ) { - sameAccountRetryCount += 1; - runtimeMetrics.sameAccountRetries += 1; - const retryDelayMs = Math.max( - 0, - Math.floor(emptyPolicy.retryDelayMs ?? emptyResponseRetryDelayMs), - ); - if (retryDelayMs > 0) { - await sleep(addJitter(retryDelayMs, 0.2)); - } - continue; - } - sessionAffinityStore?.forgetSession(sessionAffinityKey); - await sleep(addJitter(emptyResponseRetryDelayMs, 0.2)); - break; - } - logWarn(`Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`); - } - } catch { - // Intentionally empty: non-JSON response bodies should be returned as-is - } - } - - if (successAccountForResponse.index !== account.index) { - accountManager.markSwitched(successAccountForResponse, "rotation", modelFamily); - } - const successAccountKey = resolveEntitlementAccountKey(successAccountForResponse); - accountManager.recordSuccess(successAccountForResponse, modelFamily, model); - capabilityPolicyStore.recordSuccess( - successAccountKey, - capabilityModelKey, - ); - entitlementCache.clear(successAccountKey, capabilityModelKey); - sessionAffinityStore?.remember( - sessionAffinityKey, - successAccountForResponse.index, - ); - runtimeMetrics.successfulRequests++; - runtimeMetrics.lastError = null; - if (lastCodexCliActiveSyncIndex !== successAccountForResponse.index) { - void accountManager.syncCodexCliActiveSelectionForIndex(successAccountForResponse.index); - lastCodexCliActiveSyncIndex = successAccountForResponse.index; - } - return successResponse; - } if (restartAccountTraversalWithFallback) { break; } - } - - if (restartAccountTraversalWithFallback) { - continue; - } + } - const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model); - const count = accountManager.getAccountCount(); + if (restartAccountTraversalWithFallback) { + continue; + } - if ( - retryAllAccountsRateLimited && - count > 0 && - waitMs > 0 && - (retryAllAccountsMaxWaitMs === 0 || - waitMs <= retryAllAccountsMaxWaitMs) && - allRateLimitedRetries < retryAllAccountsMaxRetries - ) { - const countdownMessage = `All ${count} account(s) rate-limited. Waiting`; - await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage); - allRateLimitedRetries++; - continue; - } + const waitMs = accountManager.getMinWaitTimeForFamily( + modelFamily, + model, + ); + const count = accountManager.getAccountCount(); - const waitLabel = waitMs > 0 ? formatWaitTime(waitMs) : "a bit"; - const message = - count === 0 - ? "No Codex accounts configured. Run `codex login`." - : waitMs > 0 - ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`codex login\`.` - : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; - runtimeMetrics.failedRequests++; - runtimeMetrics.lastError = message; - return new Response(JSON.stringify({ error: { message } }), { - status: waitMs > 0 ? 429 : 503, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }); + if ( + retryAllAccountsRateLimited && + count > 0 && + waitMs > 0 && + (retryAllAccountsMaxWaitMs === 0 || + waitMs <= retryAllAccountsMaxWaitMs) && + allRateLimitedRetries < retryAllAccountsMaxRetries + ) { + const countdownMessage = `All ${count} account(s) rate-limited. Waiting`; + await sleepWithCountdown( + addJitter(waitMs, 0.2), + countdownMessage, + ); + allRateLimitedRetries++; + continue; } - } finally { - clearCorrelationId(); - } + + const waitLabel = + waitMs > 0 ? formatWaitTime(waitMs) : "a bit"; + const message = + count === 0 + ? "No Codex accounts configured. Run `codex login`." + : waitMs > 0 + ? `All ${count} account(s) are rate-limited. Try again in ${waitLabel} or add another account with \`codex login\`.` + : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; + runtimeMetrics.failedRequests++; + runtimeMetrics.lastError = message; + return new Response(JSON.stringify({ error: { message } }), { + status: waitMs > 0 ? 429 : 503, + headers: { + "content-type": "application/json; charset=utf-8", }, - }; + }); + } + } finally { + clearCorrelationId(); + } + }, + }; } finally { resolveMutex?.(); loaderMutex = null; } - }, - methods: [ - { - label: AUTH_LABELS.OAUTH, - type: "oauth" as const, - authorize: async (inputs?: Record) => { - const authPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(authPluginConfig); - applyAccountStorageScope(authPluginConfig); - - const accounts: TokenSuccessWithAccount[] = []; - const noBrowser = - inputs?.noBrowser === "true" || - inputs?.["no-browser"] === "true"; - const useManualMode = noBrowser; - const explicitLoginMode = - inputs?.loginMode === "fresh" || inputs?.loginMode === "add" - ? inputs.loginMode - : null; - - let startFresh = explicitLoginMode === "fresh"; - let refreshAccountIndex: number | undefined; - - const clampActiveIndices = (storage: AccountStorageV3): void => { - const count = storage.accounts.length; - if (count === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - return; - } - storage.activeIndex = Math.max(0, Math.min(storage.activeIndex, count - 1)); - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const candidate = - typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; - storage.activeIndexByFamily[family] = Math.max(0, Math.min(candidate, count - 1)); - } - }; + }, + methods: [ + { + label: AUTH_LABELS.OAUTH, + type: "oauth" as const, + authorize: async (inputs?: Record) => { + const authPluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(authPluginConfig); + applyAccountStorageScope(authPluginConfig); + + const accounts: TokenSuccessWithAccount[] = []; + const noBrowser = + inputs?.noBrowser === "true" || inputs?.["no-browser"] === "true"; + const useManualMode = noBrowser; + const explicitLoginMode = + inputs?.loginMode === "fresh" || inputs?.loginMode === "add" + ? inputs.loginMode + : null; + + let startFresh = explicitLoginMode === "fresh"; + let refreshAccountIndex: number | undefined; + + const isFlaggableFailure = ( + failure: Extract, + ): boolean => { + if (failure.reason === "missing_refresh") return true; + if (failure.statusCode === 401) return true; + if (failure.statusCode !== 400) return false; + const message = (failure.message ?? "").toLowerCase(); + return ( + message.includes("invalid_grant") || + message.includes("invalid refresh") || + message.includes("token has been revoked") + ); + }; - const isFlaggableFailure = (failure: Extract): boolean => { - if (failure.reason === "missing_refresh") return true; - if (failure.statusCode === 401) return true; - if (failure.statusCode !== 400) return false; - const message = (failure.message ?? "").toLowerCase(); - return ( - message.includes("invalid_grant") || - message.includes("invalid refresh") || - message.includes("token has been revoked") - ); - }; + type CodexQuotaWindow = { + usedPercent?: number; + windowMinutes?: number; + resetAtMs?: number; + }; - type CodexQuotaWindow = { - usedPercent?: number; - windowMinutes?: number; - resetAtMs?: number; - }; + type CodexQuotaSnapshot = { + status: number; + planType?: string; + activeLimit?: number; + primary: CodexQuotaWindow; + secondary: CodexQuotaWindow; + }; - type CodexQuotaSnapshot = { - status: number; - planType?: string; - activeLimit?: number; - primary: CodexQuotaWindow; - secondary: CodexQuotaWindow; - }; + const parseFiniteNumberHeader = ( + headers: Headers, + name: string, + ): number | undefined => { + const raw = headers.get(name); + if (!raw) return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : undefined; + }; - const parseFiniteNumberHeader = (headers: Headers, name: string): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const parseFiniteIntHeader = ( + headers: Headers, + name: string, + ): number | undefined => { + const raw = headers.get(name); + if (!raw) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; + }; - const parseFiniteIntHeader = (headers: Headers, name: string): number | undefined => { - const raw = headers.get(name); - if (!raw) return undefined; - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : undefined; - }; + const parseResetAtMs = ( + headers: Headers, + prefix: string, + ): number | undefined => { + const resetAfterSeconds = parseFiniteIntHeader( + headers, + `${prefix}-reset-after-seconds`, + ); + if ( + typeof resetAfterSeconds === "number" && + Number.isFinite(resetAfterSeconds) && + resetAfterSeconds > 0 + ) { + return Date.now() + resetAfterSeconds * 1000; + } - const parseResetAtMs = (headers: Headers, prefix: string): number | undefined => { - const resetAfterSeconds = parseFiniteIntHeader( - headers, - `${prefix}-reset-after-seconds`, - ); - if ( - typeof resetAfterSeconds === "number" && - Number.isFinite(resetAfterSeconds) && - resetAfterSeconds > 0 - ) { - return Date.now() + resetAfterSeconds * 1000; + const resetAtRaw = headers.get(`${prefix}-reset-at`); + if (!resetAtRaw) return undefined; + + const trimmed = resetAtRaw.trim(); + if (/^\d+$/.test(trimmed)) { + const parsedNumber = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsedNumber) && parsedNumber > 0) { + // Upstream sometimes returns seconds since epoch. + return parsedNumber < 10_000_000_000 + ? parsedNumber * 1000 + : parsedNumber; } + } - const resetAtRaw = headers.get(`${prefix}-reset-at`); - if (!resetAtRaw) return undefined; + const parsedDate = Date.parse(trimmed); + return Number.isFinite(parsedDate) ? parsedDate : undefined; + }; - const trimmed = resetAtRaw.trim(); - if (/^\d+$/.test(trimmed)) { - const parsedNumber = Number.parseInt(trimmed, 10); - if (Number.isFinite(parsedNumber) && parsedNumber > 0) { - // Upstream sometimes returns seconds since epoch. - return parsedNumber < 10_000_000_000 ? parsedNumber * 1000 : parsedNumber; - } - } + const hasCodexQuotaHeaders = (headers: Headers): boolean => { + const keys = [ + "x-codex-primary-used-percent", + "x-codex-primary-window-minutes", + "x-codex-primary-reset-at", + "x-codex-primary-reset-after-seconds", + "x-codex-secondary-used-percent", + "x-codex-secondary-window-minutes", + "x-codex-secondary-reset-at", + "x-codex-secondary-reset-after-seconds", + ]; + return keys.some((key) => headers.get(key) !== null); + }; - const parsedDate = Date.parse(trimmed); - return Number.isFinite(parsedDate) ? parsedDate : undefined; - }; + const parseCodexQuotaSnapshot = ( + headers: Headers, + status: number, + ): CodexQuotaSnapshot | null => { + if (!hasCodexQuotaHeaders(headers)) return null; - const hasCodexQuotaHeaders = (headers: Headers): boolean => { - const keys = [ - "x-codex-primary-used-percent", - "x-codex-primary-window-minutes", - "x-codex-primary-reset-at", - "x-codex-primary-reset-after-seconds", - "x-codex-secondary-used-percent", - "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-at", - "x-codex-secondary-reset-after-seconds", - ]; - return keys.some((key) => headers.get(key) !== null); + const primaryPrefix = "x-codex-primary"; + const secondaryPrefix = "x-codex-secondary"; + const primary: CodexQuotaWindow = { + usedPercent: parseFiniteNumberHeader( + headers, + `${primaryPrefix}-used-percent`, + ), + windowMinutes: parseFiniteIntHeader( + headers, + `${primaryPrefix}-window-minutes`, + ), + resetAtMs: parseResetAtMs(headers, primaryPrefix), + }; + const secondary: CodexQuotaWindow = { + usedPercent: parseFiniteNumberHeader( + headers, + `${secondaryPrefix}-used-percent`, + ), + windowMinutes: parseFiniteIntHeader( + headers, + `${secondaryPrefix}-window-minutes`, + ), + resetAtMs: parseResetAtMs(headers, secondaryPrefix), }; - const parseCodexQuotaSnapshot = (headers: Headers, status: number): CodexQuotaSnapshot | null => { - if (!hasCodexQuotaHeaders(headers)) return null; - - const primaryPrefix = "x-codex-primary"; - const secondaryPrefix = "x-codex-secondary"; - const primary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader(headers, `${primaryPrefix}-used-percent`), - windowMinutes: parseFiniteIntHeader(headers, `${primaryPrefix}-window-minutes`), - resetAtMs: parseResetAtMs(headers, primaryPrefix), - }; - const secondary: CodexQuotaWindow = { - usedPercent: parseFiniteNumberHeader(headers, `${secondaryPrefix}-used-percent`), - windowMinutes: parseFiniteIntHeader(headers, `${secondaryPrefix}-window-minutes`), - resetAtMs: parseResetAtMs(headers, secondaryPrefix), - }; + const planTypeRaw = headers.get("x-codex-plan-type"); + const planType = + planTypeRaw && planTypeRaw.trim() + ? planTypeRaw.trim() + : undefined; + const activeLimit = parseFiniteIntHeader( + headers, + "x-codex-active-limit", + ); - const planTypeRaw = headers.get("x-codex-plan-type"); - const planType = planTypeRaw && planTypeRaw.trim() ? planTypeRaw.trim() : undefined; - const activeLimit = parseFiniteIntHeader(headers, "x-codex-active-limit"); + return { status, planType, activeLimit, primary, secondary }; + }; - return { status, planType, activeLimit, primary, secondary }; - }; + const formatQuotaWindowLabel = ( + windowMinutes: number | undefined, + ): string => { + if ( + !windowMinutes || + !Number.isFinite(windowMinutes) || + windowMinutes <= 0 + ) { + return "quota"; + } + if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; + if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; + return `${windowMinutes}m`; + }; - const formatQuotaWindowLabel = (windowMinutes: number | undefined): string => { - if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { - return "quota"; - } - if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; - if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; - return `${windowMinutes}m`; - }; + const formatResetAt = ( + resetAtMs: number | undefined, + ): string | undefined => { + if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) + return undefined; + const date = new Date(resetAtMs); + if (!Number.isFinite(date.getTime())) return undefined; + + const now = new Date(); + const sameDay = + now.getFullYear() === date.getFullYear() && + now.getMonth() === date.getMonth() && + now.getDate() === date.getDate(); + + const time = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); - const formatResetAt = (resetAtMs: number | undefined): string | undefined => { - if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) return undefined; - const date = new Date(resetAtMs); - if (!Number.isFinite(date.getTime())) return undefined; - - const now = new Date(); - const sameDay = - now.getFullYear() === date.getFullYear() && - now.getMonth() === date.getMonth() && - now.getDate() === date.getDate(); - - const time = date.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + if (sameDay) return time; + const day = date.toLocaleDateString(undefined, { + month: "short", + day: "2-digit", + }); + return `${time} on ${day}`; + }; - if (sameDay) return time; - const day = date.toLocaleDateString(undefined, { month: "short", day: "2-digit" }); - return `${time} on ${day}`; + const formatCodexQuotaLine = ( + snapshot: CodexQuotaSnapshot, + ): string => { + const summarizeWindow = ( + label: string, + window: CodexQuotaWindow, + ): string => { + const used = window.usedPercent; + const left = + typeof used === "number" && Number.isFinite(used) + ? Math.max(0, Math.min(100, Math.round(100 - used))) + : undefined; + const reset = formatResetAt(window.resetAtMs); + let summary = label; + if (left !== undefined) summary = `${summary} ${left}% left`; + if (reset) summary = `${summary} (resets ${reset})`; + return summary; }; - const formatCodexQuotaLine = (snapshot: CodexQuotaSnapshot): string => { - const summarizeWindow = (label: string, window: CodexQuotaWindow): string => { - const used = window.usedPercent; - const left = - typeof used === "number" && Number.isFinite(used) - ? Math.max(0, Math.min(100, Math.round(100 - used))) - : undefined; - const reset = formatResetAt(window.resetAtMs); - let summary = label; - if (left !== undefined) summary = `${summary} ${left}% left`; - if (reset) summary = `${summary} (resets ${reset})`; - return summary; - }; - - const primaryLabel = formatQuotaWindowLabel(snapshot.primary.windowMinutes); - const secondaryLabel = formatQuotaWindowLabel(snapshot.secondary.windowMinutes); - const parts = [ - summarizeWindow(primaryLabel, snapshot.primary), - summarizeWindow(secondaryLabel, snapshot.secondary), - ]; - if (snapshot.planType) parts.push(`plan:${snapshot.planType}`); - if (typeof snapshot.activeLimit === "number" && Number.isFinite(snapshot.activeLimit)) { - parts.push(`active:${snapshot.activeLimit}`); - } - if (snapshot.status === 429) parts.push("rate-limited"); - return parts.join(", "); - }; + const primaryLabel = formatQuotaWindowLabel( + snapshot.primary.windowMinutes, + ); + const secondaryLabel = formatQuotaWindowLabel( + snapshot.secondary.windowMinutes, + ); + const parts = [ + summarizeWindow(primaryLabel, snapshot.primary), + summarizeWindow(secondaryLabel, snapshot.secondary), + ]; + if (snapshot.planType) parts.push(`plan:${snapshot.planType}`); + if ( + typeof snapshot.activeLimit === "number" && + Number.isFinite(snapshot.activeLimit) + ) { + parts.push(`active:${snapshot.activeLimit}`); + } + if (snapshot.status === 429) parts.push("rate-limited"); + return parts.join(", "); + }; - const fetchCodexQuotaSnapshot = async (params: { - accountId: string; - accessToken: string; - }): Promise => { - const QUOTA_PROBE_MODELS = ["gpt-5-codex", "gpt-5.3-codex", "gpt-5.2-codex"]; - let lastError: Error | null = null; + const fetchCodexQuotaSnapshot = async (params: { + accountId: string; + accessToken: string; + }): Promise => { + const QUOTA_PROBE_MODELS = [ + "gpt-5-codex", + "gpt-5.3-codex", + "gpt-5.2-codex", + ]; + let lastError: Error | null = null; - for (const model of QUOTA_PROBE_MODELS) { - try { - const instructions = await getCodexInstructions(model); - const probeBody: RequestBody = { - model, - stream: true, - store: false, - include: ["reasoning.encrypted_content"], - instructions, - input: [ - { - type: "message", - role: "user", - content: [{ type: "input_text", text: "quota ping" }], - }, - ], - reasoning: { effort: "none", summary: "auto" }, - text: { verbosity: "low" }, - }; + for (const model of QUOTA_PROBE_MODELS) { + try { + const instructions = await getCodexInstructions(model); + const probeBody: RequestBody = { + model, + stream: true, + store: false, + include: ["reasoning.encrypted_content"], + instructions, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "quota ping" }], + }, + ], + reasoning: { effort: "none", summary: "auto" }, + text: { verbosity: "low" }, + }; - const headers = createCodexHeaders(undefined, params.accountId, params.accessToken, { + const headers = createCodexHeaders( + undefined, + params.accountId, + params.accessToken, + { model, - }); - headers.set("content-type", "application/json; charset=utf-8"); + }, + ); + headers.set( + "content-type", + "application/json; charset=utf-8", + ); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - let response: Response; - try { - response = await fetch(`${CODEX_BASE_URL}/codex/responses`, { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + let response: Response; + try { + response = await fetch( + `${CODEX_BASE_URL}/codex/responses`, + { method: "POST", headers, body: JSON.stringify(probeBody), signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } + }, + ); + } finally { + clearTimeout(timeout); + } - const snapshot = parseCodexQuotaSnapshot(response.headers, response.status); - if (snapshot) { - // We only need headers; cancel the SSE stream immediately. - try { - await response.body?.cancel(); - } catch { - // Ignore cancellation failures. - } - return snapshot; + const snapshot = parseCodexQuotaSnapshot( + response.headers, + response.status, + ); + if (snapshot) { + // We only need headers; cancel the SSE stream immediately. + try { + await response.body?.cancel(); + } catch { + // Ignore cancellation failures. } + return snapshot; + } - if (!response.ok) { - const bodyText = await response.text().catch(() => ""); - let errorBody: unknown = undefined; - try { - errorBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined; - } catch { - errorBody = { error: { message: bodyText } }; - } - - const unsupportedInfo = getUnsupportedCodexModelInfo(errorBody); - if (unsupportedInfo.isUnsupported) { - lastError = new Error( - unsupportedInfo.message ?? `Model '${model}' unsupported for this account`, - ); - continue; - } + if (!response.ok) { + const bodyText = await response.text().catch(() => ""); + let errorBody: unknown; + try { + errorBody = bodyText + ? (JSON.parse(bodyText) as unknown) + : undefined; + } catch { + errorBody = { error: { message: bodyText } }; + } - const message = - (typeof (errorBody as { error?: { message?: unknown } })?.error?.message === "string" - ? (errorBody as { error?: { message?: string } }).error?.message - : bodyText) || `HTTP ${response.status}`; - throw new Error(message); + const unsupportedInfo = + getUnsupportedCodexModelInfo(errorBody); + if (unsupportedInfo.isUnsupported) { + lastError = new Error( + unsupportedInfo.message ?? + `Model '${model}' unsupported for this account`, + ); + continue; } - lastError = new Error("Codex response did not include quota headers"); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + const message = + (typeof (errorBody as { error?: { message?: unknown } }) + ?.error?.message === "string" + ? (errorBody as { error?: { message?: string } }).error + ?.message + : bodyText) || `HTTP ${response.status}`; + throw new Error(message); } + + lastError = new Error( + "Codex response did not include quota headers", + ); + } catch (error) { + lastError = + error instanceof Error ? error : new Error(String(error)); } + } - throw lastError ?? new Error("Failed to fetch quotas"); - }; + throw lastError ?? new Error("Failed to fetch quotas"); + }; - const runAccountCheck = async (deepProbe: boolean): Promise => { - const loadedStorage = await hydrateEmails(await loadAccounts()); - const workingStorage = loadedStorage - ? { + const runAccountCheck = async ( + deepProbe: boolean, + ): Promise => { + const loadedStorage = await hydrateEmails(await loadAccounts()); + const workingStorage = loadedStorage + ? { ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), + accounts: loadedStorage.accounts.map((account) => ({ + ...account, + })), activeIndexByFamily: loadedStorage.activeIndexByFamily ? { ...loadedStorage.activeIndexByFamily } : {}, } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; + : { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; - if (workingStorage.accounts.length === 0) { - console.log("\nNo accounts to check.\n"); - return; - } + if (workingStorage.accounts.length === 0) { + console.log("\nNo accounts to check.\n"); + return; + } - const flaggedStorage = await loadFlaggedAccounts(); - let storageChanged = false; - let flaggedChanged = false; - const removeFromActive = new Set(); - const total = workingStorage.accounts.length; - let ok = 0; - let disabled = 0; - let errors = 0; + const flaggedStorage = await loadFlaggedAccounts(); + let storageChanged = false; + let flaggedChanged = false; + const removeFromActive = new Set(); + const total = workingStorage.accounts.length; + let ok = 0; + let disabled = 0; + let errors = 0; + + console.log( + `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, + ); - console.log( - `\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`, - ); + for (let i = 0; i < total; i += 1) { + const account = workingStorage.accounts[i]; + if (!account) continue; + const label = + account.email ?? account.accountLabel ?? `Account ${i + 1}`; + if (account.enabled === false) { + disabled += 1; + console.log(`[${i + 1}/${total}] ${label}: DISABLED`); + continue; + } - for (let i = 0; i < total; i += 1) { - const account = workingStorage.accounts[i]; - if (!account) continue; - const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`; - if (account.enabled === false) { - disabled += 1; - console.log(`[${i + 1}/${total}] ${label}: DISABLED`); - continue; + try { + // If we already have a valid cached access token, don't force-refresh. + // This avoids flagging accounts where the refresh token has been burned + // but the access token is still valid (same behavior as Codex CLI). + const nowMs = Date.now(); + let accessToken: string | null = null; + let tokenAccountId: string | undefined; + let authDetail = "OK"; + if ( + account.accessToken && + (typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) || + account.expiresAt > nowMs) + ) { + accessToken = account.accessToken; + authDetail = "OK (cached access)"; + + tokenAccountId = extractAccountId(account.accessToken); + if ( + tokenAccountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + storageChanged = true; + } } - try { - // If we already have a valid cached access token, don't force-refresh. - // This avoids flagging accounts where the refresh token has been burned - // but the access token is still valid (same behavior as Codex CLI). - const nowMs = Date.now(); - let accessToken: string | null = null; - let tokenAccountId: string | undefined = undefined; - let authDetail = "OK"; + // If Codex CLI has a valid cached access token for this email, use it + // instead of forcing a refresh. + if (!accessToken) { + const cached = await lookupCodexCliTokensByEmail( + account.email, + ); if ( - account.accessToken && - (typeof account.expiresAt !== "number" || - !Number.isFinite(account.expiresAt) || - account.expiresAt > nowMs) + cached && + (typeof cached.expiresAt !== "number" || + !Number.isFinite(cached.expiresAt) || + cached.expiresAt > nowMs) ) { - accessToken = account.accessToken; - authDetail = "OK (cached access)"; + accessToken = cached.accessToken; + authDetail = "OK (Codex CLI cache)"; - tokenAccountId = extractAccountId(account.accessToken); if ( - tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && - tokenAccountId !== account.accountId + cached.refreshToken && + cached.refreshToken !== account.refreshToken ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; + account.refreshToken = cached.refreshToken; storageChanged = true; } - - } - - // If Codex CLI has a valid cached access token for this email, use it - // instead of forcing a refresh. - if (!accessToken) { - const cached = await lookupCodexCliTokensByEmail(account.email); if ( - cached && - (typeof cached.expiresAt !== "number" || - !Number.isFinite(cached.expiresAt) || - cached.expiresAt > nowMs) + cached.accessToken && + cached.accessToken !== account.accessToken ) { - accessToken = cached.accessToken; - authDetail = "OK (Codex CLI cache)"; - - if (cached.refreshToken && cached.refreshToken !== account.refreshToken) { - account.refreshToken = cached.refreshToken; - storageChanged = true; - } - if (cached.accessToken && cached.accessToken !== account.accessToken) { - account.accessToken = cached.accessToken; - storageChanged = true; - } - if (cached.expiresAt !== account.expiresAt) { - account.expiresAt = cached.expiresAt; - storageChanged = true; - } - - const hydratedEmail = sanitizeEmail( - extractAccountEmail(cached.accessToken), - ); - if (hydratedEmail && hydratedEmail !== account.email) { - account.email = hydratedEmail; - storageChanged = true; - } - - tokenAccountId = extractAccountId(cached.accessToken); - if ( - tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && - tokenAccountId !== account.accountId - ) { - account.accountId = tokenAccountId; - account.accountIdSource = "token"; - storageChanged = true; - } - } - } - - if (!accessToken) { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type !== "success") { - errors += 1; - const message = - refreshResult.message ?? refreshResult.reason ?? "refresh failed"; - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message})`); - if (deepProbe && isFlaggableFailure(refreshResult)) { - const existingIndex = flaggedStorage.accounts.findIndex( - (flagged) => flagged.refreshToken === account.refreshToken, - ); - const flaggedRecord: FlaggedAccountMetadataV1 = { - ...account, - flaggedAt: Date.now(), - flaggedReason: "token-invalid", - lastError: message, - }; - if (existingIndex >= 0) { - flaggedStorage.accounts[existingIndex] = flaggedRecord; - } else { - flaggedStorage.accounts.push(flaggedRecord); - } - removeFromActive.add(account.refreshToken); - flaggedChanged = true; - } - continue; - } - - accessToken = refreshResult.access; - authDetail = "OK"; - if (refreshResult.refresh !== account.refreshToken) { - account.refreshToken = refreshResult.refresh; - storageChanged = true; - } - if (refreshResult.access && refreshResult.access !== account.accessToken) { - account.accessToken = refreshResult.access; + account.accessToken = cached.accessToken; storageChanged = true; } - if ( - typeof refreshResult.expires === "number" && - refreshResult.expires !== account.expiresAt - ) { - account.expiresAt = refreshResult.expires; + if (cached.expiresAt !== account.expiresAt) { + account.expiresAt = cached.expiresAt; storageChanged = true; } + const hydratedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), + extractAccountEmail(cached.accessToken), ); if (hydratedEmail && hydratedEmail !== account.email) { account.email = hydratedEmail; storageChanged = true; } - tokenAccountId = extractAccountId(refreshResult.access); + + tokenAccountId = extractAccountId(cached.accessToken); if ( tokenAccountId && - shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId) && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && tokenAccountId !== account.accountId ) { account.accountId = tokenAccountId; @@ -2883,200 +3286,316 @@ while (attempted.size < Math.max(1, accountCount)) { storageChanged = true; } } + } - if (!accessToken) { - throw new Error("Missing access token after refresh"); + if (!accessToken) { + const refreshResult = await queuedRefresh( + account.refreshToken, + ); + if (refreshResult.type !== "success") { + errors += 1; + const message = + refreshResult.message ?? + refreshResult.reason ?? + "refresh failed"; + console.log( + `[${i + 1}/${total}] ${label}: ERROR (${message})`, + ); + if (deepProbe && isFlaggableFailure(refreshResult)) { + const existingIndex = flaggedStorage.accounts.findIndex( + (flagged) => + flagged.refreshToken === account.refreshToken, + ); + const flaggedRecord: FlaggedAccountMetadataV1 = { + ...account, + flaggedAt: Date.now(), + flaggedReason: "token-invalid", + lastError: message, + }; + if (existingIndex >= 0) { + flaggedStorage.accounts[existingIndex] = + flaggedRecord; + } else { + flaggedStorage.accounts.push(flaggedRecord); + } + removeFromActive.add(account.refreshToken); + flaggedChanged = true; + } + continue; } - if (deepProbe) { - ok += 1; - const detail = - tokenAccountId - ? `${authDetail} (id:${tokenAccountId.slice(-6)})` - : authDetail; - console.log(`[${i + 1}/${total}] ${label}: ${detail}`); - continue; + accessToken = refreshResult.access; + authDetail = "OK"; + if (refreshResult.refresh !== account.refreshToken) { + account.refreshToken = refreshResult.refresh; + storageChanged = true; + } + if ( + refreshResult.access && + refreshResult.access !== account.accessToken + ) { + account.accessToken = refreshResult.access; + storageChanged = true; + } + if ( + typeof refreshResult.expires === "number" && + refreshResult.expires !== account.expiresAt + ) { + account.expiresAt = refreshResult.expires; + storageChanged = true; + } + const hydratedEmail = sanitizeEmail( + extractAccountEmail( + refreshResult.access, + refreshResult.idToken, + ), + ); + if (hydratedEmail && hydratedEmail !== account.email) { + account.email = hydratedEmail; + storageChanged = true; + } + tokenAccountId = extractAccountId(refreshResult.access); + if ( + tokenAccountId && + shouldUpdateAccountIdFromToken( + account.accountIdSource, + account.accountId, + ) && + tokenAccountId !== account.accountId + ) { + account.accountId = tokenAccountId; + account.accountIdSource = "token"; + storageChanged = true; } + } - try { - const requestAccountId = - resolveRequestAccountId( - account.accountId, - account.accountIdSource, - tokenAccountId, - ) ?? - tokenAccountId ?? - account.accountId; + if (!accessToken) { + throw new Error("Missing access token after refresh"); + } - if (!requestAccountId) { - throw new Error("Missing accountId for quota probe"); - } + if (deepProbe) { + ok += 1; + const detail = tokenAccountId + ? `${authDetail} (id:${tokenAccountId.slice(-6)})` + : authDetail; + console.log(`[${i + 1}/${total}] ${label}: ${detail}`); + continue; + } - const snapshot = await fetchCodexQuotaSnapshot({ - accountId: requestAccountId, - accessToken, - }); - ok += 1; - console.log( - `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, - ); - } catch (error) { - errors += 1; - const message = error instanceof Error ? error.message : String(error); - console.log( - `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, - ); + try { + const requestAccountId = + resolveRequestAccountId( + account.accountId, + account.accountIdSource, + tokenAccountId, + ) ?? + tokenAccountId ?? + account.accountId; + + if (!requestAccountId) { + throw new Error("Missing accountId for quota probe"); } + + const snapshot = await fetchCodexQuotaSnapshot({ + accountId: requestAccountId, + accessToken, + }); + ok += 1; + console.log( + `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(snapshot)}`, + ); } catch (error) { errors += 1; - const message = error instanceof Error ? error.message : String(error); - console.log(`[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`); + const message = + error instanceof Error ? error.message : String(error); + console.log( + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 160)})`, + ); } - } - - if (removeFromActive.size > 0) { - workingStorage.accounts = workingStorage.accounts.filter( - (account) => !removeFromActive.has(account.refreshToken), - ); - clampActiveIndices(workingStorage); - storageChanged = true; - } - - if (storageChanged) { - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - } - if (flaggedChanged) { - await saveFlaggedAccounts(flaggedStorage); - } - - console.log(""); - console.log(`Results: ${ok} ok, ${errors} error, ${disabled} disabled`); - if (removeFromActive.size > 0) { + } catch (error) { + errors += 1; + const message = + error instanceof Error ? error.message : String(error); console.log( - `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + `[${i + 1}/${total}] ${label}: ERROR (${message.slice(0, 120)})`, ); } - console.log(""); - }; + } - const verifyFlaggedAccounts = async (): Promise => { - const flaggedStorage = await loadFlaggedAccounts(); - if (flaggedStorage.accounts.length === 0) { - console.log("\nNo flagged accounts to verify.\n"); - return; - } + if (removeFromActive.size > 0) { + workingStorage.accounts = workingStorage.accounts.filter( + (account) => !removeFromActive.has(account.refreshToken), + ); + clampActiveIndices(workingStorage); + storageChanged = true; + } + + if (storageChanged) { + await saveAccounts(workingStorage); + invalidateAccountManagerCache(); + } + if (flaggedChanged) { + await saveFlaggedAccounts(flaggedStorage); + } - console.log("\nVerifying flagged accounts...\n"); - const remaining: FlaggedAccountMetadataV1[] = []; - const restored: TokenSuccessWithAccount[] = []; + console.log(""); + console.log( + `Results: ${ok} ok, ${errors} error, ${disabled} disabled`, + ); + if (removeFromActive.size > 0) { + console.log( + `Moved ${removeFromActive.size} account(s) to flagged pool (invalid refresh token).`, + ); + } + console.log(""); + }; - for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { - const flagged = flaggedStorage.accounts[i]; - if (!flagged) continue; - const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; - try { - const cached = await lookupCodexCliTokensByEmail(flagged.email); - const now = Date.now(); - if ( - cached && - typeof cached.expiresAt === "number" && - Number.isFinite(cached.expiresAt) && - cached.expiresAt > now - ) { - const refreshToken = - typeof cached.refreshToken === "string" && cached.refreshToken.trim() - ? cached.refreshToken.trim() - : flagged.refreshToken; - const resolved = resolveAccountSelection({ - type: "success", - access: cached.accessToken, - refresh: refreshToken, - expires: cached.expiresAt, - multiAccount: true, - }); - if (!resolved.accountIdOverride && flagged.accountId) { - resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = flagged.accountIdSource ?? "manual"; - } - if (!resolved.accountLabel && flagged.accountLabel) { - resolved.accountLabel = flagged.accountLabel; - } - restored.push(resolved); - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, - ); - continue; - } + const verifyFlaggedAccounts = async (): Promise => { + const flaggedStorage = await loadFlaggedAccounts(); + if (flaggedStorage.accounts.length === 0) { + console.log("\nNo flagged accounts to verify.\n"); + return; + } - const refreshResult = await queuedRefresh(flagged.refreshToken); - if (refreshResult.type !== "success") { - console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, - ); - remaining.push(flagged); - continue; - } + console.log("\nVerifying flagged accounts...\n"); + const remaining: FlaggedAccountMetadataV1[] = []; + const restored: TokenSuccessWithAccount[] = []; - const resolved = resolveAccountSelection(refreshResult); + for (let i = 0; i < flaggedStorage.accounts.length; i += 1) { + const flagged = flaggedStorage.accounts[i]; + if (!flagged) continue; + const label = + flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`; + try { + const cached = await lookupCodexCliTokensByEmail( + flagged.email, + ); + const now = Date.now(); + if ( + cached && + typeof cached.expiresAt === "number" && + Number.isFinite(cached.expiresAt) && + cached.expiresAt > now + ) { + const refreshToken = + typeof cached.refreshToken === "string" && + cached.refreshToken.trim() + ? cached.refreshToken.trim() + : flagged.refreshToken; + const resolved = resolveAccountSelection({ + type: "success", + access: cached.accessToken, + refresh: refreshToken, + expires: cached.expiresAt, + multiAccount: true, + }); if (!resolved.accountIdOverride && flagged.accountId) { resolved.accountIdOverride = flagged.accountId; - resolved.accountIdSource = flagged.accountIdSource ?? "manual"; + resolved.accountIdSource = + flagged.accountIdSource ?? "manual"; } if (!resolved.accountLabel && flagged.accountLabel) { resolved.accountLabel = flagged.accountLabel; } restored.push(resolved); - console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); console.log( - `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`, ); - remaining.push({ - ...flagged, - lastError: message, - }); + continue; } - } - if (restored.length > 0) { - await persistAccountPool(restored, false); - invalidateAccountManagerCache(); + const refreshResult = await queuedRefresh( + flagged.refreshToken, + ); + if (refreshResult.type !== "success") { + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`, + ); + remaining.push(flagged); + continue; + } + + const resolved = resolveAccountSelection(refreshResult); + if (!resolved.accountIdOverride && flagged.accountId) { + resolved.accountIdOverride = flagged.accountId; + resolved.accountIdSource = + flagged.accountIdSource ?? "manual"; + } + if (!resolved.accountLabel && flagged.accountLabel) { + resolved.accountLabel = flagged.accountLabel; + } + restored.push(resolved); + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.log( + `[${i + 1}/${flaggedStorage.accounts.length}] ${label}: ERROR (${message.slice(0, 120)})`, + ); + remaining.push({ + ...flagged, + lastError: message, + }); } + } - await saveFlaggedAccounts({ - version: 1, - accounts: remaining, - }); + if (restored.length > 0) { + await persistAccountPool(restored, false); + invalidateAccountManagerCache(); + } - console.log(""); - console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`); - console.log(""); - }; + await saveFlaggedAccounts({ + version: 1, + accounts: remaining, + }); - if (!explicitLoginMode) { - while (true) { - const loadedStorage = await hydrateEmails(await loadAccounts()); - const workingStorage = loadedStorage - ? { + console.log(""); + console.log( + `Results: ${restored.length} restored, ${remaining.length} still flagged`, + ); + console.log(""); + }; + + if (!explicitLoginMode) { + while (true) { + const loadedStorage = await hydrateEmails(await loadAccounts()); + let workingStorage = loadedStorage + ? { ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), + accounts: loadedStorage.accounts.map((account) => ({ + ...account, + })), activeIndexByFamily: loadedStorage.activeIndexByFamily ? { ...loadedStorage.activeIndexByFamily } : {}, } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; - const flaggedStorage = await loadFlaggedAccounts(); + : { + version: 3 as const, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }; + let flaggedStorage = await loadFlaggedAccounts(); - if (workingStorage.accounts.length === 0 && flaggedStorage.accounts.length === 0) { - break; - } + if ( + workingStorage.accounts.length === 0 && + flaggedStorage.accounts.length === 0 + ) { + break; + } - const now = Date.now(); - const activeIndex = resolveActiveIndex(workingStorage, "codex"); - const existingAccounts = workingStorage.accounts.map((account, index) => { - let status: "active" | "ok" | "rate-limited" | "cooldown" | "disabled"; + const now = Date.now(); + const activeIndex = resolveActiveIndex(workingStorage, "codex"); + const existingAccounts = workingStorage.accounts.map( + (account, index) => { + let status: + | "active" + | "ok" + | "rate-limited" + | "cooldown" + | "disabled"; if (account.enabled === false) { status = "disabled"; } else if ( @@ -3102,204 +3621,166 @@ while (attempted.size < Math.max(1, accountCount)) { isCurrentAccount: index === activeIndex, enabled: account.enabled !== false, }; - }); - - const menuResult = await promptLoginMode(existingAccounts, { - flaggedCount: flaggedStorage.accounts.length, - }); - - if (menuResult.mode === "cancel") { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; - } + }, + ); - if (menuResult.mode === "check") { - await runAccountCheck(false); - continue; - } - if (menuResult.mode === "deep-check") { - await runAccountCheck(true); - continue; - } - if (menuResult.mode === "verify-flagged") { - await verifyFlaggedAccounts(); - continue; - } + const menuResult = await promptLoginMode(existingAccounts, { + flaggedCount: flaggedStorage.accounts.length, + }); - if (menuResult.mode === "manage") { - if (typeof menuResult.deleteAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.deleteAccountIndex]; - if (target) { - workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1); - clampActiveIndices(workingStorage); - await saveAccounts(workingStorage); - await saveFlaggedAccounts({ - version: 1, - accounts: flaggedStorage.accounts.filter( - (flagged) => flagged.refreshToken !== target.refreshToken, - ), - }); - invalidateAccountManagerCache(); - console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`); - } - continue; - } + if (menuResult.mode === "cancel") { + return { + url: "", + instructions: "Authentication cancelled", + method: "auto", + callback: () => + Promise.resolve({ + type: "failed" as const, + }), + }; + } - if (typeof menuResult.toggleAccountIndex === "number") { - const target = workingStorage.accounts[menuResult.toggleAccountIndex]; - if (target) { - target.enabled = target.enabled === false ? true : false; - await saveAccounts(workingStorage); - invalidateAccountManagerCache(); - console.log( - `\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`, - ); - } - continue; - } + if (menuResult.mode === "check") { + await runAccountCheck(false); + continue; + } + if (menuResult.mode === "deep-check") { + await runAccountCheck(true); + continue; + } + if (menuResult.mode === "verify-flagged") { + await verifyFlaggedAccounts(); + continue; + } - if (typeof menuResult.refreshAccountIndex === "number") { - refreshAccountIndex = menuResult.refreshAccountIndex; - startFresh = false; - break; + if (menuResult.mode === "manage") { + if (typeof menuResult.deleteAccountIndex === "number") { + const deleted = await deleteAccountAtIndex({ + storage: workingStorage, + index: menuResult.deleteAccountIndex, + flaggedStorage, + }); + if (deleted) { + workingStorage = { + ...deleted.storage, + activeIndexByFamily: + deleted.storage.activeIndexByFamily ?? {}, + }; + flaggedStorage = deleted.flagged; + invalidateAccountManagerCache(); + const label = formatAccountLabel( + deleted.removedAccount, + menuResult.deleteAccountIndex, + ); + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`\nDeleted ${label}.${flaggedNote}\n`); } - continue; } - if (menuResult.mode === "fresh") { - startFresh = true; - if (menuResult.deleteAll) { - await clearAccounts(); - await clearFlaggedAccounts(); + if (typeof menuResult.toggleAccountIndex === "number") { + const target = + workingStorage.accounts[menuResult.toggleAccountIndex]; + if (target) { + target.enabled = target.enabled === false ? true : false; + await saveAccounts(workingStorage); invalidateAccountManagerCache(); - console.log("\nDeleted all accounts. Starting fresh.\n"); + console.log( + `\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`, + ); } + continue; + } + + if (typeof menuResult.refreshAccountIndex === "number") { + refreshAccountIndex = menuResult.refreshAccountIndex; + startFresh = false; break; } - startFresh = false; - break; + continue; } - } - const latestStorage = await loadAccounts(); - const existingCount = latestStorage?.accounts.length ?? 0; - const requestedCount = Number.parseInt(inputs?.accountCount ?? "1", 10); - const normalizedRequested = Number.isFinite(requestedCount) ? requestedCount : 1; - const availableSlots = - refreshAccountIndex !== undefined - ? 1 - : startFresh - ? ACCOUNT_LIMITS.MAX_ACCOUNTS - : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount; - - if (availableSlots <= 0) { - return { - url: "", - instructions: "Account limit reached. Remove an account or start fresh.", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; - } - - let targetCount = Math.max(1, Math.min(normalizedRequested, availableSlots)); - if (refreshAccountIndex !== undefined) { - targetCount = 1; - } - if (useManualMode) { - targetCount = 1; - } - - if (useManualMode) { - const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { - try { - await persistAccountPool([tokens], startFresh); + if (menuResult.mode === "fresh") { + startFresh = true; + if (menuResult.deleteAll) { + await deleteSavedAccounts(); invalidateAccountManagerCache(); - } catch (err) { - const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; - const hint = - err instanceof StorageError - ? err.hint - : formatStorageErrorHint(err, storagePath); - logError( - `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, + console.log( + `\n${DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed}\n`, ); - await showToast(hint, "error", { - title: "Account Persistence Failed", - duration: 10000, - }); - } - }); - } - - const explicitCountProvided = - typeof inputs?.accountCount === "string" && inputs.accountCount.trim().length > 0; - - while (accounts.length < targetCount) { - logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`); - const forceNewLogin = accounts.length > 0 || refreshAccountIndex !== undefined; - const result = await runOAuthFlow(forceNewLogin); - - let resolved: TokenSuccessWithAccount | null = null; - if (result.type === "success") { - resolved = resolveAccountSelection(result); - const email = extractAccountEmail(resolved.access, resolved.idToken); - const accountId = resolved.accountIdOverride ?? extractAccountId(resolved.access); - const label = resolved.accountLabel ?? email ?? accountId ?? "Unknown account"; - logInfo(`Authenticated as: ${label}`); - - const isDuplicate = accounts.some( - (account) => - (accountId && - (account.accountIdOverride ?? extractAccountId(account.access)) === accountId) || - (email && extractAccountEmail(account.access, account.idToken) === email), - ); - - if (isDuplicate) { - logWarn(`WARNING: duplicate account login detected (${label}). Existing entry will be updated.`); } + break; } - if (result.type === "failed") { - if (accounts.length === 0) { - return { - url: "", - instructions: "Authentication failed.", - method: "auto", - callback: () => Promise.resolve(result), - }; - } - logWarn(`[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`); + if (menuResult.mode === "reset") { + startFresh = true; + await resetLocalState(); + invalidateAccountManagerCache(); + console.log( + `\n${DESTRUCTIVE_ACTION_COPY.resetLocalState.completed}\n`, + ); break; } - if (!resolved) { - continue; - } + startFresh = false; + break; + } + } + + const latestStorage = await loadAccounts(); + const existingCount = latestStorage?.accounts.length ?? 0; + const requestedCount = Number.parseInt( + inputs?.accountCount ?? "1", + 10, + ); + const normalizedRequested = Number.isFinite(requestedCount) + ? requestedCount + : 1; + const availableSlots = + refreshAccountIndex !== undefined + ? 1 + : startFresh + ? ACCOUNT_LIMITS.MAX_ACCOUNTS + : ACCOUNT_LIMITS.MAX_ACCOUNTS - existingCount; + + if (availableSlots <= 0) { + return { + url: "", + instructions: + "Account limit reached. Remove an account or start fresh.", + method: "auto", + callback: () => + Promise.resolve({ + type: "failed" as const, + }), + }; + } - accounts.push(resolved); - await showToast(`Account ${accounts.length} authenticated`, "success"); + let targetCount = Math.max( + 1, + Math.min(normalizedRequested, availableSlots), + ); + if (refreshAccountIndex !== undefined) { + targetCount = 1; + } + if (useManualMode) { + targetCount = 1; + } + if (useManualMode) { + const { pkce, state, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow(pkce, url, state, async (tokens) => { try { - const isFirstAccount = accounts.length === 1; - await persistAccountPool([resolved], isFirstAccount && startFresh); + await persistAccountPool([tokens], startFresh); invalidateAccountManagerCache(); } catch (err) { const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; const hint = err instanceof StorageError ? err.hint @@ -3312,106 +3793,206 @@ while (attempted.size < Math.max(1, accountCount)) { duration: 10000, }); } + }); + } - if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - break; + const explicitCountProvided = + typeof inputs?.accountCount === "string" && + inputs.accountCount.trim().length > 0; + + while (accounts.length < targetCount) { + logInfo(`=== OpenAI OAuth (Account ${accounts.length + 1}) ===`); + const forceNewLogin = + accounts.length > 0 || refreshAccountIndex !== undefined; + const result = await runOAuthFlow(forceNewLogin); + + let resolved: TokenSuccessWithAccount | null = null; + if (result.type === "success") { + resolved = resolveAccountSelection(result); + const email = extractAccountEmail( + resolved.access, + resolved.idToken, + ); + const accountId = + resolved.accountIdOverride ?? + extractAccountId(resolved.access); + const label = + resolved.accountLabel ?? + email ?? + accountId ?? + "Unknown account"; + logInfo(`Authenticated as: ${label}`); + + const isDuplicate = accounts.some( + (account) => + (accountId && + (account.accountIdOverride ?? + extractAccountId(account.access)) === accountId) || + (email && + extractAccountEmail(account.access, account.idToken) === + email), + ); + + if (isDuplicate) { + logWarn( + `WARNING: duplicate account login detected (${label}). Existing entry will be updated.`, + ); } + } - if ( - !explicitCountProvided && - refreshAccountIndex === undefined && - accounts.length < availableSlots && - accounts.length >= targetCount - ) { - const addMore = await promptAddAnotherAccount(accounts.length); - if (addMore) { - targetCount = Math.min(targetCount + 1, availableSlots); - continue; - } - break; + if (result.type === "failed") { + if (accounts.length === 0) { + return { + url: "", + instructions: "Authentication failed.", + method: "auto", + callback: () => Promise.resolve(result), + }; } + logWarn( + `[${PLUGIN_NAME}] Skipping failed account ${accounts.length + 1}`, + ); + break; } - const primary = accounts[0]; - if (!primary) { - return { - url: "", - instructions: "Authentication cancelled", - method: "auto", - callback: () => - Promise.resolve({ - type: "failed" as const, - }), - }; + if (!resolved) { + continue; } - let actualAccountCount = accounts.length; + accounts.push(resolved); + await showToast( + `Account ${accounts.length} authenticated`, + "success", + ); + try { - const finalStorage = await loadAccounts(); - if (finalStorage) { - actualAccountCount = finalStorage.accounts.length; - } + const isFirstAccount = accounts.length === 1; + await persistAccountPool( + [resolved], + isFirstAccount && startFresh, + ); + invalidateAccountManagerCache(); } catch (err) { - logWarn( - `[${PLUGIN_NAME}] Failed to load final account count: ${(err as Error)?.message ?? String(err)}`, + const storagePath = getStoragePath(); + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const hint = + err instanceof StorageError + ? err.hint + : formatStorageErrorHint(err, storagePath); + logError( + `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, ); + await showToast(hint, "error", { + title: "Account Persistence Failed", + duration: 10000, + }); + } + + if (accounts.length >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { + break; + } + + if ( + !explicitCountProvided && + refreshAccountIndex === undefined && + accounts.length < availableSlots && + accounts.length >= targetCount + ) { + const addMore = await promptAddAnotherAccount(accounts.length); + if (addMore) { + targetCount = Math.min(targetCount + 1, availableSlots); + continue; + } + break; } + } + const primary = accounts[0]; + if (!primary) { return { url: "", - instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`, + instructions: "Authentication cancelled", method: "auto", - callback: () => Promise.resolve(primary), + callback: () => + Promise.resolve({ + type: "failed" as const, + }), }; - }, + } + + let actualAccountCount = accounts.length; + try { + const finalStorage = await loadAccounts(); + if (finalStorage) { + actualAccountCount = finalStorage.accounts.length; + } + } catch (err) { + logWarn( + `[${PLUGIN_NAME}] Failed to load final account count: ${(err as Error)?.message ?? String(err)}`, + ); + } + + return { + url: "", + instructions: `Multi-account setup complete (${actualAccountCount} account(s)).`, + method: "auto", + callback: () => Promise.resolve(primary), + }; }, + }, { label: AUTH_LABELS.OAUTH_MANUAL, type: "oauth" as const, - authorize: async () => { - // Initialize storage path for manual OAuth flow - // Must happen BEFORE persistAccountPool to ensure correct storage location - const manualPluginConfig = loadPluginConfig(); - applyUiRuntimeFromConfig(manualPluginConfig); - applyAccountStorageScope(manualPluginConfig); - - const { pkce, state, url } = await createAuthorizationFlow(); - return buildManualOAuthFlow(pkce, url, state, async (tokens) => { - try { - await persistAccountPool([tokens], false); - } catch (err) { - const storagePath = getStoragePath(); - const errorCode = (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; - const hint = err instanceof StorageError ? err.hint : formatStorageErrorHint(err, storagePath); - logError(`[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`); - await showToast( - hint, - "error", - { title: "Account Persistence Failed", duration: 10000 }, - ); - } - }); - }, - }, - ], - }, - tool: { - edit: createHashlineEditTool(), - // Legacy runtime v1.2.x exposes apply_patch (not edit) to the model. - // Register the same hashline-capable implementation under both names. - apply_patch: createHashlineEditTool(), - hashline_read: createHashlineReadTool(), - "codex-list": tool({ - description: - "List all Codex OAuth accounts and the current active index.", - args: {}, - async execute() { + authorize: async () => { + // Initialize storage path for manual OAuth flow + // Must happen BEFORE persistAccountPool to ensure correct storage location + const manualPluginConfig = loadPluginConfig(); + applyUiRuntimeFromConfig(manualPluginConfig); + applyAccountStorageScope(manualPluginConfig); + + const { pkce, state, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow(pkce, url, state, async (tokens) => { + try { + await persistAccountPool([tokens], false); + } catch (err) { + const storagePath = getStoragePath(); + const errorCode = + (err as NodeJS.ErrnoException)?.code || "UNKNOWN"; + const hint = + err instanceof StorageError + ? err.hint + : formatStorageErrorHint(err, storagePath); + logError( + `[${PLUGIN_NAME}] Failed to persist account: [${errorCode}] ${(err as Error)?.message ?? String(err)}`, + ); + await showToast(hint, "error", { + title: "Account Persistence Failed", + duration: 10000, + }); + } + }); + }, + }, + ], + }, + tool: { + edit: createHashlineEditTool(), + // Legacy runtime v1.2.x exposes apply_patch (not edit) to the model. + // Register the same hashline-capable implementation under both names. + apply_patch: createHashlineEditTool(), + hashline_read: createHashlineReadTool(), + "codex-list": tool({ + description: + "List all Codex OAuth accounts and the current active index.", + args: {}, + async execute() { const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - const storePath = getStoragePath(); + const storage = await loadAccounts(); + const storePath = getStoragePath(); - if (!storage || storage.accounts.length === 0) { + if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Codex accounts"), @@ -3421,15 +4002,15 @@ while (attempted.size < Math.max(1, accountCount)) { formatUiKeyValue(ui, "Storage", storePath, "muted"), ].join("\n"); } - return [ - "No Codex accounts configured.", - "", - "Add accounts:", - " codex login", - "", - `Storage: ${storePath}`, - ].join("\n"); - } + return [ + "No Codex accounts configured.", + "", + "Add accounts:", + " codex login", + "", + `Storage: ${storePath}`, + ].join("\n"); + } const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); @@ -3445,10 +4026,13 @@ while (attempted.size < Math.max(1, accountCount)) { storage.accounts.forEach((account, index) => { const label = formatAccountLabel(account, index); const badges: string[] = []; - if (index === activeIndex) badges.push(formatUiBadge(ui, "current", "accent")); - if (account.enabled === false) badges.push(formatUiBadge(ui, "disabled", "danger")); + if (index === activeIndex) + badges.push(formatUiBadge(ui, "current", "accent")); + if (account.enabled === false) + badges.push(formatUiBadge(ui, "disabled", "danger")); const rateLimit = formatRateLimitEntry(account, now); - if (rateLimit) badges.push(formatUiBadge(ui, "rate-limited", "warning")); + if (rateLimit) + badges.push(formatUiBadge(ui, "rate-limited", "warning")); if ( typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now @@ -3459,21 +4043,30 @@ while (attempted.size < Math.max(1, accountCount)) { badges.push(formatUiBadge(ui, "ok", "success")); } - lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim())); + lines.push( + formatUiItem( + ui, + `${index + 1}. ${label} ${badges.join(" ")}`.trim(), + ), + ); if (rateLimit) { - lines.push(` ${paintUiText(ui, `rate limit: ${rateLimit}`, "muted")}`); + lines.push( + ` ${paintUiText(ui, `rate limit: ${rateLimit}`, "muted")}`, + ); } }); lines.push(""); lines.push(...formatUiSection(ui, "Commands")); lines.push(formatUiItem(ui, "Add account: codex login", "accent")); - lines.push(formatUiItem(ui, "Switch account: codex-switch ")); + lines.push( + formatUiItem(ui, "Switch account: codex-switch "), + ); lines.push(formatUiItem(ui, "Detailed status: codex-status")); lines.push(formatUiItem(ui, "Health check: codex-health")); return lines.join("\n"); } - + const listTableOptions: TableOptions = { columns: [ { header: "#", width: 3 }, @@ -3481,56 +4074,59 @@ while (attempted.size < Math.max(1, accountCount)) { { header: "Status", width: 20 }, ], }; - + const lines: string[] = [ `Codex Accounts (${storage.accounts.length}):`, "", ...buildTableHeader(listTableOptions), ]; - storage.accounts.forEach((account, index) => { - const label = formatAccountLabel(account, index); - const statuses: string[] = []; - const rateLimit = formatRateLimitEntry( - account, - now, - ); - if (index === activeIndex) statuses.push("active"); - if (rateLimit) statuses.push("rate-limited"); - if ( - typeof account.coolingDownUntil === - "number" && - account.coolingDownUntil > now - ) { - statuses.push("cooldown"); - } - const statusText = statuses.length > 0 ? statuses.join(", ") : "ok"; - lines.push(buildTableRow([String(index + 1), label, statusText], listTableOptions)); - }); - - lines.push(""); - lines.push(`Storage: ${storePath}`); - lines.push(""); - lines.push("Commands:"); - lines.push(" - Add account: codex login"); - lines.push(" - Switch account: codex-switch"); - lines.push(" - Status details: codex-status"); - lines.push(" - Health check: codex-health"); - - return lines.join("\n"); - }, - }), - "codex-switch": tool({ - description: "Switch active Codex account by index (1-based).", - args: { - index: tool.schema.number().describe( - "Account number to switch to (1-based, e.g., 1 for first account)", - ), - }, - async execute({ index }) { + storage.accounts.forEach((account, index) => { + const label = formatAccountLabel(account, index); + const statuses: string[] = []; + const rateLimit = formatRateLimitEntry(account, now); + if (index === activeIndex) statuses.push("active"); + if (rateLimit) statuses.push("rate-limited"); + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { + statuses.push("cooldown"); + } + const statusText = statuses.length > 0 ? statuses.join(", ") : "ok"; + lines.push( + buildTableRow( + [String(index + 1), label, statusText], + listTableOptions, + ), + ); + }); + + lines.push(""); + lines.push(`Storage: ${storePath}`); + lines.push(""); + lines.push("Commands:"); + lines.push(" - Add account: codex login"); + lines.push(" - Switch account: codex-switch"); + lines.push(" - Status details: codex-status"); + lines.push(" - Health check: codex-health"); + + return lines.join("\n"); + }, + }), + "codex-switch": tool({ + description: "Switch active Codex account by index (1-based).", + args: { + index: tool.schema + .number() + .describe( + "Account number to switch to (1-based, e.g., 1 for first account)", + ), + }, + async execute({ index }) { const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), @@ -3539,48 +4135,63 @@ while (attempted.size < Math.max(1, accountCount)) { formatUiItem(ui, "Run: codex login", "accent"), ].join("\n"); } - return "No Codex accounts configured. Run: codex login"; - } - - const targetIndex = Math.floor((index ?? 0) - 1); - if ( - !Number.isFinite(targetIndex) || - targetIndex < 0 || - targetIndex >= storage.accounts.length - ) { + return "No Codex accounts configured. Run: codex login"; + } + + const targetIndex = Math.floor((index ?? 0) - 1); + if ( + !Number.isFinite(targetIndex) || + targetIndex < 0 || + targetIndex >= storage.accounts.length + ) { if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", formatUiItem(ui, `Invalid account number: ${index}`, "danger"), - formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"), + formatUiKeyValue( + ui, + "Valid range", + `1-${storage.accounts.length}`, + "muted", + ), ].join("\n"); } - return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`; - } + return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`; + } - const now = Date.now(); - const account = storage.accounts[targetIndex]; - if (account) { - account.lastUsed = now; - account.lastSwitchReason = "rotation"; - } + const now = Date.now(); + const account = storage.accounts[targetIndex]; + if (account) { + account.lastUsed = now; + account.lastSwitchReason = "rotation"; + } storage.activeIndex = targetIndex; storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; + storage.activeIndexByFamily[family] = targetIndex; } try { await saveAccounts(storage); } catch (saveError) { - logWarn("Failed to save account switch", { error: String(saveError) }); + logWarn("Failed to save account switch", { + error: String(saveError), + }); if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", - formatUiItem(ui, `Switched to ${formatAccountLabel(account, targetIndex)}`, "warning"), - formatUiItem(ui, "Failed to persist change. It may be lost on restart.", "danger"), + formatUiItem( + ui, + `Switched to ${formatAccountLabel(account, targetIndex)}`, + "warning", + ), + formatUiItem( + ui, + "Failed to persist change. It may be lost on restart.", + "danger", + ), ].join("\n"); } return `Switched to ${formatAccountLabel(account, targetIndex)} but failed to persist. Changes may be lost on restart.`; @@ -3590,17 +4201,21 @@ while (attempted.size < Math.max(1, accountCount)) { await reloadAccountManagerFromDisk(); } - const label = formatAccountLabel(account, targetIndex); + const label = formatAccountLabel(account, targetIndex); if (ui.v2Enabled) { return [ ...formatUiHeader(ui, "Switch account"), "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Switched to ${label}`, "success"), + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Switched to ${label}`, + "success", + ), ].join("\n"); } - return `Switched to account: ${label}`; - }, - }), + return `Switched to account: ${label}`; + }, + }), "codex-status": tool({ description: "Show detailed status of Codex accounts and rate limits.", args: {}, @@ -3619,215 +4234,363 @@ while (attempted.size < Math.max(1, accountCount)) { return "No Codex accounts configured. Run: codex login"; } - const now = Date.now(); - const activeIndex = resolveActiveIndex(storage, "codex"); - if (ui.v2Enabled) { + const now = Date.now(); + const activeIndex = resolveActiveIndex(storage, "codex"); + if (ui.v2Enabled) { + const lines: string[] = [ + ...formatUiHeader(ui, "Account status"), + formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + "", + ...formatUiSection(ui, "Accounts"), + ]; + + storage.accounts.forEach((account, index) => { + const label = formatAccountLabel(account, index); + const badges: string[] = []; + if (index === activeIndex) + badges.push(formatUiBadge(ui, "active", "accent")); + if (account.enabled === false) + badges.push(formatUiBadge(ui, "disabled", "danger")); + const rateLimit = formatRateLimitEntry(account, now) ?? "none"; + const cooldown = formatCooldown(account, now) ?? "none"; + if (rateLimit !== "none") + badges.push(formatUiBadge(ui, "rate-limited", "warning")); + if (cooldown !== "none") + badges.push(formatUiBadge(ui, "cooldown", "warning")); + if (badges.length === 0) + badges.push(formatUiBadge(ui, "ok", "success")); + + lines.push( + formatUiItem( + ui, + `${index + 1}. ${label} ${badges.join(" ")}`.trim(), + ), + ); + lines.push( + ` ${formatUiKeyValue(ui, "rate limit", rateLimit, rateLimit === "none" ? "muted" : "warning")}`, + ); + lines.push( + ` ${formatUiKeyValue(ui, "cooldown", cooldown, cooldown === "none" ? "muted" : "warning")}`, + ); + }); + + lines.push(""); + lines.push(...formatUiSection(ui, "Active index by model family")); + for (const family of MODEL_FAMILIES) { + const idx = storage.activeIndexByFamily?.[family]; + const familyIndexLabel = + typeof idx === "number" && Number.isFinite(idx) + ? String(idx + 1) + : "-"; + lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`)); + } + + lines.push(""); + lines.push( + ...formatUiSection( + ui, + "Rate limits by model family (per account)", + ), + ); + storage.accounts.forEach((account, index) => { + const statuses = MODEL_FAMILIES.map((family) => { + const resetAt = getRateLimitResetTimeForFamily( + account, + now, + family, + ); + if (typeof resetAt !== "number") return `${family}=ok`; + return `${family}=${formatWaitTime(resetAt - now)}`; + }); + lines.push( + formatUiItem( + ui, + `Account ${index + 1}: ${statuses.join(" | ")}`, + ), + ); + }); + + return lines.join("\n"); + } + + const statusTableOptions: TableOptions = { + columns: [ + { header: "#", width: 3 }, + { header: "Label", width: 42 }, + { header: "Active", width: 6 }, + { header: "Rate Limit", width: 16 }, + { header: "Cooldown", width: 16 }, + { header: "Last Used", width: 16 }, + ], + }; + const lines: string[] = [ - ...formatUiHeader(ui, "Account status"), - formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + `Account Status (${storage.accounts.length} total):`, "", - ...formatUiSection(ui, "Accounts"), + ...buildTableHeader(statusTableOptions), ]; storage.accounts.forEach((account, index) => { const label = formatAccountLabel(account, index); - const badges: string[] = []; - if (index === activeIndex) badges.push(formatUiBadge(ui, "active", "accent")); - if (account.enabled === false) badges.push(formatUiBadge(ui, "disabled", "danger")); - const rateLimit = formatRateLimitEntry(account, now) ?? "none"; - const cooldown = formatCooldown(account, now) ?? "none"; - if (rateLimit !== "none") badges.push(formatUiBadge(ui, "rate-limited", "warning")); - if (cooldown !== "none") badges.push(formatUiBadge(ui, "cooldown", "warning")); - if (badges.length === 0) badges.push(formatUiBadge(ui, "ok", "success")); - - lines.push(formatUiItem(ui, `${index + 1}. ${label} ${badges.join(" ")}`.trim())); - lines.push(` ${formatUiKeyValue(ui, "rate limit", rateLimit, rateLimit === "none" ? "muted" : "warning")}`); - lines.push(` ${formatUiKeyValue(ui, "cooldown", cooldown, cooldown === "none" ? "muted" : "warning")}`); + const active = index === activeIndex ? "Yes" : "No"; + const rateLimit = formatRateLimitEntry(account, now) ?? "None"; + const cooldown = formatCooldown(account, now) ?? "No"; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `${formatWaitTime(now - account.lastUsed)} ago` + : "-"; + + lines.push( + buildTableRow( + [ + String(index + 1), + label, + active, + rateLimit, + cooldown, + lastUsed, + ], + statusTableOptions, + ), + ); }); lines.push(""); - lines.push(...formatUiSection(ui, "Active index by model family")); + lines.push("Active index by model family:"); for (const family of MODEL_FAMILIES) { const idx = storage.activeIndexByFamily?.[family]; const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`)); + typeof idx === "number" && Number.isFinite(idx) + ? String(idx + 1) + : "-"; + lines.push(` ${family}: ${familyIndexLabel}`); } lines.push(""); - lines.push(...formatUiSection(ui, "Rate limits by model family (per account)")); + lines.push("Rate limits by model family (per account):"); storage.accounts.forEach((account, index) => { const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); + const resetAt = getRateLimitResetTimeForFamily( + account, + now, + family, + ); if (typeof resetAt !== "number") return `${family}=ok`; return `${family}=${formatWaitTime(resetAt - now)}`; }); - lines.push(formatUiItem(ui, `Account ${index + 1}: ${statuses.join(" | ")}`)); + lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`); }); return lines.join("\n"); - } - - const statusTableOptions: TableOptions = { - columns: [ - { header: "#", width: 3 }, - { header: "Label", width: 42 }, - { header: "Active", width: 6 }, - { header: "Rate Limit", width: 16 }, - { header: "Cooldown", width: 16 }, - { header: "Last Used", width: 16 }, - ], - }; - - const lines: string[] = [ - `Account Status (${storage.accounts.length} total):`, - "", - ...buildTableHeader(statusTableOptions), - ]; - - storage.accounts.forEach((account, index) => { - const label = formatAccountLabel(account, index); - const active = index === activeIndex ? "Yes" : "No"; - const rateLimit = formatRateLimitEntry(account, now) ?? "None"; - const cooldown = formatCooldown(account, now) ?? "No"; - const lastUsed = - typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `${formatWaitTime(now - account.lastUsed)} ago` - : "-"; - - lines.push(buildTableRow([String(index + 1), label, active, rateLimit, cooldown, lastUsed], statusTableOptions)); - }); - - lines.push(""); - lines.push("Active index by model family:"); - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily?.[family]; - const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(` ${family}: ${familyIndexLabel}`); - } - - lines.push(""); - lines.push("Rate limits by model family (per account):"); - storage.accounts.forEach((account, index) => { - const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return `${family}=ok`; - return `${family}=${formatWaitTime(resetAt - now)}`; - }); - lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`); - }); + }, + }), + ...(exposeAdvancedCodexTools + ? { + "codex-metrics": tool({ + description: + "Show runtime request metrics for this plugin process.", + args: {}, + execute() { + const ui = resolveUiRuntime(); + const now = Date.now(); + const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); + const total = runtimeMetrics.totalRequests; + const successful = runtimeMetrics.successfulRequests; + const successRate = + total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0"; + const avgLatencyMs = + successful > 0 + ? Math.round( + runtimeMetrics.cumulativeLatencyMs / successful, + ) + : 0; + const liveSyncSnapshot = liveAccountSync?.getSnapshot(); + const guardianStats = refreshGuardian?.getStats(); + const sessionAffinityEntries = + sessionAffinityStore?.size() ?? 0; + const lastRequest = + runtimeMetrics.lastRequestAt !== null + ? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago` + : "never"; + + const lines = [ + "Codex Plugin Metrics:", + "", + `Uptime: ${formatWaitTime(uptimeMs)}`, + `Total upstream requests: ${total}`, + `Successful responses: ${successful}`, + `Failed responses: ${runtimeMetrics.failedRequests}`, + `Success rate: ${successRate}%`, + `Average successful latency: ${avgLatencyMs}ms`, + `Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`, + `Server errors (5xx): ${runtimeMetrics.serverErrors}`, + `Network errors: ${runtimeMetrics.networkErrors}`, + `User aborts: ${runtimeMetrics.userAborts}`, + `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`, + `Account rotations: ${runtimeMetrics.accountRotations}`, + `Same-account retries: ${runtimeMetrics.sameAccountRetries}`, + `Stream failover attempts: ${runtimeMetrics.streamFailoverAttempts}`, + `Stream failover recoveries: ${runtimeMetrics.streamFailoverRecoveries}`, + `Stream failover cross-account recoveries: ${runtimeMetrics.streamFailoverCrossAccountRecoveries}`, + `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`, + `Session affinity entries: ${sessionAffinityEntries}`, + `Live sync: ${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, + `Refresh guardian: ${guardianStats ? "on" : "off"} (${guardianStats?.refreshed ?? 0} refreshed, ${guardianStats?.failed ?? 0} failed)`, + `Last upstream request: ${lastRequest}`, + ]; - return lines.join("\n"); - }, - }), - ...(exposeAdvancedCodexTools ? { - "codex-metrics": tool({ - description: "Show runtime request metrics for this plugin process.", - args: {}, - execute() { - const ui = resolveUiRuntime(); - const now = Date.now(); - const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt); - const total = runtimeMetrics.totalRequests; - const successful = runtimeMetrics.successfulRequests; - const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0"; - const avgLatencyMs = - successful > 0 - ? Math.round(runtimeMetrics.cumulativeLatencyMs / successful) - : 0; - const liveSyncSnapshot = liveAccountSync?.getSnapshot(); - const guardianStats = refreshGuardian?.getStats(); - const sessionAffinityEntries = sessionAffinityStore?.size() ?? 0; - const lastRequest = - runtimeMetrics.lastRequestAt !== null - ? `${formatWaitTime(now - runtimeMetrics.lastRequestAt)} ago` - : "never"; - - const lines = [ - "Codex Plugin Metrics:", - "", - `Uptime: ${formatWaitTime(uptimeMs)}`, - `Total upstream requests: ${total}`, - `Successful responses: ${successful}`, - `Failed responses: ${runtimeMetrics.failedRequests}`, - `Success rate: ${successRate}%`, - `Average successful latency: ${avgLatencyMs}ms`, - `Rate-limited responses: ${runtimeMetrics.rateLimitedResponses}`, - `Server errors (5xx): ${runtimeMetrics.serverErrors}`, - `Network errors: ${runtimeMetrics.networkErrors}`, - `User aborts: ${runtimeMetrics.userAborts}`, - `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`, - `Account rotations: ${runtimeMetrics.accountRotations}`, - `Same-account retries: ${runtimeMetrics.sameAccountRetries}`, - `Stream failover attempts: ${runtimeMetrics.streamFailoverAttempts}`, - `Stream failover recoveries: ${runtimeMetrics.streamFailoverRecoveries}`, - `Stream failover cross-account recoveries: ${runtimeMetrics.streamFailoverCrossAccountRecoveries}`, - `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`, - `Session affinity entries: ${sessionAffinityEntries}`, - `Live sync: ${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, - `Refresh guardian: ${guardianStats ? "on" : "off"} (${guardianStats?.refreshed ?? 0} refreshed, ${guardianStats?.failed ?? 0} failed)`, - `Last upstream request: ${lastRequest}`, - ]; + if (runtimeMetrics.lastError) { + lines.push(`Last error: ${runtimeMetrics.lastError}`); + } - if (runtimeMetrics.lastError) { - lines.push(`Last error: ${runtimeMetrics.lastError}`); - } + if (ui.v2Enabled) { + const styled: string[] = [ + ...formatUiHeader(ui, "Codex plugin metrics"), + formatUiKeyValue(ui, "Uptime", formatWaitTime(uptimeMs)), + formatUiKeyValue( + ui, + "Total upstream requests", + String(total), + ), + formatUiKeyValue( + ui, + "Successful responses", + String(successful), + "success", + ), + formatUiKeyValue( + ui, + "Failed responses", + String(runtimeMetrics.failedRequests), + "danger", + ), + formatUiKeyValue( + ui, + "Success rate", + `${successRate}%`, + "accent", + ), + formatUiKeyValue( + ui, + "Average successful latency", + `${avgLatencyMs}ms`, + ), + formatUiKeyValue( + ui, + "Rate-limited responses", + String(runtimeMetrics.rateLimitedResponses), + "warning", + ), + formatUiKeyValue( + ui, + "Server errors (5xx)", + String(runtimeMetrics.serverErrors), + "danger", + ), + formatUiKeyValue( + ui, + "Network errors", + String(runtimeMetrics.networkErrors), + "danger", + ), + formatUiKeyValue( + ui, + "User aborts", + String(runtimeMetrics.userAborts), + "muted", + ), + formatUiKeyValue( + ui, + "Auth refresh failures", + String(runtimeMetrics.authRefreshFailures), + "warning", + ), + formatUiKeyValue( + ui, + "Account rotations", + String(runtimeMetrics.accountRotations), + "accent", + ), + formatUiKeyValue( + ui, + "Same-account retries", + String(runtimeMetrics.sameAccountRetries), + "warning", + ), + formatUiKeyValue( + ui, + "Stream failover attempts", + String(runtimeMetrics.streamFailoverAttempts), + "muted", + ), + formatUiKeyValue( + ui, + "Stream failover recoveries", + String(runtimeMetrics.streamFailoverRecoveries), + "success", + ), + formatUiKeyValue( + ui, + "Stream failover cross-account recoveries", + String( + runtimeMetrics.streamFailoverCrossAccountRecoveries, + ), + "accent", + ), + formatUiKeyValue( + ui, + "Empty-response retries", + String(runtimeMetrics.emptyResponseRetries), + "warning", + ), + formatUiKeyValue( + ui, + "Session affinity entries", + String(sessionAffinityEntries), + "muted", + ), + formatUiKeyValue( + ui, + "Live sync", + `${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, + liveSyncSnapshot?.running ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Refresh guardian", + guardianStats + ? `on (${guardianStats.refreshed} refreshed, ${guardianStats.failed} failed)` + : "off", + guardianStats ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Last upstream request", + lastRequest, + "muted", + ), + ]; + if (runtimeMetrics.lastError) { + styled.push( + formatUiKeyValue( + ui, + "Last error", + runtimeMetrics.lastError, + "danger", + ), + ); + } + return Promise.resolve(styled.join("\n")); + } - if (ui.v2Enabled) { - const styled: string[] = [ - ...formatUiHeader(ui, "Codex plugin metrics"), - formatUiKeyValue(ui, "Uptime", formatWaitTime(uptimeMs)), - formatUiKeyValue(ui, "Total upstream requests", String(total)), - formatUiKeyValue(ui, "Successful responses", String(successful), "success"), - formatUiKeyValue(ui, "Failed responses", String(runtimeMetrics.failedRequests), "danger"), - formatUiKeyValue(ui, "Success rate", `${successRate}%`, "accent"), - formatUiKeyValue(ui, "Average successful latency", `${avgLatencyMs}ms`), - formatUiKeyValue(ui, "Rate-limited responses", String(runtimeMetrics.rateLimitedResponses), "warning"), - formatUiKeyValue(ui, "Server errors (5xx)", String(runtimeMetrics.serverErrors), "danger"), - formatUiKeyValue(ui, "Network errors", String(runtimeMetrics.networkErrors), "danger"), - formatUiKeyValue(ui, "User aborts", String(runtimeMetrics.userAborts), "muted"), - formatUiKeyValue(ui, "Auth refresh failures", String(runtimeMetrics.authRefreshFailures), "warning"), - formatUiKeyValue(ui, "Account rotations", String(runtimeMetrics.accountRotations), "accent"), - formatUiKeyValue(ui, "Same-account retries", String(runtimeMetrics.sameAccountRetries), "warning"), - formatUiKeyValue(ui, "Stream failover attempts", String(runtimeMetrics.streamFailoverAttempts), "muted"), - formatUiKeyValue(ui, "Stream failover recoveries", String(runtimeMetrics.streamFailoverRecoveries), "success"), - formatUiKeyValue( - ui, - "Stream failover cross-account recoveries", - String(runtimeMetrics.streamFailoverCrossAccountRecoveries), - "accent", - ), - formatUiKeyValue(ui, "Empty-response retries", String(runtimeMetrics.emptyResponseRetries), "warning"), - formatUiKeyValue(ui, "Session affinity entries", String(sessionAffinityEntries), "muted"), - formatUiKeyValue( - ui, - "Live sync", - `${liveSyncSnapshot?.running ? "on" : "off"} (${liveSyncSnapshot?.reloadCount ?? 0} reloads)`, - liveSyncSnapshot?.running ? "success" : "muted", - ), - formatUiKeyValue( - ui, - "Refresh guardian", - guardianStats - ? `on (${guardianStats.refreshed} refreshed, ${guardianStats.failed} failed)` - : "off", - guardianStats ? "success" : "muted", - ), - formatUiKeyValue(ui, "Last upstream request", lastRequest, "muted"), - ]; - if (runtimeMetrics.lastError) { - styled.push(formatUiKeyValue(ui, "Last error", runtimeMetrics.lastError, "danger")); - } - return Promise.resolve(styled.join("\n")); + return Promise.resolve(lines.join("\n")); + }, + }), } - - return Promise.resolve(lines.join("\n")); - }, - }), - } : {}), - "codex-health": tool({ - description: "Check health of all Codex accounts by validating refresh tokens.", + : {}), + "codex-health": tool({ + description: + "Check health of all Codex accounts by validating refresh tokens.", args: {}, async execute() { const ui = resolveUiRuntime(); @@ -3857,23 +4620,32 @@ while (attempted.size < Math.max(1, accountCount)) { const label = formatAccountLabel(account, i); try { - const refreshResult = await queuedRefresh(account.refreshToken); + const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - results.push(` ${getStatusMarker(ui, "ok")} ${label}: Healthy`); + results.push( + ` ${getStatusMarker(ui, "ok")} ${label}: Healthy`, + ); healthyCount++; } else { - results.push(` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Token refresh failed`, + ); unhealthyCount++; } } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`); + const errorMsg = + error instanceof Error ? error.message : String(error); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ); unhealthyCount++; } } results.push(""); - results.push(`Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`); + results.push( + `Summary: ${healthyCount} healthy, ${unhealthyCount} unhealthy`, + ); if (ui.v2Enabled) { return [ @@ -3886,280 +4658,369 @@ while (attempted.size < Math.max(1, accountCount)) { return results.join("\n"); }, }), - ...(exposeAdvancedCodexTools ? { - "codex-remove": tool({ - description: "Remove a Codex account by index (1-based). Use codex-list to list accounts first.", - args: { - index: tool.schema.number().describe( - "Account number to remove (1-based, e.g., 1 for first account)", - ), - }, - async execute({ index }) { - const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, "No accounts configured.", "warning"), - ].join("\n"); - } - return "No Codex accounts configured. Nothing to remove."; - } + ...(exposeAdvancedCodexTools + ? { + "codex-remove": tool({ + description: + "Remove a Codex account by index (1-based). Use codex-list to list accounts first.", + args: { + index: tool.schema + .number() + .describe( + "Account number to remove (1-based, e.g., 1 for first account)", + ), + }, + async execute({ index }) { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + ].join("\n"); + } + return "No Codex accounts configured. Nothing to remove."; + } - const targetIndex = Math.floor((index ?? 0) - 1); - if ( - !Number.isFinite(targetIndex) || - targetIndex < 0 || - targetIndex >= storage.accounts.length - ) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `Invalid account number: ${index}`, "danger"), - formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"), - formatUiItem(ui, "Use codex-list to list all accounts.", "accent"), - ].join("\n"); - } - return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`; - } + const targetIndex = Math.floor((index ?? 0) - 1); + if ( + !Number.isFinite(targetIndex) || + targetIndex < 0 || + targetIndex >= storage.accounts.length + ) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `Invalid account number: ${index}`, + "danger", + ), + formatUiKeyValue( + ui, + "Valid range", + `1-${storage.accounts.length}`, + "muted", + ), + formatUiItem( + ui, + "Use codex-list to list all accounts.", + "accent", + ), + ].join("\n"); + } + return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`; + } - const account = storage.accounts[targetIndex]; - if (!account) { - return `Account ${index} not found.`; - } + const account = storage.accounts[targetIndex]; + if (!account) { + return `Account ${index} not found.`; + } - const label = formatAccountLabel(account, targetIndex); + const label = formatAccountLabel(account, targetIndex); - storage.accounts.splice(targetIndex, 1); + storage.accounts.splice(targetIndex, 1); - if (storage.accounts.length === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - } else { - if (storage.activeIndex >= storage.accounts.length) { - storage.activeIndex = 0; - } else if (storage.activeIndex > targetIndex) { - storage.activeIndex -= 1; - } + if (storage.accounts.length === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + } else { + if (storage.activeIndex >= storage.accounts.length) { + storage.activeIndex = 0; + } else if (storage.activeIndex > targetIndex) { + storage.activeIndex -= 1; + } - if (storage.activeIndexByFamily) { - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily[family]; - if (typeof idx === "number") { - if (idx >= storage.accounts.length) { - storage.activeIndexByFamily[family] = 0; - } else if (idx > targetIndex) { - storage.activeIndexByFamily[family] = idx - 1; + if (storage.activeIndexByFamily) { + for (const family of MODEL_FAMILIES) { + const idx = storage.activeIndexByFamily[family]; + if (typeof idx === "number") { + if (idx >= storage.accounts.length) { + storage.activeIndexByFamily[family] = 0; + } else if (idx > targetIndex) { + storage.activeIndexByFamily[family] = idx - 1; + } + } + } } } - } - } - } - - try { - await saveAccounts(storage); - } catch (saveError) { - logWarn("Failed to save account removal", { error: String(saveError) }); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `Removed ${formatAccountLabel(account, targetIndex)} from memory`, "warning"), - formatUiItem(ui, "Failed to persist. Change may be lost on restart.", "danger"), - ].join("\n"); - } - return `Removed ${formatAccountLabel(account, targetIndex)} from memory but failed to persist. Changes may be lost on restart.`; - } - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } + try { + await saveAccounts(storage); + } catch (saveError) { + logWarn("Failed to save account removal", { + error: String(saveError), + }); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `Removed ${formatAccountLabel(account, targetIndex)} from memory`, + "warning", + ), + formatUiItem( + ui, + "Failed to persist. Change may be lost on restart.", + "danger", + ), + ].join("\n"); + } + return `Removed ${formatAccountLabel(account, targetIndex)} from memory but failed to persist. Changes may be lost on restart.`; + } - const remaining = storage.accounts.length; - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Remove account"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Removed: ${label}`, "success"), - remaining > 0 - ? formatUiKeyValue(ui, "Remaining accounts", String(remaining)) - : formatUiItem(ui, "No accounts remaining. Run: codex login", "warning"), - ].join("\n"); - } - return [ - `Removed: ${label}`, - "", - remaining > 0 - ? `Remaining accounts: ${remaining}` - : "No accounts remaining. Run: codex login", - ].join("\n"); - }, - }), + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } - "codex-refresh": tool({ - description: "Manually refresh OAuth tokens for all accounts to verify they're still valid.", - args: {}, - async execute() { - const ui = resolveUiRuntime(); - const storage = await loadAccounts(); - if (!storage || storage.accounts.length === 0) { - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Refresh accounts"), - "", - formatUiItem(ui, "No accounts configured.", "warning"), - formatUiItem(ui, "Run: codex login", "accent"), - ].join("\n"); - } - return "No Codex accounts configured. Run: codex login"; - } + const remaining = storage.accounts.length; + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Remove account"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Removed: ${label}`, + "success", + ), + remaining > 0 + ? formatUiKeyValue( + ui, + "Remaining accounts", + String(remaining), + ) + : formatUiItem( + ui, + "No accounts remaining. Run: codex login", + "warning", + ), + ].join("\n"); + } + return [ + `Removed: ${label}`, + "", + remaining > 0 + ? `Remaining accounts: ${remaining}` + : "No accounts remaining. Run: codex login", + ].join("\n"); + }, + }), + + "codex-refresh": tool({ + description: + "Manually refresh OAuth tokens for all accounts to verify they're still valid.", + args: {}, + async execute() { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Refresh accounts"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + formatUiItem(ui, "Run: codex login", "accent"), + ].join("\n"); + } + return "No Codex accounts configured. Run: codex login"; + } - const results: string[] = ui.v2Enabled - ? [] - : [`Refreshing ${storage.accounts.length} account(s):`, ""]; + const results: string[] = ui.v2Enabled + ? [] + : [`Refreshing ${storage.accounts.length} account(s):`, ""]; - let refreshedCount = 0; - let failedCount = 0; + let refreshedCount = 0; + let failedCount = 0; - for (let i = 0; i < storage.accounts.length; i++) { - const account = storage.accounts[i]; - if (!account) continue; - const label = formatAccountLabel(account, i); + for (let i = 0; i < storage.accounts.length; i++) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatAccountLabel(account, i); - try { - const refreshResult = await queuedRefresh(account.refreshToken); - if (refreshResult.type === "success") { - account.refreshToken = refreshResult.refresh; - account.accessToken = refreshResult.access; - account.expiresAt = refreshResult.expires; - results.push(` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`); - refreshedCount++; - } else { - results.push(` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`); - failedCount++; - } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - results.push(` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`); - failedCount++; - } - } + try { + const refreshResult = await queuedRefresh( + account.refreshToken, + ); + if (refreshResult.type === "success") { + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + results.push( + ` ${getStatusMarker(ui, "ok")} ${label}: Refreshed`, + ); + refreshedCount++; + } else { + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Failed - ${refreshResult.message ?? refreshResult.reason}`, + ); + failedCount++; + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error); + results.push( + ` ${getStatusMarker(ui, "error")} ${label}: Error - ${errorMsg.slice(0, 120)}`, + ); + failedCount++; + } + } - await saveAccounts(storage); - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } - results.push(""); - results.push(`Summary: ${refreshedCount} refreshed, ${failedCount} failed`); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Refresh accounts"), - "", - ...results.map((line) => paintUiText(ui, line, "normal")), - ].join("\n"); - } - return results.join("\n"); - }, - }), - - "codex-export": tool({ - description: "Export accounts to a JSON file for backup or migration to another machine.", - args: { - path: tool.schema.string().describe( - "File path to export to (e.g., ~/codex-backup.json)" - ), - force: tool.schema.boolean().optional().describe( - "Overwrite existing file (default: true)" - ), - }, - async execute({ path: filePath, force }) { - const ui = resolveUiRuntime(); - try { - await exportAccounts(filePath, force ?? true); - const storage = await loadAccounts(); - const count = storage?.accounts.length ?? 0; - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Export accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success"), - formatUiKeyValue(ui, "Path", filePath, "muted"), - ].join("\n"); - } - return `Exported ${count} account(s) to: ${filePath}`; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Export accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "error")} Export failed`, "danger"), - formatUiKeyValue(ui, "Error", msg, "danger"), - ].join("\n"); - } - return `Export failed: ${msg}`; - } - }, - }), - - "codex-import": tool({ - description: "Import accounts from a JSON file, merging with existing accounts.", - args: { - path: tool.schema.string().describe( - "File path to import from (e.g., ~/codex-backup.json)" - ), - }, - async execute({ path: filePath }) { - const ui = resolveUiRuntime(); - try { - const result = await importAccounts(filePath); - invalidateAccountManagerCache(); - const lines = [`Import complete.`, ``]; - if (result.imported > 0) { - lines.push(`New accounts: ${result.imported}`); - } - if (result.skipped > 0) { - lines.push(`Duplicates skipped: ${result.skipped}`); - } - lines.push(`Total accounts: ${result.total}`); - if (ui.v2Enabled) { - const styled = [ - ...formatUiHeader(ui, "Import accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "ok")} Import complete`, "success"), - formatUiKeyValue(ui, "Path", filePath, "muted"), - formatUiKeyValue(ui, "New accounts", String(result.imported), result.imported > 0 ? "success" : "muted"), - formatUiKeyValue(ui, "Duplicates skipped", String(result.skipped), result.skipped > 0 ? "warning" : "muted"), - formatUiKeyValue(ui, "Total accounts", String(result.total), "accent"), - ]; - return styled.join("\n"); - } - return lines.join("\n"); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (ui.v2Enabled) { - return [ - ...formatUiHeader(ui, "Import accounts"), - "", - formatUiItem(ui, `${getStatusMarker(ui, "error")} Import failed`, "danger"), - formatUiKeyValue(ui, "Error", msg, "danger"), - ].join("\n"); + await saveAccounts(storage); + if (cachedAccountManager) { + await reloadAccountManagerFromDisk(); + } + results.push(""); + results.push( + `Summary: ${refreshedCount} refreshed, ${failedCount} failed`, + ); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Refresh accounts"), + "", + ...results.map((line) => paintUiText(ui, line, "normal")), + ].join("\n"); + } + return results.join("\n"); + }, + }), + + "codex-export": tool({ + description: + "Export accounts to a JSON file for backup or migration to another machine.", + args: { + path: tool.schema + .string() + .describe( + "File path to export to (e.g., ~/codex-backup.json)", + ), + force: tool.schema + .boolean() + .optional() + .describe("Overwrite existing file (default: true)"), + }, + async execute({ path: filePath, force }) { + const ui = resolveUiRuntime(); + try { + await exportAccounts(filePath, force ?? true); + const storage = await loadAccounts(); + const count = storage?.accounts.length ?? 0; + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Export accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, + "success", + ), + formatUiKeyValue(ui, "Path", filePath, "muted"), + ].join("\n"); + } + return `Exported ${count} account(s) to: ${filePath}`; + } catch (error) { + const msg = + error instanceof Error ? error.message : String(error); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Export accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "error")} Export failed`, + "danger", + ), + formatUiKeyValue(ui, "Error", msg, "danger"), + ].join("\n"); + } + return `Export failed: ${msg}`; + } + }, + }), + + "codex-import": tool({ + description: + "Import accounts from a JSON file, merging with existing accounts.", + args: { + path: tool.schema + .string() + .describe( + "File path to import from (e.g., ~/codex-backup.json)", + ), + }, + async execute({ path: filePath }) { + const ui = resolveUiRuntime(); + try { + const result = await importAccounts(filePath); + invalidateAccountManagerCache(); + const lines = [`Import complete.`, ``]; + if (result.imported > 0) { + lines.push(`New accounts: ${result.imported}`); + } + if (result.skipped > 0) { + lines.push(`Duplicates skipped: ${result.skipped}`); + } + lines.push(`Total accounts: ${result.total}`); + if (ui.v2Enabled) { + const styled = [ + ...formatUiHeader(ui, "Import accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "ok")} Import complete`, + "success", + ), + formatUiKeyValue(ui, "Path", filePath, "muted"), + formatUiKeyValue( + ui, + "New accounts", + String(result.imported), + result.imported > 0 ? "success" : "muted", + ), + formatUiKeyValue( + ui, + "Duplicates skipped", + String(result.skipped), + result.skipped > 0 ? "warning" : "muted", + ), + formatUiKeyValue( + ui, + "Total accounts", + String(result.total), + "accent", + ), + ]; + return styled.join("\n"); + } + return lines.join("\n"); + } catch (error) { + const msg = + error instanceof Error ? error.message : String(error); + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Import accounts"), + "", + formatUiItem( + ui, + `${getStatusMarker(ui, "error")} Import failed`, + "danger", + ), + formatUiKeyValue(ui, "Error", msg, "danger"), + ].join("\n"); + } + return `Import failed: ${msg}`; + } + }, + }), } - return `Import failed: ${msg}`; - } - }, - }), - } : {}), - - }, + : {}), + }, }; }; export const OpenAIAuthPlugin = OpenAIOAuthPlugin; export default OpenAIOAuthPlugin; - - diff --git a/lib/cli.ts b/lib/cli.ts index d223c14c..59b66f77 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -1,11 +1,12 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { DESTRUCTIVE_ACTION_COPY } from "./destructive-actions.js"; import type { AccountIdSource } from "./types.js"; import { - showAuthMenu, - showAccountDetails, - isTTY, type AccountStatus, + isTTY, + showAccountDetails, + showAuthMenu, } from "./ui/auth-menu.js"; import { UI_COPY } from "./ui/copy.js"; @@ -19,12 +20,15 @@ export function isNonInteractiveMode(): boolean { if (!input.isTTY || !output.isTTY) return true; if (process.env.CODEX_TUI === "1") return true; if (process.env.CODEX_DESKTOP === "1") return true; - if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") return true; + if ((process.env.TERM_PROGRAM ?? "").trim().toLowerCase() === "codex") + return true; if (process.env.ELECTRON_RUN_AS_NODE === "1") return true; return false; } -export async function promptAddAnotherAccount(currentCount: number): Promise { +export async function promptAddAnotherAccount( + currentCount: number, +): Promise { if (isNonInteractiveMode()) { return false; } @@ -32,7 +36,9 @@ export async function promptAddAnotherAccount(currentCount: number): Promise 6 ? account.accountId.slice(-6) : account.accountId; + const suffix = + account.accountId.length > 6 + ? account.accountId.slice(-6) + : account.accountId; return `${num}. ${suffix}`; } return `${num}. Account`; @@ -112,7 +127,8 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string function resolveAccountSourceIndex(account: ExistingAccountInfo): number { const sourceIndex = - typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex) + typeof account.sourceIndex === "number" && + Number.isFinite(account.sourceIndex) ? Math.max(0, Math.floor(account.sourceIndex)) : undefined; if (typeof sourceIndex === "number") return sourceIndex; @@ -123,21 +139,40 @@ function resolveAccountSourceIndex(account: ExistingAccountInfo): number { } function warnUnresolvableAccountSelection(account: ExistingAccountInfo): void { - const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`; + const label = + account.email?.trim() || + account.accountId?.trim() || + `index ${account.index + 1}`; console.log(`Unable to resolve saved account for action: ${label}`); } async function promptDeleteAllTypedConfirm(): Promise { const rl = createInterface({ input, output }); try { - const answer = await rl.question("Type DELETE to remove all saved accounts: "); + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.typedConfirm, + ); return answer.trim() === "DELETE"; } finally { rl.close(); } } -async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise { +async function promptResetTypedConfirm(): Promise { + const rl = createInterface({ input, output }); + try { + const answer = await rl.question( + DESTRUCTIVE_ACTION_COPY.resetLocalState.typedConfirm, + ); + return answer.trim() === "RESET"; + } finally { + rl.close(); + } +} + +async function promptLoginModeFallback( + existingAccounts: ExistingAccountInfo[], +): Promise { const rl = createInterface({ input, output }); try { if (existingAccounts.length > 0) { @@ -152,17 +187,33 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): const answer = await rl.question(UI_COPY.fallback.selectModePrompt); const normalized = answer.trim().toLowerCase(); if (normalized === "a" || normalized === "add") return { mode: "add" }; - if (normalized === "b" || normalized === "p" || normalized === "forecast") { + if ( + normalized === "b" || + normalized === "p" || + normalized === "forecast" + ) { return { mode: "forecast" }; } if (normalized === "x" || normalized === "fix") return { mode: "fix" }; - if (normalized === "s" || normalized === "settings" || normalized === "configure") { + if ( + normalized === "s" || + normalized === "settings" || + normalized === "configure" + ) { return { mode: "settings" }; } - if (normalized === "f" || normalized === "fresh" || normalized === "clear") { + if ( + normalized === "f" || + normalized === "fresh" || + normalized === "clear" + ) { return { mode: "fresh", deleteAll: true }; } - if (normalized === "c" || normalized === "check") return { mode: "check" }; + if (normalized === "r" || normalized === "reset") { + return { mode: "reset" }; + } + if (normalized === "c" || normalized === "check") + return { mode: "check" }; if (normalized === "d" || normalized === "deep") { return { mode: "deep-check" }; } @@ -174,7 +225,8 @@ async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): ) { return { mode: "verify-flagged" }; } - if (normalized === "q" || normalized === "quit") return { mode: "cancel" }; + if (normalized === "q" || normalized === "quit") + return { mode: "cancel" }; console.log(UI_COPY.fallback.invalidModePrompt); } } finally { @@ -215,6 +267,12 @@ export async function promptLoginMode( continue; } return { mode: "fresh", deleteAll: true }; + case "reset-all": + if (!(await promptResetTypedConfirm())) { + console.log("\nReset cancelled.\n"); + continue; + } + return { mode: "reset" }; case "check": return { mode: "check" }; case "deep-check": @@ -306,7 +364,8 @@ export async function promptAccountSelection( ): Promise { if (candidates.length === 0) return null; const defaultIndex = - typeof options.defaultIndex === "number" && Number.isFinite(options.defaultIndex) + typeof options.defaultIndex === "number" && + Number.isFinite(options.defaultIndex) ? Math.max(0, Math.min(options.defaultIndex, candidates.length - 1)) : 0; @@ -316,7 +375,9 @@ export async function promptAccountSelection( const rl = createInterface({ input, output }); try { - console.log(`\n${options.title ?? "Multiple workspaces detected for this account:"}`); + console.log( + `\n${options.title ?? "Multiple workspaces detected for this account:"}`, + ); candidates.forEach((candidate, index) => { const isDefault = candidate.isDefault ? " (default)" : ""; console.log(` ${index + 1}. ${candidate.label}${isDefault}`); @@ -324,7 +385,9 @@ export async function promptAccountSelection( console.log(""); while (true) { - const answer = await rl.question(`Select workspace [${defaultIndex + 1}]: `); + const answer = await rl.question( + `Select workspace [${defaultIndex + 1}]: `, + ); const normalized = answer.trim().toLowerCase(); if (!normalized) { return candidates[defaultIndex] ?? candidates[0] ?? null; diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 794eb7c6..d967ffb8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1,16 +1,7 @@ -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import { promises as fs, existsSync } from "node:fs"; +import { existsSync, promises as fs } from "node:fs"; import { dirname, resolve } from "node:path"; -import { - createAuthorizationFlow, - exchangeAuthorizationCode, - parseAuthorizationInput, - REDIRECT_URI, -} from "./auth/auth.js"; -import { startLocalOAuthServer } from "./auth/server.js"; -import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; -import { promptAddAnotherAccount, promptLoginMode, type ExistingAccountInfo } from "./cli.js"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; import { extractAccountEmail, extractAccountId, @@ -22,57 +13,81 @@ import { sanitizeEmail, selectBestAccountCandidate, } from "./accounts.js"; +import { + createAuthorizationFlow, + exchangeAuthorizationCode, + parseAuthorizationInput, + REDIRECT_URI, +} from "./auth/auth.js"; +import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js"; +import { startLocalOAuthServer } from "./auth/server.js"; +import { + type ExistingAccountInfo, + promptAddAnotherAccount, + promptLoginMode, +} from "./cli.js"; +import { + getCodexCliAuthPath, + getCodexCliConfigPath, + loadCodexCliState, +} from "./codex-cli/state.js"; +import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { + applyUiThemeFromDashboardSettings, + configureUnifiedSettings, + resolveMenuLayoutMode, +} from "./codex-manager/settings-hub.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { - loadDashboardDisplaySettings, - DEFAULT_DASHBOARD_DISPLAY_SETTINGS, - type DashboardDisplaySettings, type DashboardAccountSortMode, + type DashboardDisplaySettings, + DEFAULT_DASHBOARD_DISPLAY_SETTINGS, + loadDashboardDisplaySettings, } from "./dashboard-settings.js"; +import { + DESTRUCTIVE_ACTION_COPY, + deleteAccountAtIndex, + deleteSavedAccounts, + resetLocalState, +} from "./destructive-actions.js"; import { evaluateForecastAccounts, + type ForecastAccountResult, isHardRefreshFailure, recommendForecastAccount, summarizeForecast, - type ForecastAccountResult, } from "./forecast.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; -import { - fetchCodexQuotaSnapshot, - formatQuotaSnapshotLine, - type CodexQuotaSnapshot, -} from "./quota-probe.js"; -import { queuedRefresh } from "./refresh-queue.js"; import { loadQuotaCache, - saveQuotaCache, type QuotaCacheData, type QuotaCacheEntry, + saveQuotaCache, } from "./quota-cache.js"; import { + type CodexQuotaSnapshot, + fetchCodexQuotaSnapshot, + formatQuotaSnapshotLine, +} from "./quota-probe.js"; +import { queuedRefresh } from "./refresh-queue.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, + type FlaggedAccountMetadataV1, + type FlaggedAccountStorageV1, getStoragePath, - loadFlaggedAccounts, loadAccounts, - saveFlaggedAccounts, + loadFlaggedAccounts, saveAccounts, + saveFlaggedAccounts, setStoragePath, - type AccountMetadataV3, - type AccountStorageV3, - type FlaggedAccountMetadataV1, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; -import { - getCodexCliAuthPath, - getCodexCliConfigPath, - loadCodexCliState, -} from "./codex-cli/state.js"; -import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; import { ANSI } from "./ui/ansi.js"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; import { getUiRuntimeOptions } from "./ui/runtime.js"; -import { select, type MenuItem } from "./ui/select.js"; -import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, resolveMenuLayoutMode } from "./codex-manager/settings-hub.js"; +import { type MenuItem, select } from "./ui/select.js"; type TokenSuccess = Extract; type TokenSuccessWithAccount = TokenSuccess & { @@ -92,15 +107,16 @@ function stylePromptText(text: string, tone: PromptTone): string { const mapped = tone === "accent" ? "primary" : tone; return paintUiText(ui, text, mapped); } - const legacyCode = tone === "accent" - ? ANSI.green - : tone === "success" + const legacyCode = + tone === "accent" ? ANSI.green - : tone === "warning" - ? ANSI.yellow - : tone === "danger" - ? ANSI.red - : ANSI.dim; + : tone === "success" + ? ANSI.green + : tone === "warning" + ? ANSI.yellow + : tone === "danger" + ? ANSI.red + : ANSI.dim; return `${legacyCode}${text}${ANSI.reset}`; } @@ -118,14 +134,17 @@ function extractErrorMessageFromPayload(payload: unknown): string | undefined { if (!payload || typeof payload !== "object") return undefined; const record = payload as Record; - const directMessage = typeof record.message === "string" - ? collapseWhitespace(record.message) - : ""; - const directCode = typeof record.code === "string" - ? collapseWhitespace(record.code) - : ""; + const directMessage = + typeof record.message === "string" + ? collapseWhitespace(record.message) + : ""; + const directCode = + typeof record.code === "string" ? collapseWhitespace(record.code) : ""; if (directMessage) { - if (directCode && !directMessage.toLowerCase().includes(directCode.toLowerCase())) { + if ( + directCode && + !directMessage.toLowerCase().includes(directCode.toLowerCase()) + ) { return `${directMessage} [${directCode}]`; } return directMessage; @@ -168,7 +187,8 @@ function normalizeFailureDetail( const raw = message?.trim() || reasonLabel || "refresh failed"; const structured = parseStructuredErrorMessage(raw); const normalized = collapseWhitespace(structured ?? raw); - const bounded = normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; + const bounded = + normalized.length > 260 ? `${normalized.slice(0, 257)}...` : normalized; return bounded.length > 0 ? bounded : "refresh failed"; } @@ -181,14 +201,19 @@ function joinStyledSegments(parts: string[]): string { function formatResultSummary( segments: ReadonlyArray<{ text: string; tone: PromptTone }>, ): string { - const rendered = segments.map((segment) => stylePromptText(segment.text, segment.tone)); + const rendered = segments.map((segment) => + stylePromptText(segment.text, segment.tone), + ); return `${stylePromptText("Result:", "accent")} ${joinStyledSegments(rendered)}`; } function styleQuotaSummary(summary: string): string { const normalized = collapseWhitespace(summary); if (!normalized) return stylePromptText(summary, "muted"); - const segments = normalized.split("|").map((segment) => segment.trim()).filter(Boolean); + const segments = normalized + .split("|") + .map((segment) => segment.trim()) + .filter(Boolean); if (segments.length === 0) return stylePromptText(normalized, "muted"); const rendered = segments.map((segment) => { @@ -211,7 +236,10 @@ function styleQuotaSummary(summary: string): string { return joinStyledSegments(rendered); } -function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "muted"): string { +function styleAccountDetailText( + detail: string, + fallbackTone: PromptTone = "muted", +): string { const compact = collapseWhitespace(detail); if (!compact) return stylePromptText("", fallbackTone); @@ -226,11 +254,12 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute : /ok|working|succeeded|valid/i.test(prefix) ? "success" : fallbackTone; - const suffixTone: PromptTone = /re-login|stale|warning|retry|fallback/i.test(suffix) - ? "warning" - : /failed|error/i.test(suffix) - ? "danger" - : "muted"; + const suffixTone: PromptTone = + /re-login|stale|warning|retry|fallback/i.test(suffix) + ? "warning" + : /failed|error/i.test(suffix) + ? "danger" + : "muted"; const chunks: string[] = []; if (prefix) chunks.push(stylePromptText(prefix, prefixTone)); @@ -240,13 +269,17 @@ function styleAccountDetailText(detail: string, fallbackTone: PromptTone = "mute } if (/rate-limited/i.test(compact)) return stylePromptText(compact, "danger"); - if (/re-login|stale|warning|fallback/i.test(compact)) return stylePromptText(compact, "warning"); + if (/re-login|stale|warning|fallback/i.test(compact)) + return stylePromptText(compact, "warning"); if (/failed|error/i.test(compact)) return stylePromptText(compact, "danger"); - if (/ok|working|succeeded|valid/i.test(compact)) return stylePromptText(compact, "success"); + if (/ok|working|succeeded|valid/i.test(compact)) + return stylePromptText(compact, "success"); return stylePromptText(compact, fallbackTone); } -function riskTone(level: ForecastAccountResult["riskLevel"]): "success" | "warning" | "danger" { +function riskTone( + level: ForecastAccountResult["riskLevel"], +): "success" | "warning" | "danger" { if (level === "low") return "success"; if (level === "medium") return "warning"; return "danger"; @@ -368,7 +401,8 @@ function resolveActiveIndex( ): number { const total = storage.accounts.length; if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const rawCandidate = + storage.activeIndexByFamily?.[family] ?? storage.activeIndex; const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; return Math.max(0, Math.min(raw, total - 1)); } @@ -430,7 +464,9 @@ function quotaCacheEntryToSnapshot(entry: QuotaCacheEntry): CodexQuotaSnapshot { }; } -function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): string { +function formatCompactQuotaWindowLabel( + windowMinutes: number | undefined, +): string { if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { return "quota"; } @@ -439,7 +475,10 @@ function formatCompactQuotaWindowLabel(windowMinutes: number | undefined): strin return `${windowMinutes}m`; } -function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: number | undefined): string | null { +function formatCompactQuotaPart( + windowMinutes: number | undefined, + usedPercent: number | undefined, +): string | null { const label = formatCompactQuotaWindowLabel(windowMinutes); if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return null; @@ -448,7 +487,9 @@ function formatCompactQuotaPart(windowMinutes: number | undefined, usedPercent: return `${label} ${left}%`; } -function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | undefined { +function quotaLeftPercentFromUsed( + usedPercent: number | undefined, +): number | undefined { if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return undefined; } @@ -457,9 +498,17 @@ function quotaLeftPercentFromUsed(usedPercent: number | undefined): number | und function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { const parts = [ - formatCompactQuotaPart(snapshot.primary.windowMinutes, snapshot.primary.usedPercent), - formatCompactQuotaPart(snapshot.secondary.windowMinutes, snapshot.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + snapshot.primary.windowMinutes, + snapshot.primary.usedPercent, + ), + formatCompactQuotaPart( + snapshot.secondary.windowMinutes, + snapshot.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (snapshot.status === 429) { parts.push("rate-limited"); } @@ -471,9 +520,17 @@ function formatCompactQuotaSnapshot(snapshot: CodexQuotaSnapshot): string { function formatAccountQuotaSummary(entry: QuotaCacheEntry): string { const parts = [ - formatCompactQuotaPart(entry.primary.windowMinutes, entry.primary.usedPercent), - formatCompactQuotaPart(entry.secondary.windowMinutes, entry.secondary.usedPercent), - ].filter((value): value is string => typeof value === "string" && value.length > 0); + formatCompactQuotaPart( + entry.primary.windowMinutes, + entry.primary.usedPercent, + ), + formatCompactQuotaPart( + entry.secondary.windowMinutes, + entry.secondary.usedPercent, + ), + ].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); if (entry.status === 429) { parts.push("rate-limited"); } @@ -576,7 +633,12 @@ function collectMenuQuotaRefreshTargets( ): MenuQuotaProbeTarget[] { const targets: MenuQuotaProbeTarget[] = []; for (const account of storage.accounts) { - const probeInput = resolveMenuQuotaProbeInput(account, cache, maxAgeMs, now); + const probeInput = resolveMenuQuotaProbeInput( + account, + cache, + maxAgeMs, + now, + ); if (!probeInput) continue; targets.push({ account, @@ -628,7 +690,8 @@ async function refreshQuotaCacheForMenu( accessToken: target.accessToken, model: MENU_QUOTA_REFRESH_MODEL, }); - changed = updateQuotaCacheForAccount(cache, target.account, snapshot) || changed; + changed = + updateQuotaCacheForAccount(cache, target.account, snapshot) || changed; } catch { // Keep existing cached values if probing fails. } @@ -648,11 +711,17 @@ function hasUsableAccessToken( now: number, ): boolean { if (!account.accessToken) return false; - if (typeof account.expiresAt !== "number" || !Number.isFinite(account.expiresAt)) return false; + if ( + typeof account.expiresAt !== "number" || + !Number.isFinite(account.expiresAt) + ) + return false; return account.expiresAt - now > ACCESS_TOKEN_FRESH_WINDOW_MS; } -function hasLikelyInvalidRefreshToken(refreshToken: string | undefined): boolean { +function hasLikelyInvalidRefreshToken( + refreshToken: string | undefined, +): boolean { if (!refreshToken) return true; const trimmed = refreshToken.trim(); if (trimmed.length < 20) return true; @@ -666,7 +735,10 @@ function mapAccountStatus( now: number, ): ExistingAccountInfo["status"] { if (account.enabled === false) return "disabled"; - if (typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now) { + if ( + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now + ) { return "cooldown"; } const rateLimit = formatRateLimitEntry(account, now, "codex"); @@ -680,7 +752,9 @@ function parseLeftPercentFromQuotaSummary( windowLabel: "5h" | "7d", ): number { if (!summary) return -1; - const match = summary.match(new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i")); + const match = summary.match( + new RegExp(`(?:^|\\|)\\s*${windowLabel}\\s+(\\d{1,3})%`, "i"), + ); const value = Number.parseInt(match?.[1] ?? "", 10); if (!Number.isFinite(value)) return -1; return Math.max(0, Math.min(100, value)); @@ -690,14 +764,19 @@ function readQuotaLeftPercent( account: ExistingAccountInfo, windowLabel: "5h" | "7d", ): number { - const direct = windowLabel === "5h" ? account.quota5hLeftPercent : account.quota7dLeftPercent; + const direct = + windowLabel === "5h" + ? account.quota5hLeftPercent + : account.quota7dLeftPercent; if (typeof direct === "number" && Number.isFinite(direct)) { return Math.max(0, Math.min(100, Math.round(direct))); } return parseLeftPercentFromQuotaSummary(account.quotaSummary, windowLabel); } -function accountStatusSortBucket(status: ExistingAccountInfo["status"]): number { +function accountStatusSortBucket( + status: ExistingAccountInfo["status"], +): number { switch (status) { case "active": case "ok": @@ -728,7 +807,9 @@ function compareReadyFirstAccounts( const right7d = readQuotaLeftPercent(right, "7d"); if (left7d !== right7d) return right7d - left7d; - const bucketDelta = accountStatusSortBucket(left.status) - accountStatusSortBucket(right.status); + const bucketDelta = + accountStatusSortBucket(left.status) - + accountStatusSortBucket(right.status); if (bucketDelta !== 0) return bucketDelta; const leftLastUsed = left.lastUsed ?? 0; @@ -745,18 +826,26 @@ function applyAccountMenuOrdering( displaySettings: DashboardDisplaySettings, ): ExistingAccountInfo[] { const sortEnabled = - displaySettings.menuSortEnabled ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? true); + displaySettings.menuSortEnabled ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortEnabled ?? + true; const sortMode: DashboardAccountSortMode = - displaySettings.menuSortMode ?? (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? "ready-first"); + displaySettings.menuSortMode ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortMode ?? + "ready-first"; if (!sortEnabled || sortMode !== "ready-first") { return [...accounts]; } const sorted = [...accounts].sort(compareReadyFirstAccounts); - const pinCurrent = displaySettings.menuSortPinCurrent ?? - (DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? false); + const pinCurrent = + displaySettings.menuSortPinCurrent ?? + DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuSortPinCurrent ?? + false; if (pinCurrent) { - const currentIndex = sorted.findIndex((account) => account.isCurrentAccount); + const currentIndex = sorted.findIndex( + (account) => account.isCurrentAccount, + ); if (currentIndex > 0) { const current = sorted.splice(currentIndex, 1)[0]; const first = sorted[0]; @@ -779,7 +868,9 @@ function toExistingAccountInfo( const activeIndex = resolveActiveIndex(storage, "codex"); const layoutMode = resolveMenuLayoutMode(displaySettings); const baseAccounts = storage.accounts.map((account, index) => { - const entry = quotaCache ? getQuotaCacheEntryForAccount(quotaCache, account) : null; + const entry = quotaCache + ? getQuotaCacheEntryForAccount(quotaCache, account) + : null; return { index, sourceIndex: index, @@ -789,12 +880,15 @@ function toExistingAccountInfo( addedAt: account.addedAt, lastUsed: account.lastUsed, status: mapAccountStatus(account, index, activeIndex, now), - quotaSummary: (displaySettings.menuShowQuotaSummary ?? true) && entry - ? formatAccountQuotaSummary(entry) - : undefined, + quotaSummary: + (displaySettings.menuShowQuotaSummary ?? true) && entry + ? formatAccountQuotaSummary(entry) + : undefined, quota5hLeftPercent: quotaLeftPercentFromUsed(entry?.primary.usedPercent), quota5hResetAtMs: entry?.primary.resetAtMs, - quota7dLeftPercent: quotaLeftPercentFromUsed(entry?.secondary.usedPercent), + quota7dLeftPercent: quotaLeftPercentFromUsed( + entry?.secondary.usedPercent, + ), quota7dResetAtMs: entry?.secondary.resetAtMs, quotaRateLimited: entry?.status === 429, isCurrentAccount: index === activeIndex, @@ -806,11 +900,19 @@ function toExistingAccountInfo( showHintsForUnselectedRows: layoutMode === "expanded-rows", highlightCurrentRow: displaySettings.menuHighlightCurrentRow ?? true, focusStyle: displaySettings.menuFocusStyle ?? "row-invert", - statuslineFields: displaySettings.menuStatuslineFields ?? ["last-used", "limits", "status"], + statuslineFields: displaySettings.menuStatuslineFields ?? [ + "last-used", + "limits", + "status", + ], }; }); - const orderedAccounts = applyAccountMenuOrdering(baseAccounts, displaySettings); - const quickSwitchUsesVisibleRows = displaySettings.menuSortQuickSwitchVisibleRow ?? true; + const orderedAccounts = applyAccountMenuOrdering( + baseAccounts, + displaySettings, + ); + const quickSwitchUsesVisibleRows = + displaySettings.menuSortQuickSwitchVisibleRow ?? true; return orderedAccounts.map((account, displayIndex) => ({ ...account, index: displayIndex, @@ -820,7 +922,9 @@ function toExistingAccountInfo( })); } -function resolveAccountSelection(tokens: TokenSuccess): TokenSuccessWithAccount { +function resolveAccountSelection( + tokens: TokenSuccess, +): TokenSuccessWithAccount { const override = (process.env.CODEX_AUTH_ACCOUNT_ID ?? "").trim(); if (override) { return { @@ -937,7 +1041,9 @@ interface WaitForReturnOptions { pauseOnAnyKey?: boolean; } -async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise { +async function waitForMenuReturn( + options: WaitForReturnOptions = {}, +): Promise { if (!input.isTTY || !output.isTTY) { return; } @@ -976,9 +1082,7 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise((resolve) => { @@ -1053,7 +1157,8 @@ async function waitForMenuReturn(options: WaitForReturnOptions = {}): Promise 0 ? `${stylePromptText(promptText, "muted")} ` : ""; + const question = + promptText.length > 0 ? `${stylePromptText(promptText, "muted")} ` : ""; output.write(`\r${ANSI.clearLine}`); await rl.question(question); } catch (error) { @@ -1122,7 +1227,12 @@ async function runActionPanel( ? UI_COPY.returnFlow.failed : UI_COPY.returnFlow.done; previousLog(stylePromptText(title, "accent")); - previousLog(stylePromptText(stageText, failed ? "danger" : running ? "accent" : "success")); + previousLog( + stylePromptText( + stageText, + failed ? "danger" : running ? "accent" : "success", + ), + ); previousLog(""); const lines = captured.slice(-maxVisibleLines); @@ -1135,7 +1245,8 @@ async function runActionPanel( previousLog(""); } previousLog(""); - if (running) previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); + if (running) + previousLog(stylePromptText(UI_COPY.returnFlow.working, "muted")); frame += 1; }; @@ -1184,7 +1295,9 @@ async function runActionPanel( pauseOnAnyKey: settings?.actionPauseOnKey ?? true, }); } - output.write(ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1)); + output.write( + ANSI.altScreenOff + ANSI.show + ANSI.clearScreen + ANSI.moveTo(1, 1), + ); if (failed) { throw failed; } @@ -1260,9 +1373,7 @@ async function persistAccountPool( ): Promise { if (results.length === 0) return; - const loadedStorage = replaceAll - ? null - : await loadAccounts(); + const loadedStorage = replaceAll ? null : await loadAccounts(); const now = Date.now(); const accounts = loadedStorage?.accounts ? [...loadedStorage.accounts] : []; @@ -1287,10 +1398,13 @@ async function persistAccountPool( tokenAccountId, ); const accountIdSource = accountId - ? (result.accountIdSource ?? (result.accountIdOverride ? "manual" : "token")) + ? (result.accountIdSource ?? + (result.accountIdOverride ? "manual" : "token")) : undefined; const accountLabel = result.accountLabel; - const accountEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + const accountEmail = sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ); const existingByEmail = accountEmail && indexByEmail.has(accountEmail) @@ -1364,14 +1478,19 @@ async function persistAccountPool( selectedAccountIndex = existingIndex; } - const fallbackActiveIndex = accounts.length === 0 - ? 0 - : Math.max(0, Math.min(loadedStorage?.activeIndex ?? 0, accounts.length - 1)); - const nextActiveIndex = accounts.length === 0 - ? 0 - : selectedAccountIndex === null - ? fallbackActiveIndex - : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); + const fallbackActiveIndex = + accounts.length === 0 + ? 0 + : Math.max( + 0, + Math.min(loadedStorage?.activeIndex ?? 0, accounts.length - 1), + ); + const nextActiveIndex = + accounts.length === 0 + ? 0 + : selectedAccountIndex === null + ? fallbackActiveIndex + : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); const activeIndexByFamily: Partial> = {}; for (const family of MODEL_FAMILIES) { activeIndexByFamily[family] = nextActiveIndex; @@ -1385,14 +1504,18 @@ async function persistAccountPool( }); } -async function syncSelectionToCodex(tokens: TokenSuccessWithAccount): Promise { +async function syncSelectionToCodex( + tokens: TokenSuccessWithAccount, +): Promise { const tokenAccountId = extractAccountId(tokens.access); const accountId = resolveRequestAccountId( tokens.accountIdOverride, tokens.accountIdSource, tokenAccountId, ); - const email = sanitizeEmail(extractAccountEmail(tokens.access, tokens.idToken)); + const email = sanitizeEmail( + extractAccountEmail(tokens.access, tokens.idToken), + ); await setCodexCliActiveSelection({ accountId, email, @@ -1430,9 +1553,10 @@ async function showAccountStatus(): Promise { const cooldown = formatCooldown(account, now); if (cooldown) markers.push(`cooldown:${cooldown}`); const markerLabel = markers.length > 0 ? ` [${markers.join(", ")}]` : ""; - const lastUsed = typeof account.lastUsed === "number" && account.lastUsed > 0 - ? `used ${formatWaitTime(now - account.lastUsed)} ago` - : "never used"; + const lastUsed = + typeof account.lastUsed === "number" && account.lastUsed > 0 + ? `used ${formatWaitTime(now - account.lastUsed)} ago` + : "never used"; console.log(`${i + 1}. ${label}${markerLabel} ${lastUsed}`); } } @@ -1465,12 +1589,14 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const activeIndex = resolveActiveIndex(storage, "codex"); let activeAccountRefreshed = false; const now = Date.now(); - console.log(stylePromptText( - forceRefresh - ? `Checking ${storage.accounts.length} account(s) with full refresh test...` - : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, - "accent", - )); + console.log( + stylePromptText( + forceRefresh + ? `Checking ${storage.accounts.length} account(s) with full refresh test...` + : `Checking ${storage.accounts.length} account(s) with quick check${liveProbe ? " + live check" : ""}...`, + "accent", + ), + ); for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; if (!account) continue; @@ -1493,7 +1619,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { : undefined; if (!probeAccountId || !currentAccessToken) { warnings += 1; - healthDetail = "signed in and working (live check skipped: missing account ID)"; + healthDetail = + "signed in and working (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -1503,7 +1630,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { }); if (quotaCache) { quotaCacheChanged = - updateQuotaCacheForAccount(quotaCache, account, snapshot) || quotaCacheChanged; + updateQuotaCacheForAccount(quotaCache, account, snapshot) || + quotaCacheChanged; } healthDetail = formatQuotaSnapshotForDashboard(snapshot, display); } catch (error) { @@ -1530,7 +1658,9 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const result = await queuedRefresh(account.refreshToken); if (result.type === "success") { const tokenAccountId = extractAccountId(result.access); - const nextEmail = sanitizeEmail(extractAccountEmail(result.access, result.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(result.access, result.idToken), + ); if (account.refreshToken !== result.refresh) { account.refreshToken = result.refresh; changed = true; @@ -1566,7 +1696,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { const probeAccountId = account.accountId ?? tokenAccountId; if (!probeAccountId) { warnings += 1; - healthyMessage = "working now (live check skipped: missing account ID)"; + healthyMessage = + "working now (live check skipped: missing account ID)"; } else { try { const snapshot = await fetchCodexQuotaSnapshot({ @@ -1576,7 +1707,8 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { }); if (quotaCache) { quotaCacheChanged = - updateQuotaCacheForAccount(quotaCache, account, snapshot) || quotaCacheChanged; + updateQuotaCacheForAccount(quotaCache, account, snapshot) || + quotaCacheChanged; } healthyMessage = formatQuotaSnapshotForDashboard(snapshot, display); } catch (error) { @@ -1615,7 +1747,12 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (quotaCache && quotaCacheChanged) { await saveQuotaCache(quotaCache); @@ -1625,7 +1762,11 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { await saveAccounts(storage); } - if (activeAccountRefreshed && activeIndex >= 0 && activeIndex < storage.accounts.length) { + if ( + activeAccountRefreshed && + activeIndex >= 0 && + activeIndex < storage.accounts.length + ) { const activeAccount = storage.accounts[activeIndex]; if (activeAccount) { await setCodexCliActiveSelection({ @@ -1639,11 +1780,19 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise { } console.log(""); - console.log(formatResultSummary([ - { text: `${ok} working`, tone: "success" }, - { text: `${failed} need re-login`, tone: failed > 0 ? "danger" : "muted" }, - { text: `${warnings} warning${warnings === 1 ? "" : "s"}`, tone: warnings > 0 ? "warning" : "muted" }, - ])); + console.log( + formatResultSummary([ + { text: `${ok} working`, tone: "success" }, + { + text: `${failed} need re-login`, + tone: failed > 0 ? "danger" : "muted", + }, + { + text: `${warnings} warning${warnings === 1 ? "" : "s"}`, + tone: warnings > 0 ? "warning" : "muted", + }, + ]), + ); } interface ForecastCliOptions { @@ -1672,7 +1821,9 @@ interface VerifyFlaggedCliOptions { restore: boolean; } -type ParsedArgsResult = { ok: true; options: T } | { ok: false; message: string }; +type ParsedArgsResult = + | { ok: true; options: T } + | { ok: false; message: string }; function printForecastUsage(): void { console.log( @@ -1726,7 +1877,9 @@ function printVerifyFlaggedUsage(): void { ); } -function parseForecastArgs(args: string[]): ParsedArgsResult { +function parseForecastArgs( + args: string[], +): ParsedArgsResult { const options: ForecastCliOptions = { live: false, json: false, @@ -1814,7 +1967,9 @@ function parseFixArgs(args: string[]): ParsedArgsResult { return { ok: true, options }; } -function parseVerifyFlaggedArgs(args: string[]): ParsedArgsResult { +function parseVerifyFlaggedArgs( + args: string[], +): ParsedArgsResult { const options: VerifyFlaggedCliOptions = { dryRun: false, json: false, @@ -1963,7 +2118,10 @@ function parseReportArgs(args: string[]): ParsedArgsResult { function serializeForecastResults( results: ForecastAccountResult[], - liveQuotaByIndex: Map>>, + liveQuotaByIndex: Map< + number, + Awaited> + >, refreshFailures: Map, ): Array<{ index: number; @@ -1996,12 +2154,12 @@ function serializeForecastResults( reasons: result.reasons, liveQuota: liveQuota ? { - status: liveQuota.status, - planType: liveQuota.planType, - activeLimit: liveQuota.activeLimit, - model: liveQuota.model, - summary: formatQuotaSnapshotLine(liveQuota), - } + status: liveQuota.status, + planType: liveQuota.planType, + activeLimit: liveQuota.activeLimit, + model: liveQuota.model, + summary: formatQuotaSnapshotLine(liveQuota), + } : undefined, refreshFailure: refreshFailures.get(result.index), }; @@ -2035,7 +2193,10 @@ async function runForecast(args: string[]): Promise { const now = Date.now(); const activeIndex = resolveActiveIndex(storage, "codex"); const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; for (let i = 0; i < storage.accounts.length; i += 1) { @@ -2044,22 +2205,29 @@ async function runForecast(args: string[]): Promise { if (account.enabled === false) continue; let probeAccessToken = account.accessToken; - let probeAccountId = account.accountId ?? extractAccountId(account.accessToken); + let probeAccountId = + account.accountId ?? extractAccountId(account.accessToken); if (!hasUsableAccessToken(account, now)) { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } probeAccessToken = refreshResult.access; - probeAccountId = account.accountId ?? extractAccountId(refreshResult.access); + probeAccountId = + account.accountId ?? extractAccountId(refreshResult.access); } if (!probeAccessToken || !probeAccountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2074,7 +2242,8 @@ async function runForecast(args: string[]): Promise { const account = storage.accounts[i]; if (account) { quotaCacheChanged = - updateQuotaCacheForAccount(quotaCache, account, liveQuota) || quotaCacheChanged; + updateQuotaCacheForAccount(quotaCache, account, liveQuota) || + quotaCacheChanged; } } } catch (error) { @@ -2111,7 +2280,11 @@ async function runForecast(args: string[]): Promise { summary, recommendation, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, null, 2, @@ -2130,8 +2303,14 @@ async function runForecast(args: string[]): Promise { formatResultSummary([ { text: `${summary.ready} ready now`, tone: "success" }, { text: `${summary.delayed} waiting`, tone: "warning" }, - { text: `${summary.unavailable} unavailable`, tone: summary.unavailable > 0 ? "danger" : "muted" }, - { text: `${summary.highRisk} high risk`, tone: summary.highRisk > 0 ? "danger" : "muted" }, + { + text: `${summary.unavailable} unavailable`, + tone: summary.unavailable > 0 ? "danger" : "muted", + }, + { + text: `${summary.highRisk} high risk`, + tone: summary.highRisk > 0 ? "danger" : "muted", + }, ]), ); console.log(""); @@ -2141,25 +2320,48 @@ async function runForecast(args: string[]): Promise { continue; } const currentTag = result.isCurrent ? " [current]" : ""; - const waitLabel = result.waitMs > 0 ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") : ""; + const waitLabel = + result.waitMs > 0 + ? stylePromptText(`wait ${formatWaitTime(result.waitMs)}`, "muted") + : ""; const indexLabel = stylePromptText(`${result.index + 1}.`, "accent"); - const accountLabel = stylePromptText(`${result.label}${currentTag}`, "accent"); - const riskLabel = stylePromptText(`${result.riskLevel} risk (${result.riskScore})`, riskTone(result.riskLevel)); - const availabilityLabel = stylePromptText(result.availability, availabilityTone(result.availability)); + const accountLabel = stylePromptText( + `${result.label}${currentTag}`, + "accent", + ); + const riskLabel = stylePromptText( + `${result.riskLevel} risk (${result.riskScore})`, + riskTone(result.riskLevel), + ); + const availabilityLabel = stylePromptText( + result.availability, + availabilityTone(result.availability), + ); const rowParts = [availabilityLabel, riskLabel]; if (waitLabel) rowParts.push(waitLabel); - console.log(`${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`); + console.log( + `${indexLabel} ${accountLabel} ${stylePromptText("|", "muted")} ${joinStyledSegments(rowParts)}`, + ); if (display.showForecastReasons && result.reasons.length > 0) { - console.log(` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`); + console.log( + ` ${stylePromptText(result.reasons.slice(0, 3).join("; "), "muted")}`, + ); } const liveQuota = liveQuotaByIndex.get(result.index); if (display.showQuotaDetails && liveQuota) { - console.log(` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`); + console.log( + ` ${stylePromptText("quota:", "accent")} ${styleQuotaSummary(formatCompactQuotaSnapshot(liveQuota))}`, + ); } } if (!display.showPerAccountRows) { - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { @@ -2171,21 +2373,31 @@ async function runForecast(args: string[]): Promise { console.log( `${stylePromptText("Best next account:", "accent")} ${stylePromptText(`${index + 1} (${account.label})`, "success")}`, ); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (index !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${index + 1}`, + ); } } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); } } if (display.showLiveProbeNotes && probeErrors.length > 0) { console.log(""); - console.log(stylePromptText(`Live check notes (${probeErrors.length}):`, "warning")); + console.log( + stylePromptText(`Live check notes (${probeErrors.length}):`, "warning"), + ); for (const error of probeErrors) { - console.log(` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`); + console.log( + ` ${stylePromptText("-", "warning")} ${stylePromptText(error, "muted")}`, + ); } } if (quotaCache && quotaCacheChanged) { @@ -2216,7 +2428,10 @@ async function runReport(args: string[]): Promise { const accountCount = storage?.accounts.length ?? 0; const activeIndex = storage ? resolveActiveIndex(storage, "codex") : 0; const refreshFailures = new Map(); - const liveQuotaByIndex = new Map>>(); + const liveQuotaByIndex = new Map< + number, + Awaited> + >(); const probeErrors: string[] = []; if (storage && options.live) { @@ -2228,14 +2443,20 @@ async function runReport(args: string[]): Promise { if (refreshResult.type !== "success") { refreshFailures.set(i, { ...refreshResult, - message: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + message: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); continue; } - const accountId = account.accountId ?? extractAccountId(refreshResult.access); + const accountId = + account.accountId ?? extractAccountId(refreshResult.access); if (!accountId) { - probeErrors.push(`${formatAccountLabel(account, i)}: missing accountId for live probe`); + probeErrors.push( + `${formatAccountLabel(account, i)}: missing accountId for live probe`, + ); continue; } @@ -2277,11 +2498,14 @@ async function runReport(args: string[]): Promise { const coolingCount = storage ? storage.accounts.filter( (account) => - typeof account.coolingDownUntil === "number" && account.coolingDownUntil > now, + typeof account.coolingDownUntil === "number" && + account.coolingDownUntil > now, ).length : 0; const rateLimitedCount = storage - ? storage.accounts.filter((account) => !!formatRateLimitEntry(account, now, "codex")).length + ? storage.accounts.filter( + (account) => !!formatRateLimitEntry(account, now, "codex"), + ).length : 0; const report = { @@ -2302,14 +2526,22 @@ async function runReport(args: string[]): Promise { summary: forecastSummary, recommendation, probeErrors, - accounts: serializeForecastResults(forecastResults, liveQuotaByIndex, refreshFailures), + accounts: serializeForecastResults( + forecastResults, + liveQuotaByIndex, + refreshFailures, + ), }, }; if (options.outPath) { const outputPath = resolve(process.cwd(), options.outPath); await fs.mkdir(dirname(outputPath), { recursive: true }); - await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf-8"); + await fs.writeFile( + outputPath, + `${JSON.stringify(report, null, 2)}\n`, + "utf-8", + ); } if (options.json) { @@ -2357,9 +2589,7 @@ interface FixAccountReport { message: string; } -function summarizeFixReports( - reports: FixAccountReport[], -): { +function summarizeFixReports(reports: FixAccountReport[]): { healthy: number; disabled: number; warnings: number; @@ -2412,7 +2642,10 @@ function findExistingAccountIndexForFlagged( for (let i = 0; i < storage.accounts.length; i += 1) { const account = storage.accounts[i]; if (!account) continue; - if (account.refreshToken === flagged.refreshToken || account.refreshToken === nextRefreshToken) { + if ( + account.refreshToken === flagged.refreshToken || + account.refreshToken === nextRefreshToken + ) { return i; } if ( @@ -2437,8 +2670,12 @@ function upsertRecoveredFlaggedAccount( refreshResult: TokenSuccess, now: number, ): { restored: boolean; changed: boolean; message: string } { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)) ?? flagged.email; - const nextAccountId = extractAccountId(refreshResult.access) ?? flagged.accountId; + const nextEmail = + sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ) ?? flagged.email; + const nextAccountId = + extractAccountId(refreshResult.access) ?? flagged.accountId; const existingIndex = findExistingAccountIndexForFlagged( storage, flagged, @@ -2450,7 +2687,11 @@ function upsertRecoveredFlaggedAccount( if (existingIndex >= 0) { const existing = storage.accounts[existingIndex]; if (!existing) { - return { restored: false, changed: false, message: "existing account entry is missing" }; + return { + restored: false, + changed: false, + message: "existing account entry is missing", + }; } let changed = false; if (existing.refreshToken !== refreshResult.refresh) { @@ -2478,7 +2719,10 @@ function upsertRecoveredFlaggedAccount( existing.enabled = true; changed = true; } - if (existing.accountLabel !== flagged.accountLabel && flagged.accountLabel) { + if ( + existing.accountLabel !== flagged.accountLabel && + flagged.accountLabel + ) { existing.accountLabel = flagged.accountLabel; changed = true; } @@ -2582,8 +2826,12 @@ async function runVerifyFlagged(args: string[]): Promise { accessToken: result.access, expiresAt: result.expires, accountId: extractAccountId(result.access) ?? flagged.accountId, - accountIdSource: extractAccountId(result.access) ? "token" : flagged.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + accountIdSource: extractAccountId(result.access) + ? "token" + : flagged.accountIdSource, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? + flagged.email, lastUsed: now, lastError: undefined, }; @@ -2595,12 +2843,18 @@ async function runVerifyFlagged(args: string[]): Promise { index: i, label, outcome: "healthy-flagged", - message: "session is healthy (left in flagged list due to --no-restore)", + message: + "session is healthy (left in flagged list due to --no-restore)", }); continue; } - const upsertResult = upsertRecoveredFlaggedAccount(storage, flagged, result, now); + const upsertResult = upsertRecoveredFlaggedAccount( + storage, + flagged, + result, + now, + ); if (upsertResult.restored) { storageChanged = storageChanged || upsertResult.changed; flaggedChanged = true; @@ -2619,8 +2873,12 @@ async function runVerifyFlagged(args: string[]): Promise { accessToken: result.access, expiresAt: result.expires, accountId: extractAccountId(result.access) ?? flagged.accountId, - accountIdSource: extractAccountId(result.access) ? "token" : flagged.accountIdSource, - email: sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? flagged.email, + accountIdSource: extractAccountId(result.access) + ? "token" + : flagged.accountIdSource, + email: + sanitizeEmail(extractAccountEmail(result.access, result.idToken)) ?? + flagged.email, lastUsed: now, lastError: upsertResult.message, }; @@ -2655,9 +2913,15 @@ async function runVerifyFlagged(args: string[]): Promise { } const remainingFlagged = nextFlaggedAccounts.length; - const restored = reports.filter((report) => report.outcome === "restored").length; - const healthyFlagged = reports.filter((report) => report.outcome === "healthy-flagged").length; - const stillFlagged = reports.filter((report) => report.outcome === "still-flagged").length; + const restored = reports.filter( + (report) => report.outcome === "restored", + ).length; + const healthyFlagged = reports.filter( + (report) => report.outcome === "healthy-flagged", + ).length; + const stillFlagged = reports.filter( + (report) => report.outcome === "still-flagged", + ).length; const changed = storageChanged || flaggedChanged; if (!options.dryRun) { @@ -2702,32 +2966,47 @@ async function runVerifyFlagged(args: string[]): Promise { ), ); for (const report of reports) { - const tone = report.outcome === "restored" - ? "success" - : report.outcome === "healthy-flagged" - ? "warning" - : report.outcome === "restore-skipped" + const tone = + report.outcome === "restored" + ? "success" + : report.outcome === "healthy-flagged" ? "warning" - : "danger"; - const marker = report.outcome === "restored" - ? "✓" - : report.outcome === "healthy-flagged" - ? "!" - : report.outcome === "restore-skipped" + : report.outcome === "restore-skipped" + ? "warning" + : "danger"; + const marker = + report.outcome === "restored" + ? "✓" + : report.outcome === "healthy-flagged" ? "!" - : "✗"; + : report.outcome === "restore-skipped" + ? "!" + : "✗"; console.log( `${stylePromptText(marker, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone)}`, ); } console.log(""); - console.log(formatResultSummary([ - { text: `${restored} restored`, tone: restored > 0 ? "success" : "muted" }, - { text: `${healthyFlagged} healthy (kept flagged)`, tone: healthyFlagged > 0 ? "warning" : "muted" }, - { text: `${stillFlagged} still flagged`, tone: stillFlagged > 0 ? "danger" : "muted" }, - ])); + console.log( + formatResultSummary([ + { + text: `${restored} restored`, + tone: restored > 0 ? "success" : "muted", + }, + { + text: `${healthyFlagged} healthy (kept flagged)`, + tone: healthyFlagged > 0 ? "warning" : "muted", + }, + { + text: `${stillFlagged} still flagged`, + tone: stillFlagged > 0 ? "danger" : "muted", + }, + ]), + ); if (options.dryRun) { - console.log(stylePromptText("Preview only: no changes were saved.", "warning")); + console.log( + stylePromptText("Preview only: no changes were saved.", "warning"), + ); } else if (!changed) { console.log(stylePromptText("No storage changes were needed.", "muted")); } @@ -2796,7 +3075,8 @@ async function runFix(args: string[]): Promise { }); if (quotaCache) { quotaCacheChanged = - updateQuotaCacheForAccount(quotaCache, account, snapshot) || quotaCacheChanged; + updateQuotaCacheForAccount(quotaCache, account, snapshot) || + quotaCacheChanged; } reports.push({ index: i, @@ -2836,7 +3116,9 @@ async function runFix(args: string[]): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); const nextAccountId = extractAccountId(refreshResult.access); let accountChanged = false; @@ -2874,7 +3156,8 @@ async function runFix(args: string[]): Promise { }); if (quotaCache) { quotaCacheChanged = - updateQuotaCacheForAccount(quotaCache, account, snapshot) || quotaCacheChanged; + updateQuotaCacheForAccount(quotaCache, account, snapshot) || + quotaCacheChanged; } reports.push({ index: i, @@ -2909,7 +3192,10 @@ async function runFix(args: string[]): Promise { continue; } - const detail = normalizeFailureDetail(refreshResult.message, refreshResult.reason); + const detail = normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ); refreshFailures.set(i, { ...refreshResult, message: detail, @@ -2935,13 +3221,17 @@ async function runFix(args: string[]): Promise { } if (hardDisabledIndexes.length > 0) { - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (enabledCount === 0) { - const fallbackIndex = - hardDisabledIndexes.includes(activeIndex) ? activeIndex : hardDisabledIndexes[0]; - const fallback = typeof fallbackIndex === "number" - ? storage.accounts[fallbackIndex] - : undefined; + const fallbackIndex = hardDisabledIndexes.includes(activeIndex) + ? activeIndex + : hardDisabledIndexes[0]; + const fallback = + typeof fallbackIndex === "number" + ? storage.accounts[fallbackIndex] + : undefined; if (fallback && fallback.enabled === false) { fallback.enabled = true; changed = true; @@ -2990,7 +3280,7 @@ async function runFix(args: string[]): Promise { recommendation, recommendedSwitchCommand: recommendation.recommendedIndex !== null && - recommendation.recommendedIndex !== activeIndex + recommendation.recommendedIndex !== activeIndex ? `codex auth switch ${recommendation.recommendedIndex + 1}` : null, reports, @@ -3002,16 +3292,26 @@ async function runFix(args: string[]): Promise { return 0; } - console.log(stylePromptText(`Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, "accent")); - console.log(formatResultSummary([ - { text: `${reportSummary.healthy} working`, tone: "success" }, - { text: `${reportSummary.disabled} disabled`, tone: reportSummary.disabled > 0 ? "danger" : "muted" }, - { - text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, - tone: reportSummary.warnings > 0 ? "warning" : "muted", - }, - { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, - ])); + console.log( + stylePromptText( + `Auto-fix scan (${options.dryRun ? "preview" : "apply"})`, + "accent", + ), + ); + console.log( + formatResultSummary([ + { text: `${reportSummary.healthy} working`, tone: "success" }, + { + text: `${reportSummary.disabled} disabled`, + tone: reportSummary.disabled > 0 ? "danger" : "muted", + }, + { + text: `${reportSummary.warnings} warning${reportSummary.warnings === 1 ? "" : "s"}`, + tone: reportSummary.warnings > 0 ? "warning" : "muted", + }, + { text: `${reportSummary.skipped} already disabled`, tone: "muted" }, + ]), + ); if (display.showPerAccountRows) { console.log(""); for (const report of reports) { @@ -3023,33 +3323,47 @@ async function runFix(args: string[]): Promise { : report.outcome === "warning-soft-failure" ? "!" : "-"; - const tone = report.outcome === "healthy" - ? "success" - : report.outcome === "disabled-hard-failure" - ? "danger" - : report.outcome === "warning-soft-failure" - ? "warning" - : "muted"; + const tone = + report.outcome === "healthy" + ? "success" + : report.outcome === "disabled-hard-failure" + ? "danger" + : report.outcome === "warning-soft-failure" + ? "warning" + : "muted"; console.log( `${stylePromptText(prefix, tone)} ${stylePromptText(`${report.index + 1}. ${report.label}`, "accent")} ${stylePromptText("|", "muted")} ${styleAccountDetailText(report.message, tone === "success" ? "muted" : tone)}`, ); } } else { console.log(""); - console.log(stylePromptText("Per-account lines are hidden in dashboard settings.", "muted")); + console.log( + stylePromptText( + "Per-account lines are hidden in dashboard settings.", + "muted", + ), + ); } if (display.showRecommendations) { console.log(""); if (recommendation.recommendedIndex !== null) { const target = recommendation.recommendedIndex + 1; - console.log(`${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`); - console.log(`${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Best next account:", "accent")} ${stylePromptText(String(target), "success")}`, + ); + console.log( + `${stylePromptText("Why:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); if (recommendation.recommendedIndex !== activeIndex) { - console.log(`${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`); + console.log( + `${stylePromptText("Switch now with:", "accent")} codex auth switch ${target}`, + ); } } else { - console.log(`${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`); + console.log( + `${stylePromptText("Note:", "accent")} ${stylePromptText(recommendation.reason, "muted")}`, + ); } } if (quotaCache && quotaCacheChanged) { @@ -3057,7 +3371,9 @@ async function runFix(args: string[]): Promise { } if (changed && options.dryRun) { - console.log(`\n${stylePromptText("Preview only: no changes were saved.", "warning")}`); + console.log( + `\n${stylePromptText("Preview only: no changes were saved.", "warning")}`, + ); } else if (changed) { console.log(`\n${stylePromptText("Saved updates.", "success")}`); } else { @@ -3095,7 +3411,8 @@ function hasPlaceholderEmail(value: string | undefined): boolean { function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { const total = storage.accounts.length; - const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); + const nextActive = + total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); let changed = false; if (storage.activeIndex !== nextActive) { storage.activeIndex = nextActive; @@ -3105,8 +3422,10 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { for (const family of MODEL_FAMILIES) { const raw = storage.activeIndexByFamily[family]; const fallback = storage.activeIndex; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; - const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); + const candidate = + typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; + const clamped = + total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); if (storage.activeIndexByFamily[family] !== clamped) { storage.activeIndexByFamily[family] = clamped; changed = true; @@ -3115,7 +3434,10 @@ function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { return changed; } -function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { +function applyDoctorFixes(storage: AccountStorageV3): { + changed: boolean; + actions: DoctorFixAction[]; +} { let changed = false; const actions: DoctorFixAction[] = []; @@ -3172,7 +3494,9 @@ function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; action } } - const enabledCount = storage.accounts.filter((account) => account.enabled !== false).length; + const enabledCount = storage.accounts.filter( + (account) => account.enabled !== false, + ).length; if (storage.accounts.length > 0 && enabledCount === 0) { const index = resolveActiveIndex(storage, "codex"); const candidate = storage.accounts[index] ?? storage.accounts[0]; @@ -3229,7 +3553,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "storage-readable", severity: stat.size > 0 ? "ok" : "warn", - message: stat.size > 0 ? "Storage file is readable" : "Storage file is empty", + message: + stat.size > 0 ? "Storage file is readable" : "Storage file is empty", details: `${stat.size} bytes`, }); } catch (error) { @@ -3262,20 +3587,27 @@ async function runDoctor(args: string[]): Promise { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object") { const payload = parsed as Record; - const tokens = payload.tokens && typeof payload.tokens === "object" - ? (payload.tokens as Record) - : null; - const accessToken = tokens && typeof tokens.access_token === "string" - ? tokens.access_token - : undefined; - const idToken = tokens && typeof tokens.id_token === "string" - ? tokens.id_token - : undefined; - const accountIdFromFile = tokens && typeof tokens.account_id === "string" - ? tokens.account_id - : undefined; - const emailFromFile = typeof payload.email === "string" ? payload.email : undefined; - codexAuthEmail = sanitizeEmail(emailFromFile ?? extractAccountEmail(accessToken, idToken)); + const tokens = + payload.tokens && typeof payload.tokens === "object" + ? (payload.tokens as Record) + : null; + const accessToken = + tokens && typeof tokens.access_token === "string" + ? tokens.access_token + : undefined; + const idToken = + tokens && typeof tokens.id_token === "string" + ? tokens.id_token + : undefined; + const accountIdFromFile = + tokens && typeof tokens.account_id === "string" + ? tokens.account_id + : undefined; + const emailFromFile = + typeof payload.email === "string" ? payload.email : undefined; + codexAuthEmail = sanitizeEmail( + emailFromFile ?? extractAccountEmail(accessToken, idToken), + ); codexAuthAccountId = accountIdFromFile ?? extractAccountId(accessToken); } addCheck({ @@ -3310,7 +3642,9 @@ async function runDoctor(args: string[]): Promise { if (existsSync(codexConfigPath)) { try { const configRaw = await fs.readFile(codexConfigPath, "utf-8"); - const match = configRaw.match(/^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m); + const match = configRaw.match( + /^\s*cli_auth_credentials_store\s*=\s*"([^"]+)"\s*$/m, + ); if (match?.[1]) { codexAuthStoreMode = match[1].trim(); } @@ -3379,7 +3713,8 @@ async function runDoctor(args: string[]): Promise { }); const activeIndex = resolveActiveIndex(storage, "codex"); - const activeExists = activeIndex >= 0 && activeIndex < storage.accounts.length; + const activeExists = + activeIndex >= 0 && activeIndex < storage.accounts.length; addCheck({ key: "active-index", severity: activeExists ? "ok" : "error", @@ -3388,7 +3723,9 @@ async function runDoctor(args: string[]): Promise { : "Active index is out of range", }); - const disabledCount = storage.accounts.filter((a) => a.enabled === false).length; + const disabledCount = storage.accounts.filter( + (a) => a.enabled === false, + ).length; addCheck({ key: "enabled-accounts", severity: disabledCount >= storage.accounts.length ? "error" : "ok", @@ -3466,7 +3803,10 @@ async function runDoctor(args: string[]): Promise { })), ); const recommendation = recommendForecastAccount(forecastResults); - if (recommendation.recommendedIndex !== null && recommendation.recommendedIndex !== activeIndex) { + if ( + recommendation.recommendedIndex !== null && + recommendation.recommendedIndex !== activeIndex + ) { addCheck({ key: "recommended-switch", severity: "warn", @@ -3485,8 +3825,10 @@ async function runDoctor(args: string[]): Promise { const activeAccount = storage.accounts[activeIndex]; const managerActiveEmail = sanitizeEmail(activeAccount?.email); const managerActiveAccountId = activeAccount?.accountId; - const codexActiveEmail = sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; - const codexActiveAccountId = codexCliState?.activeAccountId ?? codexAuthAccountId; + const codexActiveEmail = + sanitizeEmail(codexCliState?.activeEmail) ?? codexAuthEmail; + const codexActiveAccountId = + codexCliState?.activeAccountId ?? codexAuthAccountId; const isEmailMismatch = !!managerActiveEmail && !!codexActiveEmail && @@ -3520,10 +3862,15 @@ async function runDoctor(args: string[]): Promise { message: `Prepared active-account token refresh for account ${activeIndex + 1} (dry-run)`, }); } else { - const refreshResult = await queuedRefresh(activeAccount.refreshToken); + const refreshResult = await queuedRefresh( + activeAccount.refreshToken, + ); if (refreshResult.type === "success") { const refreshedEmail = sanitizeEmail( - extractAccountEmail(refreshResult.access, refreshResult.idToken), + extractAccountEmail( + refreshResult.access, + refreshResult.idToken, + ), ); const refreshedAccountId = extractAccountId(refreshResult.access); activeAccount.accessToken = refreshResult.access; @@ -3548,7 +3895,10 @@ async function runDoctor(args: string[]): Promise { key: "doctor-refresh", severity: "warn", message: "Unable to refresh active account before Codex sync", - details: normalizeFailureDetail(refreshResult.message, refreshResult.reason), + details: normalizeFailureDetail( + refreshResult.message, + refreshResult.reason, + ), }); } } @@ -3580,7 +3930,8 @@ async function runDoctor(args: string[]): Promise { addCheck({ key: "codex-active-sync", severity: "warn", - message: "Failed to sync manager active account into Codex auth state", + message: + "Failed to sync manager active account into Codex auth state", }); } } else { @@ -3625,10 +3976,13 @@ async function runDoctor(args: string[]): Promise { console.log("Doctor diagnostics"); console.log(`Storage: ${storagePath}`); - console.log(`Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`); + console.log( + `Summary: ${summary.ok} ok, ${summary.warn} warnings, ${summary.error} errors`, + ); console.log(""); for (const check of checks) { - const marker = check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; + const marker = + check.severity === "ok" ? "✓" : check.severity === "warn" ? "!" : "✗"; console.log(`${marker} ${check.key}: ${check.message}`); if (check.details) { console.log(` ${check.details}`); @@ -3637,7 +3991,9 @@ async function runDoctor(args: string[]): Promise { if (options.fix) { console.log(""); if (fixActions.length > 0) { - console.log(`Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`); + console.log( + `Auto-fix actions (${options.dryRun ? "dry-run" : "applied"}):`, + ); for (const action of fixActions) { console.log(` - ${action.message}`); } @@ -3649,18 +4005,10 @@ async function runDoctor(args: string[]): Promise { return summary.error > 0 ? 1 : 0; } -async function clearAccountsAndReset(): Promise { - await saveAccounts({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }); -} - async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, + flaggedStorage?: FlaggedAccountStorageV1, ): Promise { if (typeof menuResult.switchAccountIndex === "number") { const index = menuResult.switchAccountIndex; @@ -3671,14 +4019,19 @@ async function handleManageAction( if (typeof menuResult.deleteAccountIndex === "number") { const idx = menuResult.deleteAccountIndex; if (idx >= 0 && idx < storage.accounts.length) { - storage.accounts.splice(idx, 1); - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; + const deleted = await deleteAccountAtIndex({ + storage, + index: idx, + flaggedStorage, + }); + if (deleted) { + const label = formatAccountLabel(deleted.removedAccount, idx); + const flaggedNote = + deleted.removedFlaggedCount > 0 + ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` + : ""; + console.log(`Deleted ${label}.${flaggedNote}`); } - await saveAccounts(storage); - console.log(`Deleted account ${idx + 1}.`); } return; } @@ -3703,7 +4056,9 @@ async function handleManageAction( const tokenResult = await runOAuthFlow(true); if (tokenResult.type !== "success") { - console.error(`Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Refresh failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return; } @@ -3718,8 +4073,7 @@ async function runAuthLogin(): Promise { setStoragePath(null); let pendingMenuQuotaRefresh: Promise | null = null; let menuQuotaRefreshStatus: string | undefined; - loginFlow: - while (true) { + loginFlow: while (true) { let existingStorage = await loadAccounts(); if (existingStorage && existingStorage.accounts.length > 0) { while (true) { @@ -3731,11 +4085,17 @@ async function runAuthLogin(): Promise { const displaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(displaySettings); const quotaCache = await loadQuotaCache(); - const shouldAutoFetchLimits = displaySettings.menuAutoFetchLimits ?? true; + const shouldAutoFetchLimits = + displaySettings.menuAutoFetchLimits ?? true; const showFetchStatus = displaySettings.menuShowFetchStatus ?? true; - const quotaTtlMs = displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; + const quotaTtlMs = + displaySettings.menuQuotaTtlMs ?? DEFAULT_MENU_QUOTA_REFRESH_TTL_MS; if (shouldAutoFetchLimits && !pendingMenuQuotaRefresh) { - const staleCount = countMenuQuotaRefreshTargets(currentStorage, quotaCache, quotaTtlMs); + const staleCount = countMenuQuotaRefreshTargets( + currentStorage, + quotaCache, + quotaTtlMs, + ); if (staleCount > 0) { if (showFetchStatus) { menuQuotaRefreshStatus = `${UI_COPY.mainMenu.loadingLimits} [0/${staleCount}]`; @@ -3763,7 +4123,9 @@ async function runAuthLogin(): Promise { toExistingAccountInfo(currentStorage, quotaCache, displaySettings), { flaggedCount: flaggedStorage.accounts.length, - statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined, + statusMessage: showFetchStatus + ? () => menuQuotaRefreshStatus + : undefined, }, ); @@ -3772,27 +4134,47 @@ async function runAuthLogin(): Promise { return 0; } if (menuResult.mode === "check") { - await runActionPanel("Quick Check", "Checking local session + live status", async () => { - await runHealthCheck({ forceRefresh: false, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Quick Check", + "Checking local session + live status", + async () => { + await runHealthCheck({ forceRefresh: false, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "deep-check") { - await runActionPanel("Deep Check", "Refreshing and testing all accounts", async () => { - await runHealthCheck({ forceRefresh: true, liveProbe: true }); - }, displaySettings); + await runActionPanel( + "Deep Check", + "Refreshing and testing all accounts", + async () => { + await runHealthCheck({ forceRefresh: true, liveProbe: true }); + }, + displaySettings, + ); continue; } if (menuResult.mode === "forecast") { - await runActionPanel("Best Account", "Comparing accounts", async () => { - await runForecast(["--live"]); - }, displaySettings); + await runActionPanel( + "Best Account", + "Comparing accounts", + async () => { + await runForecast(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fix") { - await runActionPanel("Auto-Fix", "Checking and fixing common issues", async () => { - await runFix(["--live"]); - }, displaySettings); + await runActionPanel( + "Auto-Fix", + "Checking and fixing common issues", + async () => { + await runFix(["--live"]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "settings") { @@ -3800,27 +4182,65 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "verify-flagged") { - await runActionPanel("Problem Account Check", "Checking problem accounts", async () => { - await runVerifyFlagged([]); - }, displaySettings); + await runActionPanel( + "Problem Account Check", + "Checking problem accounts", + async () => { + await runVerifyFlagged([]); + }, + displaySettings, + ); continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel("Reset Accounts", "Deleting all saved accounts", async () => { - await clearAccountsAndReset(); - console.log("Deleted all accounts."); - }, displaySettings); + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, + async () => { + await deleteSavedAccounts(); + console.log( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed, + ); + }, + displaySettings, + ); + continue; + } + if (menuResult.mode === "reset") { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.resetLocalState.label, + DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, + async () => { + await resetLocalState(); + console.log(DESTRUCTIVE_ACTION_COPY.resetLocalState.completed); + }, + displaySettings, + ); continue; } if (menuResult.mode === "manage") { - const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; + const requiresInteractiveOAuth = + typeof menuResult.refreshAccountIndex === "number"; if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult); + await handleManageAction( + currentStorage, + menuResult, + flaggedStorage, + ); continue; } - await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult); - }, displaySettings); + await runActionPanel( + "Applying Change", + "Updating selected account", + async () => { + await handleManageAction( + currentStorage, + menuResult, + flaggedStorage, + ); + }, + displaySettings, + ); continue; } if (menuResult.mode === "add") { @@ -3837,13 +4257,17 @@ async function runAuthLogin(): Promise { if (tokenResult.type !== "success") { if (isUserCancelledOAuth(tokenResult)) { if (existingCount > 0) { - console.log(stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted")); + console.log( + stylePromptText(UI_COPY.oauth.cancelledBackToMenu, "muted"), + ); continue loginFlow; } console.log("Cancelled."); return 0; } - console.error(`Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`); + console.error( + `Login failed: ${tokenResult.message ?? tokenResult.reason ?? "unknown error"}`, + ); return 1; } @@ -3855,7 +4279,9 @@ async function runAuthLogin(): Promise { const count = latestStorage?.accounts.length ?? 1; console.log(`Added account. Total: ${count}`); if (count >= ACCOUNT_LIMITS.MAX_ACCOUNTS) { - console.log(`Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`); + console.log( + `Reached maximum account limit (${ACCOUNT_LIMITS.MAX_ACCOUNTS}).`, + ); break; } @@ -3863,7 +4289,6 @@ async function runAuthLogin(): Promise { if (!addAnother) break; forceNewLogin = true; } - continue loginFlow; } } @@ -3887,7 +4312,9 @@ async function runSwitch(args: string[]): Promise { return 1; } if (targetIndex < 0 || targetIndex >= storage.accounts.length) { - console.error(`Index out of range. Valid range: 1-${storage.accounts.length}`); + console.error( + `Index out of range. Valid range: 1-${storage.accounts.length}`, + ); return 1; } @@ -3916,7 +4343,9 @@ async function runSwitch(args: string[]): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; } @@ -3996,7 +4425,9 @@ export async function autoSyncActiveAccountToCodex(): Promise { const refreshResult = await queuedRefresh(account.refreshToken); if (refreshResult.type === "success") { const tokenAccountId = extractAccountId(refreshResult.access); - const nextEmail = sanitizeEmail(extractAccountEmail(refreshResult.access, refreshResult.idToken)); + const nextEmail = sanitizeEmail( + extractAccountEmail(refreshResult.access, refreshResult.idToken), + ); if (account.refreshToken !== refreshResult.refresh) { account.refreshToken = refreshResult.refresh; changed = true; diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts new file mode 100644 index 00000000..f8e436ef --- /dev/null +++ b/lib/destructive-actions.ts @@ -0,0 +1,126 @@ +import { clearCodexCliStateCache } from "./codex-cli/state.js"; +import { MODEL_FAMILIES } from "./prompts/codex.js"; +import { clearQuotaCache } from "./quota-cache.js"; +import { + type AccountMetadataV3, + type AccountStorageV3, + clearAccounts, + clearFlaggedAccounts, + type FlaggedAccountStorageV1, + loadFlaggedAccounts, + saveAccounts, + saveFlaggedAccounts, +} from "./storage.js"; + +export const DESTRUCTIVE_ACTION_COPY = { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", + stage: "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, +} as const; + +export function clampActiveIndices(storage: AccountStorageV3): void { + const count = storage.accounts.length; + const baseIndex = + typeof storage.activeIndex === "number" && + Number.isFinite(storage.activeIndex) + ? storage.activeIndex + : 0; + + if (count === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return; + } + + storage.activeIndex = Math.max(0, Math.min(baseIndex, count - 1)); + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + const fallback = storage.activeIndex; + const clamped = Math.max( + 0, + Math.min( + typeof rawIndex === "number" && Number.isFinite(rawIndex) + ? rawIndex + : fallback, + count - 1, + ), + ); + activeIndexByFamily[family] = clamped; + } + storage.activeIndexByFamily = activeIndexByFamily; +} + +export interface DeleteAccountResult { + storage: AccountStorageV3; + flagged: FlaggedAccountStorageV1; + removedAccount: AccountMetadataV3; + removedFlaggedCount: number; +} + +export async function deleteAccountAtIndex(options: { + storage: AccountStorageV3; + index: number; + flaggedStorage?: FlaggedAccountStorageV1; +}): Promise { + const target = options.storage.accounts.at(options.index); + if (!target) return null; + + options.storage.accounts.splice(options.index, 1); + clampActiveIndices(options.storage); + await saveAccounts(options.storage); + + const flagged = options.flaggedStorage ?? (await loadFlaggedAccounts()); + const remainingFlagged = flagged.accounts.filter( + (account) => account.refreshToken !== target.refreshToken, + ); + const removedFlaggedCount = flagged.accounts.length - remainingFlagged.length; + let updatedFlagged = flagged; + if (removedFlaggedCount > 0) { + updatedFlagged = { ...flagged, accounts: remainingFlagged }; + await saveFlaggedAccounts(updatedFlagged); + } + + return { + storage: options.storage, + flagged: updatedFlagged, + removedAccount: target, + removedFlaggedCount, + }; +} + +/** + * Delete saved accounts without touching flagged/problem accounts, settings, or Codex CLI sync state. + * Removes the accounts WAL and backups via the underlying storage helper. + */ +export async function deleteSavedAccounts(): Promise { + await clearAccounts(); +} + +/** + * Reset local multi-auth state: clears saved accounts, flagged/problem accounts, and quota cache. + * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. + */ +export async function resetLocalState(): Promise { + await clearAccounts(); + await clearFlaggedAccounts(); + await clearQuotaCache(); + clearCodexCliStateCache(); +} diff --git a/lib/quota-cache.ts b/lib/quota-cache.ts index 9870a2b6..18a71b56 100644 --- a/lib/quota-cache.ts +++ b/lib/quota-cache.ts @@ -46,7 +46,9 @@ function isRetryableFsError(error: unknown): boolean { * @returns The input as a finite number, or `undefined` if the value is not a finite number */ function normalizeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; } /** @@ -104,7 +106,7 @@ function normalizeEntry(value: unknown): QuotaCacheEntry | null { * * @param value - Parsed JSON value (typically an object) containing raw entries keyed by identifier; non-objects, empty keys, or invalid entries are ignored. * @returns A record mapping valid string keys to normalized `QuotaCacheEntry` objects; malformed entries are omitted. - * + * * Note: This function is pure and performs no filesystem I/O. Callers are responsible for any filesystem concurrency or Windows-specific behavior when loading/saving the on-disk cache, and for redacting any sensitive tokens before logging or persisting. */ function normalizeEntryMap(value: unknown): Record { @@ -132,7 +134,9 @@ async function readCacheFileWithRetry(path: string): Promise { await sleep(10 * 2 ** attempt); } } - throw lastError instanceof Error ? lastError : new Error("quota cache read retry exhausted"); + throw lastError instanceof Error + ? lastError + : new Error("quota cache read retry exhausted"); } /** @@ -179,7 +183,9 @@ export async function loadQuotaCache(): Promise { return { byAccountId: {}, byEmail: {} }; } if (parsed.version !== 1) { - logWarn(`Quota cache rejected due to version mismatch: ${String(parsed.version)}`); + logWarn( + `Quota cache rejected due to version mismatch: ${String(parsed.version)}`, + ); return { byAccountId: {}, byEmail: {} }; } @@ -259,3 +265,21 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { ); } } + +/** + * Deletes the on-disk quota cache file, ignoring missing files and logging non-ENOENT errors. + */ +export async function clearQuotaCache(): Promise { + try { + await fs.unlink(QUOTA_CACHE_PATH); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") { + logWarn( + `Failed to clear quota cache ${QUOTA_CACHE_LABEL}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index b7c7708f..de2546a8 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,11 +1,16 @@ -import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { DESTRUCTIVE_ACTION_COPY } from "../destructive-actions.js"; import { ANSI, isTTY } from "./ansi.js"; import { confirm } from "./confirm.js"; +import { formatCheckFlaggedLabel, UI_COPY } from "./copy.js"; +import { + formatUiBadge, + paintUiText, + quotaToneFromLeftPercent, +} from "./format.js"; import { getUiRuntimeOptions } from "./runtime.js"; -import { select, type MenuItem } from "./select.js"; -import { paintUiText, formatUiBadge, quotaToneFromLeftPercent } from "./format.js"; -import { UI_COPY, formatCheckFlaggedLabel } from "./copy.js"; +import { type MenuItem, select } from "./select.js"; export type AccountStatus = | "active" @@ -56,6 +61,7 @@ export type AuthMenuAction = | { type: "fix" } | { type: "settings" } | { type: "fresh" } + | { type: "reset-all" } | { type: "check" } | { type: "deep-check" } | { type: "verify-flagged" } @@ -68,7 +74,13 @@ export type AuthMenuAction = | { type: "delete-all" } | { type: "cancel" }; -export type AccountAction = "back" | "delete" | "refresh" | "toggle" | "set-current" | "cancel"; +export type AccountAction = + | "back" + | "delete" + | "refresh" + | "toggle" + | "set-current" + | "cancel"; function resolveCliVersionLabel(): string | null { const raw = (process.env.CODEX_MULTI_AUTH_CLI_VERSION ?? "").trim(); @@ -85,7 +97,7 @@ function mainMenuTitleWithVersion(): string { function sanitizeTerminalText(value: string | undefined): string | undefined { if (!value) return undefined; return value - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "") .replace(/[\u0000-\u001f\u007f]/g, "") .trim(); } @@ -112,10 +124,14 @@ function statusBadge(status: AccountStatus | undefined): string { tone: "accent" | "success" | "warning" | "danger" | "muted", ): string => { if (ui.v2Enabled) return formatUiBadge(ui, label, tone); - if (tone === "accent") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "success") return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "warning") return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; - if (tone === "danger") return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; + if (tone === "accent") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "success") + return `${ANSI.bgGreen}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "warning") + return `${ANSI.bgYellow}${ANSI.black}[${label}]${ANSI.reset}`; + if (tone === "danger") + return `${ANSI.bgRed}${ANSI.white}[${label}]${ANSI.reset}`; return `${ANSI.inverse}[${label}]${ANSI.reset}`; }; @@ -161,7 +177,7 @@ function statusBadge(status: AccountStatus | undefined): string { } function accountTitle(account: AccountInfo): string { - const accountNumber = account.quickSwitchNumber ?? (account.index + 1); + const accountNumber = account.quickSwitchNumber ?? account.index + 1; const base = sanitizeTerminalText(account.email) || sanitizeTerminalText(account.accountLabel) || @@ -175,15 +191,21 @@ function accountSearchText(account: AccountInfo): string { sanitizeTerminalText(account.email), sanitizeTerminalText(account.accountLabel), sanitizeTerminalText(account.accountId), - String(account.quickSwitchNumber ?? (account.index + 1)), + String(account.quickSwitchNumber ?? account.index + 1), ] - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) .join(" ") .toLowerCase(); } -function accountRowColor(account: AccountInfo): MenuItem["color"] { - if (account.isCurrentAccount && account.highlightCurrentRow !== false) return "green"; +function accountRowColor( + account: AccountInfo, +): MenuItem["color"] { + if (account.isCurrentAccount && account.highlightCurrentRow !== false) + return "green"; switch (account.status) { case "active": case "ok": @@ -200,7 +222,9 @@ function accountRowColor(account: AccountInfo): MenuItem["color" } } -function statusTone(status: AccountStatus | undefined): "success" | "warning" | "danger" | "muted" { +function statusTone( + status: AccountStatus | undefined, +): "success" | "warning" | "danger" | "muted" { switch (status) { case "active": case "ok": @@ -226,12 +250,16 @@ function normalizeQuotaPercent(value: number | undefined): number | null { return Math.max(0, Math.min(100, Math.round(value))); } -function parseLeftPercentFromSummary(summary: string, windowLabel: "5h" | "7d"): number | null { +function parseLeftPercentFromSummary( + summary: string, + windowLabel: "5h" | "7d", +): number | null { const segments = summary.split("|"); for (const segment of segments) { const trimmed = segment.trim().toLowerCase(); if (!trimmed.startsWith(`${windowLabel} `)) continue; - const percentToken = trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; + const percentToken = + trimmed.slice(windowLabel.length).trim().split(/\s+/)[0] ?? ""; const parsed = Number.parseInt(percentToken.replace("%", ""), 10); if (!Number.isFinite(parsed)) continue; return Math.max(0, Math.min(100, parsed)); @@ -274,15 +302,21 @@ function formatQuotaBar( const filledText = "█".repeat(filled); const emptyText = "▒".repeat(width - filled); if (ui.v2Enabled) { - const tone = leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); - const filledSegment = filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; - const emptySegment = emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; + const tone = + leftPercent === null ? "muted" : quotaToneFromLeftPercent(leftPercent); + const filledSegment = + filledText.length > 0 ? paintUiText(ui, filledText, tone) : ""; + const emptySegment = + emptyText.length > 0 ? paintUiText(ui, emptyText, "muted") : ""; return `${filledSegment}${emptySegment}`; } if (leftPercent === null) return `${ANSI.dim}${emptyText}${ANSI.reset}`; - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; - const filledSegment = filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; - const emptySegment = emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; + const color = + leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const filledSegment = + filledText.length > 0 ? `${color}${filledText}${ANSI.reset}` : ""; + const emptySegment = + emptyText.length > 0 ? `${ANSI.dim}${emptyText}${ANSI.reset}` : ""; return `${filledSegment}${emptySegment}`; } @@ -293,7 +327,12 @@ function formatQuotaPercent( if (leftPercent === null) return null; const percentText = `${leftPercent}%`; if (!ui.v2Enabled) { - const color = leftPercent <= 15 ? ANSI.red : leftPercent <= 35 ? ANSI.yellow : ANSI.green; + const color = + leftPercent <= 15 + ? ANSI.red + : leftPercent <= 35 + ? ANSI.yellow + : ANSI.green; return `${color}${percentText}${ANSI.reset}`; } const tone = quotaToneFromLeftPercent(leftPercent); @@ -317,28 +356,60 @@ function formatQuotaWindow( if (!cooldown) { return percent ? `${labelText} ${bar} ${percent}` : `${labelText} ${bar}`; } - const cooldownText = ui.v2Enabled ? paintUiText(ui, cooldown, "muted") : cooldown; + const cooldownText = ui.v2Enabled + ? paintUiText(ui, cooldown, "muted") + : cooldown; if (!percent) { return `${labelText} ${bar} ${cooldownText}`; } return `${labelText} ${bar} ${percent} ${cooldownText}`; } -function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatQuotaSummary( + account: AccountInfo, + ui: ReturnType, +): string { const summary = account.quotaSummary ?? ""; const showCooldown = account.showQuotaCooldown !== false; - const left5h = normalizeQuotaPercent(account.quota5hLeftPercent) ?? parseLeftPercentFromSummary(summary, "5h"); - const left7d = normalizeQuotaPercent(account.quota7dLeftPercent) ?? parseLeftPercentFromSummary(summary, "7d"); + const left5h = + normalizeQuotaPercent(account.quota5hLeftPercent) ?? + parseLeftPercentFromSummary(summary, "5h"); + const left7d = + normalizeQuotaPercent(account.quota7dLeftPercent) ?? + parseLeftPercentFromSummary(summary, "7d"); const segments: string[] = []; if (left5h !== null || typeof account.quota5hResetAtMs === "number") { - segments.push(formatQuotaWindow("5h", left5h, account.quota5hResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "5h", + left5h, + account.quota5hResetAtMs, + showCooldown, + ui, + ), + ); } if (left7d !== null || typeof account.quota7dResetAtMs === "number") { - segments.push(formatQuotaWindow("7d", left7d, account.quota7dResetAtMs, showCooldown, ui)); + segments.push( + formatQuotaWindow( + "7d", + left7d, + account.quota7dResetAtMs, + showCooldown, + ui, + ), + ); } - if (account.quotaRateLimited || summary.toLowerCase().includes("rate-limited")) { - segments.push(ui.v2Enabled ? paintUiText(ui, "rate-limited", "danger") : `${ANSI.red}rate-limited${ANSI.reset}`); + if ( + account.quotaRateLimited || + summary.toLowerCase().includes("rate-limited") + ) { + segments.push( + ui.v2Enabled + ? paintUiText(ui, "rate-limited", "danger") + : `${ANSI.red}rate-limited${ANSI.reset}`, + ); } if (segments.length === 0) { @@ -350,7 +421,10 @@ function formatQuotaSummary(account: AccountInfo, ui: ReturnType): string { +function formatAccountHint( + account: AccountInfo, + ui: ReturnType, +): string { const withKey = ( key: string, value: string, @@ -365,19 +439,30 @@ function formatAccountHint(account: AccountInfo, ui: ReturnType(); if (account.showStatusBadge === false) { - partsByKey.set("status", withKey("Status:", statusText(account.status), statusTone(account.status))); + partsByKey.set( + "status", + withKey( + "Status:", + statusText(account.status), + statusTone(account.status), + ), + ); } if (account.showLastUsed !== false) { - partsByKey.set("last-used", withKey("Last used:", formatRelativeTime(account.lastUsed), "heading")); + partsByKey.set( + "last-used", + withKey("Last used:", formatRelativeTime(account.lastUsed), "heading"), + ); } const quotaSummaryText = formatQuotaSummary(account, ui); if (quotaSummaryText) { partsByKey.set("limits", withKey("Limits:", quotaSummaryText, "accent")); } - const fields = account.statuslineFields && account.statuslineFields.length > 0 - ? account.statuslineFields - : ["last-used", "limits", "status"]; + const fields = + account.statuslineFields && account.statuslineFields.length > 0 + ? account.statuslineFields + : ["last-used", "limits", "status"]; const orderedParts: string[] = []; for (const field of fields) { const part = partsByKey.get(field); @@ -426,6 +511,7 @@ function authMenuFocusKey(action: AuthMenuAction): string { case "fix": case "settings": case "fresh": + case "reset-all": case "check": case "deep-check": case "verify-flagged": @@ -448,13 +534,16 @@ export async function showAuthMenu( let focusKey = "action:add"; while (true) { const normalizedSearch = searchQuery.trim().toLowerCase(); - const visibleAccounts = normalizedSearch.length > 0 - ? accounts.filter((account) => accountSearchText(account).includes(normalizedSearch)) - : accounts; + const visibleAccounts = + normalizedSearch.length > 0 + ? accounts.filter((account) => + accountSearchText(account).includes(normalizedSearch), + ) + : accounts; const visibleByNumber = new Map(); const duplicateQuickSwitchNumbers = new Set(); for (const account of visibleAccounts) { - const quickSwitchNumber = account.quickSwitchNumber ?? (account.index + 1); + const quickSwitchNumber = account.quickSwitchNumber ?? account.index + 1; if (visibleByNumber.has(quickSwitchNumber)) { duplicateQuickSwitchNumbers.add(quickSwitchNumber); continue; @@ -463,18 +552,58 @@ export async function showAuthMenu( } const items: MenuItem[] = [ - { label: UI_COPY.mainMenu.quickStart, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.addAccount, value: { type: "add" }, color: "green" }, - { label: UI_COPY.mainMenu.checkAccounts, value: { type: "check" }, color: "green" }, - { label: UI_COPY.mainMenu.bestAccount, value: { type: "forecast" }, color: "green" }, - { label: UI_COPY.mainMenu.fixIssues, value: { type: "fix" }, color: "green" }, - { label: UI_COPY.mainMenu.settings, value: { type: "settings" }, color: "green" }, + { + label: UI_COPY.mainMenu.quickStart, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.addAccount, + value: { type: "add" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.checkAccounts, + value: { type: "check" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.bestAccount, + value: { type: "forecast" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.fixIssues, + value: { type: "fix" }, + color: "green", + }, + { + label: UI_COPY.mainMenu.settings, + value: { type: "settings" }, + color: "green", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.moreChecks, value: { type: "cancel" }, kind: "heading" }, - { label: UI_COPY.mainMenu.refreshChecks, value: { type: "deep-check" }, color: "green" }, - { label: verifyLabel, value: { type: "verify-flagged" }, color: flaggedCount > 0 ? "red" : "yellow" }, + { + label: UI_COPY.mainMenu.moreChecks, + value: { type: "cancel" }, + kind: "heading", + }, + { + label: UI_COPY.mainMenu.refreshChecks, + value: { type: "deep-check" }, + color: "green", + }, + { + label: verifyLabel, + value: { type: "verify-flagged" }, + color: flaggedCount > 0 ? "red" : "yellow", + }, { label: "", value: { type: "cancel" }, separator: true }, - { label: UI_COPY.mainMenu.accounts, value: { type: "cancel" }, kind: "heading" }, + { + label: UI_COPY.mainMenu.accounts, + value: { type: "cancel" }, + kind: "heading", + }, ]; if (visibleAccounts.length === 0) { @@ -486,20 +615,34 @@ export async function showAuthMenu( } else { items.push( ...visibleAccounts.map((account) => { - const currentBadge = account.isCurrentAccount && account.showCurrentBadge !== false - ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "current", "accent")}` : ` ${ANSI.cyan}[current]${ANSI.reset}`) - : ""; - const badge = account.showStatusBadge === false ? "" : statusBadge(account.status); + const currentBadge = + account.isCurrentAccount && account.showCurrentBadge !== false + ? ui.v2Enabled + ? ` ${formatUiBadge(ui, "current", "accent")}` + : ` ${ANSI.cyan}[current]${ANSI.reset}` + : ""; + const badge = + account.showStatusBadge === false + ? "" + : statusBadge(account.status); const statusSuffix = badge ? ` ${badge}` : ""; const title = ui.v2Enabled - ? paintUiText(ui, accountTitle(account), account.isCurrentAccount ? "accent" : "heading") + ? paintUiText( + ui, + accountTitle(account), + account.isCurrentAccount ? "accent" : "heading", + ) : accountTitle(account); const label = `${title}${currentBadge}${statusSuffix}`; const hint = formatAccountHint(account, ui); const hasHint = hint.length > 0; const hintText = ui.v2Enabled - ? (hasHint ? hint : undefined) - : (hasHint ? hint : undefined); + ? hasHint + ? hint + : undefined + : hasHint + ? hint + : undefined; return { label, hint: hintText, @@ -511,27 +654,45 @@ export async function showAuthMenu( } items.push({ label: "", value: { type: "cancel" }, separator: true }); - items.push({ label: UI_COPY.mainMenu.dangerZone, value: { type: "cancel" }, kind: "heading" }); - items.push({ label: UI_COPY.mainMenu.removeAllAccounts, value: { type: "delete-all" }, color: "red" }); + items.push({ + label: UI_COPY.mainMenu.dangerZone, + value: { type: "cancel" }, + kind: "heading", + }); + items.push({ + label: UI_COPY.mainMenu.removeAllAccounts, + value: { type: "delete-all" }, + color: "red", + }); + items.push({ + label: UI_COPY.mainMenu.resetLocalState, + value: { type: "reset-all" }, + color: "red", + }); const compactHelp = UI_COPY.mainMenu.helpCompact; const detailedHelp = UI_COPY.mainMenu.helpDetailed; - const showHintsForUnselectedRows = visibleAccounts[0]?.showHintsForUnselectedRows ?? + const showHintsForUnselectedRows = + visibleAccounts[0]?.showHintsForUnselectedRows ?? accounts[0]?.showHintsForUnselectedRows ?? false; - const focusStyle = visibleAccounts[0]?.focusStyle ?? - accounts[0]?.focusStyle ?? - "row-invert"; + const focusStyle = + visibleAccounts[0]?.focusStyle ?? accounts[0]?.focusStyle ?? "row-invert"; const resolveStatusMessage = (): string | undefined => { - const raw = typeof options.statusMessage === "function" - ? options.statusMessage() - : options.statusMessage; - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; + const raw = + typeof options.statusMessage === "function" + ? options.statusMessage() + : options.statusMessage; + return typeof raw === "string" && raw.trim().length > 0 + ? raw.trim() + : undefined; }; const buildSubtitle = (): string | undefined => { const parts: string[] = []; if (normalizedSearch.length > 0) { - parts.push(`${UI_COPY.mainMenu.searchSubtitlePrefix} ${normalizedSearch}`); + parts.push( + `${UI_COPY.mainMenu.searchSubtitlePrefix} ${normalizedSearch}`, + ); } const statusText = resolveStatusMessage(); if (statusText) { @@ -541,7 +702,8 @@ export async function showAuthMenu( return parts.join(" | "); }; const initialCursor = items.findIndex((item) => { - if (item.separator || item.disabled || item.kind === "heading") return false; + if (item.separator || item.disabled || item.kind === "heading") + return false; return authMenuFocusKey(item.value) === focusKey; }); @@ -582,7 +744,12 @@ export async function showAuthMenu( } const selected = context.items[context.cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") { + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) { return undefined; } if (selected.value.type !== "select-account") return undefined; @@ -590,7 +757,13 @@ export async function showAuthMenu( }, onCursorChange: ({ cursor }) => { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusKey = authMenuFocusKey(selected.value); }, }); @@ -602,15 +775,27 @@ export async function showAuthMenu( continue; } if (result.type === "delete-all") { - const confirmed = await confirm("Delete all accounts?"); + const confirmed = await confirm( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.confirm, + ); + if (!confirmed) continue; + } + if (result.type === "reset-all") { + const confirmed = await confirm( + DESTRUCTIVE_ACTION_COPY.resetLocalState.confirm, + ); if (!confirmed) continue; } if (result.type === "delete-account") { - const confirmed = await confirm(`Delete ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Delete ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } if (result.type === "refresh-account") { - const confirmed = await confirm(`Re-authenticate ${accountTitle(result.account)}?`); + const confirmed = await confirm( + `Re-authenticate ${accountTitle(result.account)}?`, + ); if (!confirmed) continue; } focusKey = authMenuFocusKey(result); @@ -618,14 +803,16 @@ export async function showAuthMenu( } } -export async function showAccountDetails(account: AccountInfo): Promise { +export async function showAccountDetails( + account: AccountInfo, +): Promise { const ui = getUiRuntimeOptions(); const header = `${accountTitle(account)} ${statusBadge(account.status)}` + (account.enabled === false - ? (ui.v2Enabled + ? ui.v2Enabled ? ` ${formatUiBadge(ui, "disabled", "danger")}` - : ` ${ANSI.red}[disabled]${ANSI.reset}`) + : ` ${ANSI.red}[disabled]${ANSI.reset}` : ""); const statusLabel = account.status ?? "unknown"; const subtitle = `Added: ${formatDate(account.addedAt)} | Used: ${formatRelativeTime(account.lastUsed)} | Status: ${statusLabel}`; @@ -635,7 +822,10 @@ export async function showAccountDetails(account: AccountInfo): Promise[] = [ { label: UI_COPY.accountDetails.back, value: "back" }, { - label: account.enabled === false ? UI_COPY.accountDetails.enable : UI_COPY.accountDetails.disable, + label: + account.enabled === false + ? UI_COPY.accountDetails.enable + : UI_COPY.accountDetails.disable, value: "toggle", color: account.enabled === false ? "green" : "yellow", }, @@ -644,7 +834,11 @@ export async function showAccountDetails(account: AccountInfo): Promise item.value === focusAction); @@ -668,7 +862,13 @@ export async function showAccountDetails(account: AccountInfo): Promise { const selected = items[cursor]; - if (!selected || selected.separator || selected.disabled || selected.kind === "heading") return; + if ( + !selected || + selected.separator || + selected.disabled || + selected.kind === "heading" + ) + return; focusAction = selected.value; }, }); @@ -680,7 +880,9 @@ export async function showAccountDetails(account: AccountInfo): Promise `Returning in ${seconds}s... Press any key to pause.`, + autoReturn: (seconds: number) => + `Returning in ${seconds}s... Press any key to pause.`, paused: "Paused. Press any key to continue.", working: "Running...", done: "Done.", @@ -75,22 +78,27 @@ export const UI_COPY = { backNoSave: "Back Without Saving", accountListTitle: "Account List View", accountListSubtitle: "Choose row details and optional smart sorting", - accountListHelp: "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", + accountListHelp: + "Enter Toggle | Number Toggle | M Sort | L Layout | S Save | Q Back (No Save)", summaryTitle: "Account Details Row", summarySubtitle: "Choose and order detail fields", - summaryHelp: "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", + summaryHelp: + "Enter Toggle | 1-3 Toggle | [ ] Reorder | S Save | Q Back (No Save)", behaviorTitle: "Return Behavior", behaviorSubtitle: "Control how result screens return", - behaviorHelp: "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", + behaviorHelp: + "Enter Select | 1-3 Delay | P Pause | L AutoFetch | F Status | T TTL | S Save | Q Back (No Save)", themeTitle: "Color Theme", themeSubtitle: "Pick base color and accent", themeHelp: "Enter Select | 1-2 Base | S Save | Q Back (No Save)", backendTitle: "Backend Controls", backendSubtitle: "Tune sync, retry, and limit behavior", - backendHelp: "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", + backendHelp: + "Enter Open | 1-4 Category | S Save | R Reset | Q Back (No Save)", backendCategoriesHeading: "Categories", backendCategoryTitle: "Backend Category", - backendCategoryHelp: "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", + backendCategoryHelp: + "Enter Toggle/Adjust | +/- or [ ] Number | 1-9 Toggle | R Reset | Q Back", backendToggleHeading: "Switches", backendNumberHeading: "Numbers", backendDecrease: "Decrease Focused Value", @@ -104,11 +112,13 @@ export const UI_COPY = { moveDown: "Move Focused Field Down", }, fallback: { - addAnotherTip: "Tip: Use private mode or sign out before adding another account.", - addAnotherQuestion: (count: number) => `Add another account? (${count} added) (y/n): `, + addAnotherTip: + "Tip: Use private mode or sign out before adding another account.", + addAnotherQuestion: (count: number) => + `Add another account? (${count} added) (y/n): `, selectModePrompt: - "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (q) back [a/c/b/x/s/d/g/f/q]: ", - invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, q.", + "(a) add, (c) check, (b) best, fi(x), (s) settings, (d) deep, (g) problem, (f) fresh, (r) reset, (q) back [a/c/b/x/s/d/g/f/r/q]: ", + invalidModePrompt: "Use one of: a, c, b, x, s, d, g, f, r, q.", }, } as const; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 27261cd2..334a2bff 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -18,6 +18,9 @@ const saveQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); const selectMock = vi.fn(); +const deleteSavedAccountsMock = vi.fn(); +const resetLocalStateMock = vi.fn(); +const deleteAccountAtIndexMock = vi.fn(); vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -61,11 +64,16 @@ vi.mock("../lib/accounts.js", () => ({ account.email ? `${index + 1}. ${account.email}` : `Account ${index + 1}`, ), formatCooldown: vi.fn(() => null), - formatWaitTime: vi.fn((ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`), + formatWaitTime: vi.fn( + (ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`, + ), getAccountIdCandidates: vi.fn(() => []), resolveRequestAccountId: vi.fn( - (_override: string | undefined, _source: string | undefined, tokenId: string | undefined) => - tokenId, + ( + _override: string | undefined, + _source: string | undefined, + tokenId: string | undefined, + ) => tokenId, ), sanitizeEmail: vi.fn((email: string | undefined) => typeof email === "string" ? email.toLowerCase() : undefined, @@ -127,12 +135,39 @@ vi.mock("../lib/quota-cache.js", () => ({ saveQuotaCache: saveQuotaCacheMock, })); +vi.mock("../lib/destructive-actions.js", () => ({ + DESTRUCTIVE_ACTION_COPY: { + deleteSavedAccounts: { + label: "Delete Saved Accounts", + stage: "Deleting saved accounts only", + completed: + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + }, + resetLocalState: { + label: "Reset Local State", + stage: + "Clearing saved accounts, flagged/problem accounts, and quota cache", + completed: + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + }, + }, + deleteSavedAccounts: deleteSavedAccountsMock, + resetLocalState: resetLocalStateMock, + deleteAccountAtIndex: deleteAccountAtIndexMock, +})); + vi.mock("../lib/ui/select.js", () => ({ select: selectMock, })); -const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); -const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); +const stdinIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY", +); +const stdoutIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdout, + "isTTY", +); function setInteractiveTTY(enabled: boolean): void { Object.defineProperty(process.stdin, "isTTY", { @@ -198,6 +233,8 @@ describe("codex manager cli commands", () => { loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); selectMock.mockReset(); + deleteAccountAtIndexMock.mockReset(); + deleteAccountAtIndexMock.mockResolvedValue(null); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, model: "gpt-5-codex", @@ -290,7 +327,9 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls[0]?.[0]).toBe("Implemented features (40)"); expect( logSpy.mock.calls.some((call) => - String(call[0]).includes("40. OAuth browser-first flow with manual callback fallback"), + String(call[0]).includes( + "40. OAuth browser-first flow with manual callback fallback", + ), ), ).toBe(true); }); @@ -330,7 +369,11 @@ describe("codex manager cli commands", () => { }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "verify-flagged", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "verify-flagged", + "--json", + ]); expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); expect(saveFlaggedAccountsMock).toHaveBeenCalledTimes(1); @@ -369,7 +412,11 @@ describe("codex manager cli commands", () => { }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "verify-flagged", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "verify-flagged", + "--json", + ]); expect(exitCode).toBe(0); expect(saveAccountsMock).not.toHaveBeenCalled(); expect(saveFlaggedAccountsMock).toHaveBeenCalledTimes(1); @@ -409,7 +456,12 @@ describe("codex manager cli commands", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "fix", "--dry-run", "--json"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--dry-run", + "--json", + ]); expect(exitCode).toBe(0); expect(saveAccountsMock).not.toHaveBeenCalled(); @@ -496,7 +548,11 @@ describe("codex manager cli commands", () => { 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); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("live session OK"), + ), + ).toBe(true); }); it("runs fix apply mode and returns a switch recommendation", async () => { @@ -688,7 +744,9 @@ describe("codex manager cli commands", () => { }); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); - const { autoSyncActiveAccountToCodex } = await import("../lib/codex-manager.js"); + const { autoSyncActiveAccountToCodex } = await import( + "../lib/codex-manager.js" + ); const synced = await autoSyncActiveAccountToCodex(); expect(synced).toBe(true); @@ -730,7 +788,9 @@ describe("codex manager cli commands", () => { }); setCodexCliActiveSelectionMock.mockResolvedValueOnce(true); - const { autoSyncActiveAccountToCodex } = await import("../lib/codex-manager.js"); + const { autoSyncActiveAccountToCodex } = await import( + "../lib/codex-manager.js" + ); const synced = await autoSyncActiveAccountToCodex(); expect(synced).toBe(true); @@ -826,7 +886,9 @@ describe("codex manager cli commands", () => { }, ], }; - loadAccountsMock.mockImplementation(async () => structuredClone(storageState)); + loadAccountsMock.mockImplementation(async () => + structuredClone(storageState), + ); saveAccountsMock.mockImplementation(async (nextStorage) => { storageState = structuredClone(nextStorage); }); @@ -836,20 +898,29 @@ describe("codex manager cli commands", () => { promptAddAnotherAccountMock.mockResolvedValue(false); const authModule = await import("../lib/auth/auth.js"); - const createAuthorizationFlowMock = vi.mocked(authModule.createAuthorizationFlow); - const exchangeAuthorizationCodeMock = vi.mocked(authModule.exchangeAuthorizationCode); + const createAuthorizationFlowMock = vi.mocked( + authModule.createAuthorizationFlow, + ); + const exchangeAuthorizationCodeMock = vi.mocked( + authModule.exchangeAuthorizationCode, + ); const browserModule = await import("../lib/auth/browser.js"); const openBrowserUrlMock = vi.mocked(browserModule.openBrowserUrl); const serverModule = await import("../lib/auth/server.js"); - const startLocalOAuthServerMock = vi.mocked(serverModule.startLocalOAuthServer); + const startLocalOAuthServerMock = vi.mocked( + serverModule.startLocalOAuthServer, + ); - const flow: Awaited> = { - pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, - state: "oauth-state", - url: "https://auth.openai.com/mock", - }; + const flow: Awaited> = + { + pkce: { challenge: "pkce-challenge", verifier: "pkce-verifier" }, + state: "oauth-state", + url: "https://auth.openai.com/mock", + }; createAuthorizationFlowMock.mockResolvedValue(flow); - const oauthResult: Awaited> = { + const oauthResult: Awaited< + ReturnType + > = { type: "success", access: "access-new", refresh: "refresh-new", @@ -859,7 +930,9 @@ describe("codex manager cli commands", () => { }; exchangeAuthorizationCodeMock.mockResolvedValue(oauthResult); openBrowserUrlMock.mockReturnValue(true); - const oauthServer: Awaited> = { + const oauthServer: Awaited< + ReturnType + > = { ready: true, waitForCode: vi.fn(async () => ({ code: "oauth-code" })), close: vi.fn(), @@ -912,7 +985,11 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(queuedRefreshMock).toHaveBeenCalledTimes(1); expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); - expect(logSpy.mock.calls.some((call) => String(call[0]).includes("full refresh test"))).toBe(true); + expect( + logSpy.mock.calls.some((call) => + String(call[0]).includes("full refresh test"), + ), + ).toBe(true); }); it("runs quick check from login menu with live probe", async () => { @@ -1076,22 +1153,46 @@ describe("codex manager cli commands", () => { 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 }, + 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 }, + primary: { + usedPercent: 0, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 0, + windowMinutes: 10080, + 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 }, + primary: { + usedPercent: 60, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 60, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, }, }, }); @@ -1113,9 +1214,15 @@ describe("codex manager cli commands", () => { "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.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); }); @@ -1168,15 +1275,31 @@ describe("codex manager cli commands", () => { 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 }, + 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 }, + primary: { + usedPercent: 0, + windowMinutes: 300, + resetAtMs: now + 1_000, + }, + secondary: { + usedPercent: 0, + windowMinutes: 10080, + resetAtMs: now + 2_000, + }, }, }, }); @@ -1194,7 +1317,9 @@ describe("codex manager cli commands", () => { "b@example.com", "a@example.com", ]); - expect(firstCallAccounts.map((account) => account.quickSwitchNumber)).toEqual([2, 1]); + expect( + firstCallAccounts.map((account) => account.quickSwitchNumber), + ).toEqual([2, 1]); }); it("runs doctor command in json mode", async () => { @@ -1226,7 +1351,9 @@ describe("codex manager cli commands", () => { }; expect(payload.command).toBe("doctor"); expect(payload.summary.error).toBe(0); - expect(payload.checks.some((check) => check.key === "active-index")).toBe(true); + expect(payload.checks.some((check) => check.key === "active-index")).toBe( + true, + ); }); it("runs doctor --fix in dry-run mode", async () => { @@ -1257,12 +1384,23 @@ describe("codex manager cli commands", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "doctor", "--fix", "--dry-run", "--json"]); + 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 }> }; + fix: { + enabled: boolean; + dryRun: boolean; + changed: boolean; + actions: Array<{ key: string }>; + }; }; expect(payload.fix.enabled).toBe(true); expect(payload.fix.dryRun).toBe(true); @@ -1362,7 +1500,9 @@ describe("codex manager cli commands", () => { { type: "save" }, { type: "back" }, ]; - selectMock.mockImplementation(async () => selectResults.shift() ?? { type: "back" }); + selectMock.mockImplementation( + async () => selectResults.shift() ?? { type: "back" }, + ); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); @@ -1394,139 +1534,146 @@ describe("codex manager cli commands", () => { { panel: "behavior", mode: "token-refresh-race" }, { panel: "theme", mode: "token-refresh-race" }, { panel: "backend", mode: "token-refresh-race" }, - ] as const)( - "keeps no-save-on-cancel contract for panel=$panel mode=$mode", - async ({ panel, mode }) => { - setInteractiveTTY(true); - const now = Date.now(); - let originalRuntimeTheme: - | { - v2Enabled: boolean; - colorProfile: string; - glyphMode: string; - palette: string; - accent: string; - } - | null = null; - if (panel === "theme") { - const runtime = await import("../lib/ui/runtime.js"); - runtime.resetUiRuntimeOptions(); - const snapshot = runtime.getUiRuntimeOptions(); - originalRuntimeTheme = { - v2Enabled: snapshot.v2Enabled, - colorProfile: snapshot.colorProfile, - glyphMode: snapshot.glyphMode, - palette: snapshot.palette, - accent: snapshot.accent, - }; - } - loadAccountsMock.mockResolvedValue({ - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 0 }, - accounts: [ - { - email: "cancel-settings@example.com", - accountId: "acc_cancel_settings", - refreshToken: "refresh-cancel-settings", - accessToken: "access-cancel-settings", - expiresAt: now + 3_600_000, - addedAt: now - 1_000, - lastUsed: now - 1_000, - enabled: true, - }, - ], + ] as const)("keeps no-save-on-cancel contract for panel=$panel mode=$mode", async ({ + panel, + mode, + }) => { + setInteractiveTTY(true); + const now = Date.now(); + let originalRuntimeTheme: { + v2Enabled: boolean; + colorProfile: string; + glyphMode: string; + palette: string; + accent: string; + } | null = null; + if (panel === "theme") { + const runtime = await import("../lib/ui/runtime.js"); + runtime.resetUiRuntimeOptions(); + const snapshot = runtime.getUiRuntimeOptions(); + originalRuntimeTheme = { + v2Enabled: snapshot.v2Enabled, + colorProfile: snapshot.colorProfile, + glyphMode: snapshot.glyphMode, + palette: snapshot.palette, + accent: snapshot.accent, + }; + } + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "cancel-settings@example.com", + accountId: "acc_cancel_settings", + refreshToken: "refresh-cancel-settings", + accessToken: "access-cancel-settings", + expiresAt: now + 3_600_000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "settings" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + if (mode === "windows-ebusy") { + const busy = makeErrnoError("busy", "EBUSY"); + saveDashboardDisplaySettingsMock.mockRejectedValue(busy); + savePluginConfigMock.mockRejectedValue(busy); + } + if (mode === "concurrent-save-ordering") { + const dashboardDeferred = createDeferred(); + const pluginDeferred = createDeferred(); + saveDashboardDisplaySettingsMock.mockImplementation( + async () => dashboardDeferred.promise, + ); + savePluginConfigMock.mockImplementation( + async () => pluginDeferred.promise, + ); + queueMicrotask(() => { + dashboardDeferred.resolve(undefined); + pluginDeferred.resolve(undefined); }); - promptLoginModeMock - .mockResolvedValueOnce({ mode: "settings" }) - .mockResolvedValueOnce({ mode: "cancel" }); - - if (mode === "windows-ebusy") { - const busy = makeErrnoError("busy", "EBUSY"); - saveDashboardDisplaySettingsMock.mockRejectedValue(busy); - savePluginConfigMock.mockRejectedValue(busy); - } - if (mode === "concurrent-save-ordering") { - const dashboardDeferred = createDeferred(); - const pluginDeferred = createDeferred(); - saveDashboardDisplaySettingsMock.mockImplementation(async () => dashboardDeferred.promise); - savePluginConfigMock.mockImplementation(async () => pluginDeferred.promise); - queueMicrotask(() => { - dashboardDeferred.resolve(undefined); - pluginDeferred.resolve(undefined); + } + if (mode === "token-refresh-race") { + const refreshDeferred = createDeferred<{ + type: "success"; + access: string; + refresh: string; + expires: number; + }>(); + queuedRefreshMock.mockImplementation(async () => refreshDeferred.promise); + queueMicrotask(() => { + refreshDeferred.resolve({ + type: "success", + access: "race-access", + refresh: "race-refresh", + expires: now + 3_600_000, }); + }); + } + + let selectCall = 0; + selectMock.mockImplementation(async (_items, options) => { + selectCall += 1; + const onInput = ( + options as { onInput?: (raw: string) => unknown } | undefined + )?.onInput; + if (selectCall === 1) return { type: panel }; + + if (panel === "account-list") { + if (selectCall === 2) + return { type: "toggle", key: "menuShowStatusBadge" }; + if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; + return { type: "back" }; } - if (mode === "token-refresh-race") { - const refreshDeferred = createDeferred<{ - type: "success"; - access: string; - refresh: string; - expires: number; - }>(); - queuedRefreshMock.mockImplementation(async () => refreshDeferred.promise); - queueMicrotask(() => { - refreshDeferred.resolve({ - type: "success", - access: "race-access", - refresh: "race-refresh", - expires: now + 3_600_000, - }); - }); + if (panel === "summary-fields") { + if (selectCall === 2) return { type: "toggle", key: "status" }; + if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; + return { type: "back" }; } - - let selectCall = 0; - selectMock.mockImplementation(async (_items, options) => { - selectCall += 1; - const onInput = (options as { onInput?: (raw: string) => unknown } | undefined)?.onInput; - if (selectCall === 1) return { type: panel }; - - if (panel === "account-list") { - if (selectCall === 2) return { type: "toggle", key: "menuShowStatusBadge" }; - if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; - return { type: "back" }; - } - if (panel === "summary-fields") { - if (selectCall === 2) return { type: "toggle", key: "status" }; - if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; - return { type: "back" }; - } - if (panel === "behavior") { - if (selectCall === 2) return { type: "toggle-pause" }; - if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; - return { type: "back" }; - } - if (panel === "theme") { - if (selectCall === 2) return { type: "set-palette", palette: "blue" }; - if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; - return { type: "back" }; - } - - if (selectCall === 2) return { type: "open-category", key: "rotation-quota" }; - if (selectCall === 3) return { type: "toggle", key: "preemptiveQuotaEnabled" }; - if (selectCall === 4) return { type: "back" }; - if (selectCall === 5) return onInput?.("q") ?? { type: "cancel" }; + if (panel === "behavior") { + if (selectCall === 2) return { type: "toggle-pause" }; + if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; return { type: "back" }; - }); - - const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); - const exitCode = await runCodexMultiAuthCli(["auth", "login"]); - - expect(exitCode).toBe(0); - expect(saveDashboardDisplaySettingsMock).not.toHaveBeenCalled(); - expect(savePluginConfigMock).not.toHaveBeenCalled(); + } if (panel === "theme") { - const runtime = await import("../lib/ui/runtime.js"); - const restored = runtime.getUiRuntimeOptions(); - expect({ - v2Enabled: restored.v2Enabled, - colorProfile: restored.colorProfile, - glyphMode: restored.glyphMode, - palette: restored.palette, - accent: restored.accent, - }).toEqual(originalRuntimeTheme); + if (selectCall === 2) return { type: "set-palette", palette: "blue" }; + if (selectCall === 3) return onInput?.("q") ?? { type: "cancel" }; + return { type: "back" }; } - }, - ); + + if (selectCall === 2) + return { type: "open-category", key: "rotation-quota" }; + if (selectCall === 3) + return { type: "toggle", key: "preemptiveQuotaEnabled" }; + if (selectCall === 4) return { type: "back" }; + if (selectCall === 5) return onInput?.("q") ?? { type: "cancel" }; + return { type: "back" }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(saveDashboardDisplaySettingsMock).not.toHaveBeenCalled(); + expect(savePluginConfigMock).not.toHaveBeenCalled(); + if (panel === "theme") { + const runtime = await import("../lib/ui/runtime.js"); + const restored = runtime.getUiRuntimeOptions(); + expect({ + v2Enabled: restored.v2Enabled, + colorProfile: restored.colorProfile, + glyphMode: restored.glyphMode, + palette: restored.palette, + accent: restored.accent, + }).toEqual(originalRuntimeTheme); + } + }); it("retries transient EBUSY dashboard save and keeps settings flow alive", async () => { setInteractiveTTY(true); @@ -1825,7 +1972,9 @@ describe("codex manager cli commands", () => { 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); + 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 }>; @@ -1873,14 +2022,27 @@ describe("codex manager cli commands", () => { .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 }, + 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"]); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "fix", + "--live", + "--json", + ]); expect(exitCode).toBe(0); expect(fetchCodexQuotaSnapshotMock).toHaveBeenCalledTimes(2); @@ -1932,14 +2094,38 @@ describe("codex manager cli commands", () => { promptLoginModeMock .mockResolvedValueOnce({ mode: "manage", deleteAccountIndex: 1 }) .mockResolvedValueOnce({ mode: "cancel" }); + deleteAccountAtIndexMock.mockResolvedValueOnce({ + storage: { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now - 2_000, + lastUsed: now - 2_000, + enabled: true, + }, + ], + }, + flagged: { version: 1, accounts: [] }, + removedAccount: { + refreshToken: "refresh-second", + addedAt: now - 1_000, + lastUsed: now - 1_000, + accountIdSource: undefined, + enabled: true, + }, + removedFlaggedCount: 0, + }); const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); const exitCode = await runCodexMultiAuthCli(["auth", "login"]); expect(exitCode).toBe(0); - expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts).toHaveLength(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.email).toBe("first@example.com"); + expect(deleteAccountAtIndexMock).toHaveBeenCalledTimes(1); + expect(deleteAccountAtIndexMock.mock.calls[0]?.[0]?.index).toBe(1); }); it("toggles account enabled state from manage mode", async () => { @@ -1967,7 +2153,98 @@ describe("codex manager cli commands", () => { expect(exitCode).toBe(0); expect(saveAccountsMock).toHaveBeenCalledTimes(1); - expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe(false); + expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe( + false, + ); + }); + + it("skips destructive work when user cancels from menu", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "keep@example.com", + refreshToken: "keep-refresh", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + promptLoginModeMock.mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(saveAccountsMock).not.toHaveBeenCalled(); + }); + + it("deletes saved accounts only when requested", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "fresh", deleteAll: true }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(deleteSavedAccountsMock).toHaveBeenCalledTimes(1); + expect(resetLocalStateMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + + it("resets local state when reset mode is chosen", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "login"]); + + expect(exitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(deleteSavedAccountsMock).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); }); it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { diff --git a/test/storage.test.ts b/test/storage.test.ts index 3c3157e8..18e3e005 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1,1854 +1,2234 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { promises as fs, existsSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { existsSync, promises as fs } from "node:fs"; import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; -import { - deduplicateAccounts, - deduplicateAccountsByEmail, - normalizeAccountStorage, - loadAccounts, - saveAccounts, - clearAccounts, - getStoragePath, - setStoragePath, - setStoragePathDirect, - StorageError, - formatStorageErrorHint, - exportAccounts, - importAccounts, - withAccountStorageTransaction, +import { + clearAccounts, + deduplicateAccounts, + deduplicateAccountsByEmail, + exportAccounts, + formatStorageErrorHint, + getStoragePath, + importAccounts, + loadAccounts, + normalizeAccountStorage, + StorageError, + saveAccounts, + setStoragePath, + setStoragePathDirect, + withAccountStorageTransaction, } from "../lib/storage.js"; // Mocking the behavior we're about to implement for TDD -// Since the functions aren't in lib/storage.ts yet, we'll need to mock them or +// Since the functions aren't in lib/storage.ts yet, we'll need to mock them or // accept that this test won't even compile/run until we add them. // But Task 0 says: "Tests should fail initially (RED phase)" describe("storage", () => { - const _origCODEX_HOME = process.env.CODEX_HOME; - const _origCODEX_MULTI_AUTH_DIR = process.env.CODEX_MULTI_AUTH_DIR; - - beforeEach(() => { - delete process.env.CODEX_HOME; - delete process.env.CODEX_MULTI_AUTH_DIR; - }); - - afterEach(() => { - if (_origCODEX_HOME !== undefined) process.env.CODEX_HOME = _origCODEX_HOME; else delete process.env.CODEX_HOME; - if (_origCODEX_MULTI_AUTH_DIR !== undefined) process.env.CODEX_MULTI_AUTH_DIR = _origCODEX_MULTI_AUTH_DIR; else delete process.env.CODEX_MULTI_AUTH_DIR; - }); - describe("deduplication", () => { - it("remaps activeIndex after deduplication using active account key", () => { - const now = Date.now(); - - const raw = { - version: 1, - activeIndex: 1, - accounts: [ - { - accountId: "acctA", - refreshToken: "tokenA", - addedAt: now - 2000, - lastUsed: now - 2000, - }, - { - accountId: "acctA", - refreshToken: "tokenA", - addedAt: now - 1000, - lastUsed: now - 1000, - }, - { - accountId: "acctB", - refreshToken: "tokenB", - addedAt: now, - lastUsed: now, - }, - ], - }; - - const normalized = normalizeAccountStorage(raw); - expect(normalized).not.toBeNull(); - expect(normalized?.accounts).toHaveLength(2); - expect(normalized?.accounts[0]?.accountId).toBe("acctA"); - expect(normalized?.accounts[1]?.accountId).toBe("acctB"); - expect(normalized?.activeIndex).toBe(0); - }); - - it("deduplicates accounts by keeping the most recently used record", () => { - const now = Date.now(); - - const accounts = [ - { - accountId: "acctA", - refreshToken: "tokenA", - addedAt: now - 2000, - lastUsed: now - 1000, - }, - { - accountId: "acctA", - refreshToken: "tokenA", - addedAt: now - 1500, - lastUsed: now, - }, - ]; - - const deduped = deduplicateAccounts(accounts); - expect(deduped).toHaveLength(1); - expect(deduped[0]?.addedAt).toBe(now - 1500); - expect(deduped[0]?.lastUsed).toBe(now); - }); - }); - - describe("import/export (TDD)", () => { - const testWorkDir = join(tmpdir(), "codex-test-" + Math.random().toString(36).slice(2)); - const exportPath = join(testWorkDir, "export.json"); - let testStoragePath: string; - - beforeEach(async () => { - await fs.mkdir(testWorkDir, { recursive: true }); - testStoragePath = join(testWorkDir, "accounts-" + Math.random().toString(36).slice(2) + ".json"); - setStoragePathDirect(testStoragePath); - }); - - afterEach(async () => { - setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("should export accounts to a file", async () => { - // @ts-ignore - exportAccounts doesn't exist yet - const { exportAccounts } = await import("../lib/storage.js"); - - const storage = { - version: 3, - activeIndex: 0, - accounts: [{ accountId: "test", refreshToken: "ref", addedAt: 1, lastUsed: 2 }] - }; - // @ts-ignore - await saveAccounts(storage); - - // @ts-ignore - await exportAccounts(exportPath); - - expect(existsSync(exportPath)).toBe(true); - const exported = JSON.parse(await fs.readFile(exportPath, "utf-8")); - expect(exported.accounts[0].accountId).toBe("test"); - }); - - it("should fail export if file exists and force is false", async () => { - // @ts-ignore - const { exportAccounts } = await import("../lib/storage.js"); - await fs.writeFile(exportPath, "exists"); - - // @ts-ignore - await expect(exportAccounts(exportPath, false)).rejects.toThrow(/already exists/); - }); - - it("should import accounts from a file and merge", async () => { - // @ts-ignore - const { importAccounts } = await import("../lib/storage.js"); - - const existing = { - version: 3, - activeIndex: 0, - accounts: [{ accountId: "existing", refreshToken: "ref1", addedAt: 1, lastUsed: 2 }] - }; - // @ts-ignore - await saveAccounts(existing); - - const toImport = { - version: 3, - activeIndex: 0, - accounts: [{ accountId: "new", refreshToken: "ref2", addedAt: 3, lastUsed: 4 }] - }; - await fs.writeFile(exportPath, JSON.stringify(toImport)); - - // @ts-ignore - await importAccounts(exportPath); - - const loaded = await loadAccounts(); - expect(loaded?.accounts).toHaveLength(2); - expect(loaded?.accounts.map(a => a.accountId)).toContain("new"); - }); - - it("should serialize concurrent transactional updates without losing accounts", async () => { - await saveAccounts({ - version: 3, - activeIndex: 0, - accounts: [], - }); - - const addAccount = async (accountId: string, delayMs: number): Promise => { - await withAccountStorageTransaction(async (current, persist) => { - const snapshot = current ?? { - version: 3 as const, - activeIndex: 0, - accounts: [], - }; - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - await persist({ - ...snapshot, - accounts: [ - ...snapshot.accounts, - { accountId, refreshToken: `ref-${accountId}`, addedAt: Date.now(), lastUsed: Date.now() }, - ], - }); - }); - }; - - await Promise.all([ - addAccount("acct-a", 20), - addAccount("acct-b", 0), - ]); - - const loaded = await loadAccounts(); - expect(loaded?.accounts).toHaveLength(2); - expect(new Set(loaded?.accounts.map((account) => account.accountId))).toEqual( - new Set(["acct-a", "acct-b"]), - ); - }); - - it("should enforce MAX_ACCOUNTS during import", async () => { - // @ts-ignore - const { importAccounts } = await import("../lib/storage.js"); - - const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ - accountId: `acct${i}`, - refreshToken: `ref${i}`, - addedAt: Date.now(), - lastUsed: Date.now() - })); - - const toImport = { - version: 3, - activeIndex: 0, - accounts: manyAccounts - }; - await fs.writeFile(exportPath, JSON.stringify(toImport)); - - // @ts-ignore - await expect(importAccounts(exportPath)).rejects.toThrow(/exceed maximum/); - }); - - it("should fail export when no accounts exist", async () => { - const { exportAccounts } = await import("../lib/storage.js"); - setStoragePathDirect(testStoragePath); - await expect(exportAccounts(exportPath)).rejects.toThrow(/No accounts to export/); - }); - - it("should fail import when file does not exist", async () => { - const { importAccounts } = await import("../lib/storage.js"); - const nonexistentPath = join(testWorkDir, "nonexistent-file.json"); - await expect(importAccounts(nonexistentPath)).rejects.toThrow(/Import file not found/); - }); - - it("should fail import when file contains invalid JSON", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await fs.writeFile(exportPath, "not valid json {["); - await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); - }); - - it("should fail import when file contains invalid format", async () => { - const { importAccounts } = await import("../lib/storage.js"); - await fs.writeFile(exportPath, JSON.stringify({ invalid: "format" })); - await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid account storage format/); - }); - }); - - describe("filename migration (TDD)", () => { - it("should migrate from old filename to new filename", async () => { - // This test is tricky because it depends on the internal state of getStoragePath() - // which we are about to change. - - const oldName = "openai-codex-accounts.json"; - const newName = "codex-accounts.json"; - - // We'll need to mock/verify that loadAccounts checks for oldName if newName is missing - // Since we haven't implemented it yet, this is just a placeholder for the logic - expect(true).toBe(true); - }); - }); - - describe("StorageError and formatStorageErrorHint", () => { - describe("StorageError class", () => { - it("should store code, path, and hint properties", () => { - const err = new StorageError( - "Failed to write file", - "EACCES", - "/path/to/file.json", - "Permission denied. Check folder permissions." - ); - - expect(err.name).toBe("StorageError"); - expect(err.message).toBe("Failed to write file"); - expect(err.code).toBe("EACCES"); - expect(err.path).toBe("/path/to/file.json"); - expect(err.hint).toBe("Permission denied. Check folder permissions."); - }); - - it("should be instanceof Error", () => { - const err = new StorageError("test", "CODE", "/path", "hint"); - expect(err instanceof Error).toBe(true); - expect(err instanceof StorageError).toBe(true); - }); - }); - - describe("formatStorageErrorHint", () => { - const testPath = "/home/user/.codex/accounts.json"; - - it("should return permission hint for EACCES on Windows", () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32" }); - - const err = { code: "EACCES" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("antivirus"); - expect(hint).toContain(testPath); - - Object.defineProperty(process, "platform", { value: originalPlatform }); - }); - - it("should return chmod hint for EACCES on Unix", () => { - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "darwin" }); - - const err = { code: "EACCES" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("chmod"); - expect(hint).toContain(testPath); - - Object.defineProperty(process, "platform", { value: originalPlatform }); - }); - - it("should return permission hint for EPERM", () => { - const err = { code: "EPERM" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("Permission denied"); - expect(hint).toContain(testPath); - }); - - it("should return file locked hint for EBUSY", () => { - const err = { code: "EBUSY" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("locked"); - expect(hint).toContain("another program"); - }); - - it("should return disk full hint for ENOSPC", () => { - const err = { code: "ENOSPC" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("Disk is full"); - }); - - it("should return empty file hint for EEMPTY", () => { - const err = { code: "EEMPTY" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("empty"); - }); - - it("should return generic hint for unknown error codes", () => { - const err = { code: "UNKNOWN_CODE" } as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("Failed to write"); - expect(hint).toContain(testPath); - }); - - it("should handle errors without code property", () => { - const err = new Error("Some error") as NodeJS.ErrnoException; - const hint = formatStorageErrorHint(err, testPath); - - expect(hint).toContain("Failed to write"); - expect(hint).toContain(testPath); - }); - }); - }); - - describe("selectNewestAccount logic", () => { - it("when lastUsed are equal, prefers newer addedAt", () => { - const now = Date.now(); - const accounts = [ - { accountId: "A", refreshToken: "t1", addedAt: now - 1000, lastUsed: now }, - { accountId: "A", refreshToken: "t1", addedAt: now - 500, lastUsed: now }, - ]; - const deduped = deduplicateAccounts(accounts); - expect(deduped).toHaveLength(1); - expect(deduped[0]?.addedAt).toBe(now - 500); - }); - - it("when candidate lastUsed is less than current, keeps current", () => { - const now = Date.now(); - const accounts = [ - { accountId: "A", refreshToken: "t1", addedAt: now, lastUsed: now }, - { accountId: "A", refreshToken: "t1", addedAt: now - 500, lastUsed: now - 1000 }, - ]; - const deduped = deduplicateAccounts(accounts); - expect(deduped).toHaveLength(1); - expect(deduped[0]?.lastUsed).toBe(now); - }); - - it("handles accounts without lastUsed or addedAt", () => { - const accounts = [ - { accountId: "A", refreshToken: "t1" }, - { accountId: "A", refreshToken: "t1", lastUsed: 100 }, - ]; - const deduped = deduplicateAccounts(accounts); - expect(deduped).toHaveLength(1); - expect(deduped[0]?.lastUsed).toBe(100); - }); - }); - - describe("deduplicateAccountsByKey edge cases", () => { - it("uses refreshToken as key when accountId is empty", () => { - const accounts = [ - { accountId: "A", refreshToken: "t1", lastUsed: 100 }, - { accountId: "", refreshToken: "t2", lastUsed: 200 }, - { accountId: "C", refreshToken: "t3", lastUsed: 300 }, - ]; - const deduped = deduplicateAccounts(accounts); - expect(deduped).toHaveLength(3); - }); - - it("handles empty array", () => { - const deduped = deduplicateAccounts([]); - expect(deduped).toHaveLength(0); - }); - - it("handles null/undefined in array", () => { - const accounts = [ - { accountId: "A", refreshToken: "t1" }, - null as never, - { accountId: "B", refreshToken: "t2" }, - ]; - const deduped = deduplicateAccounts(accounts); - expect(deduped).toHaveLength(2); - }); - }); - - describe("deduplicateAccountsByEmail edge cases", () => { - it("preserves accounts without email", () => { - const accounts = [ - { email: "test@example.com", lastUsed: 100, addedAt: 50 }, - { lastUsed: 200, addedAt: 100 }, - { email: "", lastUsed: 300, addedAt: 150 }, - ]; - const deduped = deduplicateAccountsByEmail(accounts); - expect(deduped).toHaveLength(3); - }); - - it("handles email with whitespace", () => { - const accounts = [ - { email: " test@example.com ", lastUsed: 100, addedAt: 50 }, - { email: "test@example.com", lastUsed: 200, addedAt: 100 }, - ]; - const deduped = deduplicateAccountsByEmail(accounts); - expect(deduped).toHaveLength(1); - }); - - it("treats email casing as the same logical account", () => { - const accounts = [ - { email: "Test@Example.com", refreshToken: "old", lastUsed: 100, addedAt: 10 }, - { email: "test@example.com", refreshToken: "new", lastUsed: 200, addedAt: 20 }, - ]; - const deduped = deduplicateAccountsByEmail(accounts); - expect(deduped).toHaveLength(1); - expect(deduped[0]?.refreshToken).toBe("new"); - expect(deduped[0]?.email).toBe("test@example.com"); - }); - - it("handles null existing account edge case", () => { - const accounts = [ - { email: "test@example.com", lastUsed: 100 }, - { email: "test@example.com", lastUsed: 200 }, - ]; - const deduped = deduplicateAccountsByEmail(accounts); - expect(deduped.length).toBeGreaterThanOrEqual(1); - }); - - it("when addedAt differs but lastUsed is same, uses addedAt to decide", () => { - const now = Date.now(); - const accounts = [ - { email: "test@example.com", lastUsed: now, addedAt: now - 1000 }, - { email: "test@example.com", lastUsed: now, addedAt: now - 500 }, - ]; - const deduped = deduplicateAccountsByEmail(accounts); - expect(deduped).toHaveLength(1); - expect(deduped[0]?.addedAt).toBe(now - 500); - }); - }); - - describe("normalizeAccountStorage edge cases", () => { - it("returns null for non-object data", () => { - expect(normalizeAccountStorage(null)).toBeNull(); - expect(normalizeAccountStorage("string")).toBeNull(); - expect(normalizeAccountStorage(123)).toBeNull(); - expect(normalizeAccountStorage([])).toBeNull(); - }); - - it("returns null for invalid version", () => { - const result = normalizeAccountStorage({ version: 2, accounts: [] }); - expect(result).toBeNull(); - }); - - it("returns null for non-array accounts", () => { - expect(normalizeAccountStorage({ version: 3, accounts: "not-array" })).toBeNull(); - expect(normalizeAccountStorage({ version: 3, accounts: {} })).toBeNull(); - }); - - it("handles missing activeIndex", () => { - const data = { - version: 3, - accounts: [{ refreshToken: "t1", accountId: "A" }], - }; - const result = normalizeAccountStorage(data); - expect(result?.activeIndex).toBe(0); - }); - - it("handles non-finite activeIndex", () => { - const data = { - version: 3, - activeIndex: NaN, - accounts: [{ refreshToken: "t1", accountId: "A" }], - }; - const result = normalizeAccountStorage(data); - expect(result?.activeIndex).toBe(0); - }); - - it("handles Infinity activeIndex", () => { - const data = { - version: 3, - activeIndex: Infinity, - accounts: [{ refreshToken: "t1", accountId: "A" }], - }; - const result = normalizeAccountStorage(data); - expect(result?.activeIndex).toBe(0); - }); - - it("clamps out-of-bounds activeIndex", () => { - const data = { - version: 3, - activeIndex: 100, - accounts: [{ refreshToken: "t1", accountId: "A" }, { refreshToken: "t2", accountId: "B" }], - }; - const result = normalizeAccountStorage(data); - expect(result?.activeIndex).toBe(1); - }); - - it("filters out accounts with empty refreshToken", () => { - const data = { - version: 3, - accounts: [ - { refreshToken: "valid", accountId: "A" }, - { refreshToken: " ", accountId: "B" }, - { refreshToken: "", accountId: "C" }, - ], - }; - const result = normalizeAccountStorage(data); - expect(result?.accounts).toHaveLength(1); - }); - - it("remaps activeKey when deduplication changes indices", () => { - const now = Date.now(); - const data = { - version: 3, - activeIndex: 2, - accounts: [ - { refreshToken: "t1", accountId: "A", lastUsed: now - 100 }, - { refreshToken: "t1", accountId: "A", lastUsed: now }, - { refreshToken: "t2", accountId: "B", lastUsed: now - 50 }, - ], - }; - const result = normalizeAccountStorage(data); - expect(result?.accounts).toHaveLength(2); - expect(result?.activeIndex).toBe(1); - }); - - it("handles v1 to v3 migration", () => { - const data = { - version: 1, - activeIndex: 0, - accounts: [ - { refreshToken: "t1", accountId: "A", accessToken: "acc1", expiresAt: Date.now() + 3600000 }, - ], - }; - const result = normalizeAccountStorage(data); - expect(result?.version).toBe(3); - expect(result?.accounts).toHaveLength(1); - }); - - it("preserves activeIndexByFamily when valid", () => { - const data = { - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: 1, "gpt-5.x": 0 }, - accounts: [ - { refreshToken: "t1", accountId: "A" }, - { refreshToken: "t2", accountId: "B" }, - ], - }; - const result = normalizeAccountStorage(data); - expect(result?.activeIndexByFamily).toBeDefined(); - }); - - it("handles activeIndexByFamily with non-finite values", () => { - const data = { - version: 3, - activeIndex: 0, - activeIndexByFamily: { codex: NaN, "gpt-5.x": Infinity }, - accounts: [{ refreshToken: "t1", accountId: "A" }], - }; - const result = normalizeAccountStorage(data); - expect(result?.activeIndexByFamily).toBeDefined(); - }); - - it("handles account with only accountId, no refreshToken key match", () => { - const data = { - version: 3, - activeIndex: 0, - accounts: [ - { refreshToken: "t1", accountId: "" }, - ], - }; - const result = normalizeAccountStorage(data); - expect(result?.accounts).toHaveLength(1); - }); - }); - - describe("loadAccounts", () => { - const testWorkDir = join(tmpdir(), "codex-load-test-" + Math.random().toString(36).slice(2)); - let testStoragePath: string; - - beforeEach(async () => { - await fs.mkdir(testWorkDir, { recursive: true }); - testStoragePath = join(testWorkDir, "accounts.json"); - setStoragePathDirect(testStoragePath); - }); - - afterEach(async () => { - setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("returns null when file does not exist", async () => { - const result = await loadAccounts(); - expect(result).toBeNull(); - }); - - it("returns null on parse error", async () => { - await fs.writeFile(testStoragePath, "not valid json{{{", "utf-8"); - const result = await loadAccounts(); - expect(result).toBeNull(); - }); - - it("returns normalized data on valid file", async () => { - const storage = { version: 3, activeIndex: 0, accounts: [{ refreshToken: "t1", accountId: "A" }] }; - await fs.writeFile(testStoragePath, JSON.stringify(storage), "utf-8"); - const result = await loadAccounts(); - expect(result?.accounts).toHaveLength(1); - }); - - it("logs schema validation warnings but still returns data", async () => { - const storage = { version: 3, activeIndex: 0, accounts: [{ refreshToken: "t1", accountId: "A", extraField: "ignored" }] }; - await fs.writeFile(testStoragePath, JSON.stringify(storage), "utf-8"); - const result = await loadAccounts(); - expect(result).not.toBeNull(); - }); - - it("migrates v1 to v3 and attempts to save", async () => { - const v1Storage = { - version: 1, - activeIndex: 0, - accounts: [{ refreshToken: "t1", accountId: "A", accessToken: "acc", expiresAt: Date.now() + 3600000 }] - }; - await fs.writeFile(testStoragePath, JSON.stringify(v1Storage), "utf-8"); - const result = await loadAccounts(); - expect(result?.version).toBe(3); - const saved = JSON.parse(await fs.readFile(testStoragePath, "utf-8")); - expect(saved.version).toBe(3); - }); - - it("returns migrated data even when save fails (line 422-423 coverage)", async () => { - const v1Storage = { - version: 1, - activeIndex: 0, - accounts: [{ refreshToken: "t1", accountId: "A", accessToken: "acc", expiresAt: Date.now() + 3600000 }] - }; - await fs.writeFile(testStoragePath, JSON.stringify(v1Storage), "utf-8"); - - // Make the file read-only to cause save to fail - await fs.chmod(testStoragePath, 0o444); - - const result = await loadAccounts(); - - // Should still return migrated data even though save failed - expect(result?.version).toBe(3); - expect(result?.accounts).toHaveLength(1); - - // Restore permissions for cleanup - await fs.chmod(testStoragePath, 0o644); - }); - }); - - describe("saveAccounts", () => { - const testWorkDir = join(tmpdir(), "codex-save-test-" + Math.random().toString(36).slice(2)); - let testStoragePath: string; - - beforeEach(async () => { - await fs.mkdir(testWorkDir, { recursive: true }); - testStoragePath = join(testWorkDir, ".codex", "accounts.json"); - setStoragePathDirect(testStoragePath); - }); - - afterEach(async () => { - setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("creates directory and saves file", async () => { - const storage = { version: 3 as const, activeIndex: 0, accounts: [{ refreshToken: "t1", accountId: "A", addedAt: Date.now(), lastUsed: Date.now() }] }; - await saveAccounts(storage); - expect(existsSync(testStoragePath)).toBe(true); - }); - - it("writes valid JSON", async () => { - const storage = { version: 3 as const, activeIndex: 0, accounts: [{ refreshToken: "t1", accountId: "A", addedAt: 1, lastUsed: 2 }] }; - await saveAccounts(storage); - const content = await fs.readFile(testStoragePath, "utf-8"); - const parsed = JSON.parse(content); - expect(parsed.version).toBe(3); - }); - }); - - describe("clearAccounts", () => { - const testWorkDir = join(tmpdir(), "codex-clear-test-" + Math.random().toString(36).slice(2)); - let testStoragePath: string; - - beforeEach(async () => { - await fs.mkdir(testWorkDir, { recursive: true }); - testStoragePath = join(testWorkDir, "accounts.json"); - setStoragePathDirect(testStoragePath); - }); - - afterEach(async () => { - setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("deletes the file when it exists", async () => { - await fs.writeFile(testStoragePath, "{}"); - expect(existsSync(testStoragePath)).toBe(true); - await clearAccounts(); - expect(existsSync(testStoragePath)).toBe(false); - }); - - it("does not throw when file does not exist", async () => { - await expect(clearAccounts()).resolves.not.toThrow(); - }); - }); - - describe("setStoragePath", () => { - afterEach(() => { - setStoragePathDirect(null); - }); - - it("sets path to null when projectPath is null", () => { - setStoragePath(null); - const path = getStoragePath(); - expect(path).toContain(".codex"); - }); - - it("sets path to null when no project root found", () => { - setStoragePath("/nonexistent/path/that/does/not/exist"); - const path = getStoragePath(); - expect(path).toContain(".codex"); - }); - - it("sets project-scoped path under global .codex when project root found", () => { - setStoragePath(process.cwd()); - const path = getStoragePath(); - expect(path).toContain("openai-codex-accounts.json"); - expect(path).toContain(".codex"); - expect(path).toContain("projects"); - }); - - it("uses the same storage path for main repo and linked worktree", async () => { - const testWorkDir = join(tmpdir(), "codex-worktree-key-" + Math.random().toString(36).slice(2)); - const fakeHome = join(testWorkDir, "home"); - const mainRepo = join(testWorkDir, "repo-main"); - const mainGitDir = join(mainRepo, ".git"); - const worktreeRepo = join(testWorkDir, "repo-pr-8"); - const worktreeGitDir = join(mainGitDir, "worktrees", "repo-pr-8"); - const originalHome = process.env.HOME; - const originalUserProfile = process.env.USERPROFILE; - try { - process.env.HOME = fakeHome; - process.env.USERPROFILE = fakeHome; - await fs.mkdir(mainGitDir, { recursive: true }); - await fs.mkdir(worktreeGitDir, { recursive: true }); - await fs.mkdir(worktreeRepo, { recursive: true }); - await fs.writeFile(join(worktreeRepo, ".git"), `gitdir: ${worktreeGitDir}\n`, "utf-8"); - await fs.writeFile(join(worktreeGitDir, "commondir"), "../..\n", "utf-8"); - await fs.writeFile(join(worktreeGitDir, "gitdir"), `${join(worktreeRepo, ".git")}\n`, "utf-8"); - - setStoragePath(mainRepo); - const mainPath = getStoragePath(); - setStoragePath(worktreeRepo); - const worktreePath = getStoragePath(); - expect(worktreePath).toBe(mainPath); - } finally { - setStoragePathDirect(null); - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserProfile; - await fs.rm(testWorkDir, { recursive: true, force: true }); - } - }); - }); - - describe("getStoragePath", () => { - afterEach(() => { - setStoragePathDirect(null); - }); - - it("returns custom path when set directly", () => { - setStoragePathDirect("/custom/path/accounts.json"); - expect(getStoragePath()).toBe("/custom/path/accounts.json"); - }); - - it("returns global path when no custom path set", () => { - setStoragePathDirect(null); - const path = getStoragePath(); - expect(path).toContain("openai-codex-accounts.json"); - }); - }); - - describe("normalizeAccountStorage activeKey remapping", () => { - it("remaps activeIndex using activeKey when present", () => { - const now = Date.now(); - const data = { - version: 3, - activeIndex: 0, - accounts: [ - { refreshToken: "t1", accountId: "A", lastUsed: now }, - { refreshToken: "t2", accountId: "B", lastUsed: now - 100 }, - { refreshToken: "t3", accountId: "C", lastUsed: now - 200 }, - ], - }; - const result = normalizeAccountStorage(data); - expect(result).not.toBeNull(); - expect(result?.accounts).toHaveLength(3); - expect(result?.activeIndex).toBe(0); - }); - - it("remaps familyKey for activeIndexByFamily when indices change after dedup", () => { - const now = Date.now(); - const data = { - version: 3, - activeIndex: 0, - activeIndexByFamily: { - "codex": 2, - "gpt-5.x": 1, - }, - accounts: [ - { refreshToken: "t1", accountId: "A", lastUsed: now }, - { refreshToken: "t1", accountId: "A", lastUsed: now + 100 }, - { refreshToken: "t2", accountId: "B", lastUsed: now - 50 }, - ], - }; - const result = normalizeAccountStorage(data); - expect(result).not.toBeNull(); - expect(result?.accounts).toHaveLength(2); - expect(result?.activeIndexByFamily?.codex).toBeDefined(); - }); - }); - - describe("clearAccounts error handling", () => { - const testWorkDir = join(tmpdir(), "codex-clear-err-" + Math.random().toString(36).slice(2)); - let testStoragePath: string; - - beforeEach(async () => { - await fs.mkdir(testWorkDir, { recursive: true }); - testStoragePath = join(testWorkDir, "accounts.json"); - setStoragePathDirect(testStoragePath); - }); - - afterEach(async () => { - setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("logs but does not throw on non-ENOENT errors", async () => { - const readOnlyDir = join(testWorkDir, "readonly"); - await fs.mkdir(readOnlyDir, { recursive: true }); - const readOnlyFile = join(readOnlyDir, "accounts.json"); - await fs.writeFile(readOnlyFile, "{}"); - setStoragePathDirect(readOnlyFile); - - await expect(clearAccounts()).resolves.not.toThrow(); - }); - }); - - describe("StorageError with cause", () => { - it("preserves the original error as cause", () => { - const originalError = new Error("Original error"); - const storageErr = new StorageError( - "Wrapper message", - "EACCES", - "/path/to/file", - "Permission hint", - originalError - ); - expect((storageErr as unknown as { cause?: Error }).cause).toBe(originalError); - }); - - it("works without cause parameter", () => { - const storageErr = new StorageError( - "Wrapper message", - "EACCES", - "/path/to/file", - "Permission hint" - ); - expect((storageErr as unknown as { cause?: Error }).cause).toBeUndefined(); - }); - }); - - describe("ensureGitignore edge cases", () => { - const testWorkDir = join(tmpdir(), "codex-gitignore-" + Math.random().toString(36).slice(2)); - const originalHome = process.env.HOME; - const originalUserProfile = process.env.USERPROFILE; - let testStoragePath: string; - - beforeEach(async () => { - await fs.mkdir(testWorkDir, { recursive: true }); - }); - - afterEach(async () => { - setStoragePathDirect(null); - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserProfile; - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("writes .gitignore in project root when storage path is externalized", async () => { - const fakeHome = join(testWorkDir, "home"); - const projectDir = join(testWorkDir, "project-externalized"); - const gitDir = join(projectDir, ".git"); - const gitignorePath = join(projectDir, ".gitignore"); - - await fs.mkdir(fakeHome, { recursive: true }); - await fs.mkdir(gitDir, { recursive: true }); - process.env.HOME = fakeHome; - process.env.USERPROFILE = fakeHome; - setStoragePath(projectDir); - - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "t1", accountId: "A", addedAt: Date.now(), lastUsed: Date.now() }], - }; - - await saveAccounts(storage); - - expect(existsSync(gitignorePath)).toBe(true); - const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); - expect(gitignoreContent).toContain(".codex/"); - expect(getStoragePath()).toContain(join(fakeHome, ".codex", "multi-auth", "projects")); - }); - - it("creates .gitignore when it does not exist but .git dir exists (line 99-100 false branch)", async () => { - const projectDir = join(testWorkDir, "project"); - const codexDir = join(projectDir, ".codex"); - const gitDir = join(projectDir, ".git"); - const gitignorePath = join(projectDir, ".gitignore"); - - await fs.mkdir(codexDir, { recursive: true }); - await fs.mkdir(gitDir, { recursive: true }); - - testStoragePath = join(codexDir, "accounts.json"); - setStoragePathDirect(testStoragePath); - - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "t1", accountId: "A", addedAt: Date.now(), lastUsed: Date.now() }], - }; - - await saveAccounts(storage); - - expect(existsSync(gitignorePath)).toBe(true); - const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); - expect(gitignoreContent).toContain(".codex/"); - }); - - it("appends to existing .gitignore without trailing newline (line 107 coverage)", async () => { - const projectDir = join(testWorkDir, "project2"); - const codexDir = join(projectDir, ".codex"); - const gitDir = join(projectDir, ".git"); - const gitignorePath = join(projectDir, ".gitignore"); - - await fs.mkdir(codexDir, { recursive: true }); - await fs.mkdir(gitDir, { recursive: true }); - await fs.writeFile(gitignorePath, "node_modules", "utf-8"); - - testStoragePath = join(codexDir, "accounts.json"); - setStoragePathDirect(testStoragePath); - - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "t1", accountId: "A", addedAt: Date.now(), lastUsed: Date.now() }], - }; - - await saveAccounts(storage); - - const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); - expect(gitignoreContent).toBe("node_modules\n.codex/\n"); - }); - }); - - describe("legacy project storage migration", () => { - const testWorkDir = join(tmpdir(), "codex-legacy-migration-" + Math.random().toString(36).slice(2)); - const originalHome = process.env.HOME; - const originalUserProfile = process.env.USERPROFILE; - - afterEach(async () => { - setStoragePathDirect(null); - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserProfile; - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("removes legacy project storage file after successful migration", async () => { - const fakeHome = join(testWorkDir, "home"); - const projectDir = join(testWorkDir, "project"); - const projectGitDir = join(projectDir, ".git"); - const legacyProjectConfigDir = join(projectDir, ".codex"); - const legacyStoragePath = join(legacyProjectConfigDir, "openai-codex-accounts.json"); - - await fs.mkdir(fakeHome, { recursive: true }); - await fs.mkdir(projectGitDir, { recursive: true }); - await fs.mkdir(legacyProjectConfigDir, { recursive: true }); - process.env.HOME = fakeHome; - process.env.USERPROFILE = fakeHome; - setStoragePath(projectDir); - - const legacyStorage = { - version: 3, - activeIndex: 0, - accounts: [{ refreshToken: "legacy-refresh", accountId: "legacy-account", addedAt: 1, lastUsed: 1 }], - }; - await fs.writeFile(legacyStoragePath, JSON.stringify(legacyStorage), "utf-8"); - - const migrated = await loadAccounts(); - - expect(migrated).not.toBeNull(); - expect(migrated?.accounts).toHaveLength(1); - expect(existsSync(legacyStoragePath)).toBe(false); - expect(existsSync(getStoragePath())).toBe(true); - }); - }); - - describe("worktree-scoped storage migration", () => { - const testWorkDir = join(tmpdir(), "codex-worktree-migration-" + Math.random().toString(36).slice(2)); - const originalHome = process.env.HOME; - const originalUserProfile = process.env.USERPROFILE; - const originalMultiAuthDir = process.env.CODEX_MULTI_AUTH_DIR; - - type StoredAccountFixture = { - refreshToken: string; - accountId: string; - addedAt: number; - lastUsed: number; - }; - - const now = Date.now(); - const accountFromLegacy: StoredAccountFixture = { - refreshToken: "legacy-refresh", - accountId: "legacy-account", - addedAt: now, - lastUsed: now, - }; - const accountFromCanonical: StoredAccountFixture = { - refreshToken: "canonical-refresh", - accountId: "canonical-account", - addedAt: now + 1, - lastUsed: now + 1, - }; - - async function prepareWorktreeFixture(options?: { - pointerStyle?: "default" | "windows"; - worktreeName?: string; - }): Promise<{ - fakeHome: string; - mainRepo: string; - worktreeRepo: string; - }> { - const fakeHome = join(testWorkDir, "home"); - const mainRepo = join(testWorkDir, "repo-main"); - const worktreeName = options?.worktreeName ?? "repo-pr-8"; - const worktreeRepo = join(testWorkDir, worktreeName); - const mainGitDir = join(mainRepo, ".git"); - const worktreeGitDir = join(mainGitDir, "worktrees", worktreeName); - - process.env.HOME = fakeHome; - process.env.USERPROFILE = fakeHome; - process.env.CODEX_MULTI_AUTH_DIR = join(fakeHome, ".codex", "multi-auth"); - - await fs.mkdir(mainGitDir, { recursive: true }); - await fs.mkdir(worktreeGitDir, { recursive: true }); - await fs.mkdir(worktreeRepo, { recursive: true }); - if (options?.pointerStyle === "windows") { - const winGitDirPointer = worktreeGitDir.replace(/\//g, "\\"); - await fs.writeFile(join(worktreeRepo, ".git"), `gitdir: ${winGitDirPointer}\n`, "utf-8"); - await fs.writeFile(join(worktreeGitDir, "commondir"), "..\\..\\\n", "utf-8"); - await fs.writeFile( - join(worktreeGitDir, "gitdir"), - `${join(worktreeRepo, ".git").replace(/\//g, "\\")}\n`, - "utf-8", - ); - } else { - await fs.writeFile(join(worktreeRepo, ".git"), `gitdir: ${worktreeGitDir}\n`, "utf-8"); - await fs.writeFile(join(worktreeGitDir, "commondir"), "../..\n", "utf-8"); - await fs.writeFile(join(worktreeGitDir, "gitdir"), `${join(worktreeRepo, ".git")}\n`, "utf-8"); - } - - return { fakeHome, mainRepo, worktreeRepo }; - } - - function buildStorage(accounts: StoredAccountFixture[]) { - return { - version: 3 as const, - activeIndex: 0, - activeIndexByFamily: {}, - accounts, - }; - } - - beforeEach(async () => { - await fs.mkdir(testWorkDir, { recursive: true }); - }); - - afterEach(async () => { - setStoragePathDirect(null); - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - if (originalUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = originalUserProfile; - if (originalMultiAuthDir === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; - else process.env.CODEX_MULTI_AUTH_DIR = originalMultiAuthDir; - await fs.rm(testWorkDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it("migrates worktree-keyed storage to repo-shared canonical path", async () => { - const { worktreeRepo } = await prepareWorktreeFixture(); - - setStoragePath(worktreeRepo); - const canonicalPath = getStoragePath(); - const legacyWorktreePath = join( - getConfigDir(), - "projects", - getProjectStorageKey(worktreeRepo), - "openai-codex-accounts.json", - ); - expect(legacyWorktreePath).not.toBe(canonicalPath); - - await fs.mkdir(join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), { - recursive: true, - }); - await fs.writeFile( - legacyWorktreePath, - JSON.stringify(buildStorage([accountFromLegacy]), null, 2), - "utf-8", - ); - - const loaded = await loadAccounts(); - - expect(loaded).not.toBeNull(); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); - expect(existsSync(canonicalPath)).toBe(true); - expect(existsSync(legacyWorktreePath)).toBe(false); - }); - - it("merges canonical and legacy worktree storage when both exist", async () => { - const { worktreeRepo } = await prepareWorktreeFixture(); - - setStoragePath(worktreeRepo); - const canonicalPath = getStoragePath(); - const legacyWorktreePath = join( - getConfigDir(), - "projects", - getProjectStorageKey(worktreeRepo), - "openai-codex-accounts.json", - ); - await fs.mkdir(join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), { - recursive: true, - }); - await fs.mkdir(join(getConfigDir(), "projects", getProjectStorageKey(join(testWorkDir, "repo-main"))), { - recursive: true, - }); - - await fs.writeFile( - canonicalPath, - JSON.stringify(buildStorage([accountFromCanonical]), null, 2), - "utf-8", - ); - await fs.writeFile( - legacyWorktreePath, - JSON.stringify(buildStorage([accountFromLegacy]), null, 2), - "utf-8", - ); - - const loaded = await loadAccounts(); - - expect(loaded).not.toBeNull(); - expect(loaded?.accounts).toHaveLength(2); - const accountIds = loaded?.accounts.map((account) => account.accountId) ?? []; - expect(accountIds).toContain("canonical-account"); - expect(accountIds).toContain("legacy-account"); - expect(existsSync(legacyWorktreePath)).toBe(false); - }); - - it("keeps legacy worktree file when migration persist fails", async () => { - const { worktreeRepo } = await prepareWorktreeFixture(); - - setStoragePath(worktreeRepo); - const canonicalPath = getStoragePath(); - const canonicalWalPath = `${canonicalPath}.wal`; - const legacyWorktreePath = join( - getConfigDir(), - "projects", - getProjectStorageKey(worktreeRepo), - "openai-codex-accounts.json", - ); - await fs.mkdir(join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), { - recursive: true, - }); - await fs.writeFile( - legacyWorktreePath, - JSON.stringify(buildStorage([accountFromLegacy]), null, 2), - "utf-8", - ); - - const originalWriteFile = fs.writeFile.bind(fs); - const writeSpy = vi - .spyOn(fs, "writeFile") - .mockImplementation(async (...args: Parameters) => { - const [targetPath] = args; - if (typeof targetPath === "string" && targetPath === canonicalWalPath) { - const error = new Error("forced write failure") as NodeJS.ErrnoException; - error.code = "EACCES"; - throw error; - } - return originalWriteFile(...args); - }); - - const loaded = await loadAccounts(); - - writeSpy.mockRestore(); - expect(loaded).not.toBeNull(); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); - expect(existsSync(legacyWorktreePath)).toBe(true); - }); - - it("handles concurrent loadAccounts migration without duplicate race artifacts", async () => { - const { worktreeRepo } = await prepareWorktreeFixture({ worktreeName: "repo-pr-race" }); - - setStoragePath(worktreeRepo); - const canonicalPath = getStoragePath(); - const legacyWorktreePath = join( - getConfigDir(), - "projects", - getProjectStorageKey(worktreeRepo), - "openai-codex-accounts.json", - ); - await fs.mkdir(join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), { - recursive: true, - }); - await fs.mkdir(dirname(canonicalPath), { recursive: true }); - await fs.writeFile( - canonicalPath, - JSON.stringify(buildStorage([accountFromCanonical]), null, 2), - "utf-8", - ); - await fs.writeFile( - legacyWorktreePath, - JSON.stringify(buildStorage([accountFromLegacy]), null, 2), - "utf-8", - ); - - const results = await Promise.all([ - loadAccounts(), - loadAccounts(), - loadAccounts(), - loadAccounts(), - ]); - - for (const result of results) { - expect(result).not.toBeNull(); - expect(result?.accounts).toHaveLength(2); - } - - const persistedRaw = await fs.readFile(canonicalPath, "utf-8"); - const persistedNormalized = normalizeAccountStorage(JSON.parse(persistedRaw) as unknown); - expect(persistedNormalized).not.toBeNull(); - expect(persistedNormalized?.accounts).toHaveLength(2); - expect(existsSync(legacyWorktreePath)).toBe(false); - }); - - it("migrates worktree storage with Windows-style gitdir pointer fixtures", async () => { - const { worktreeRepo } = await prepareWorktreeFixture({ - pointerStyle: "windows", - worktreeName: "repo-pr-win-ptr", - }); - - setStoragePath(worktreeRepo); - const canonicalPath = getStoragePath(); - const legacyWorktreePath = join( - getConfigDir(), - "projects", - getProjectStorageKey(worktreeRepo), - "openai-codex-accounts.json", - ); - expect(legacyWorktreePath).not.toBe(canonicalPath); - - await fs.mkdir(join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), { - recursive: true, - }); - await fs.writeFile( - legacyWorktreePath, - JSON.stringify(buildStorage([accountFromLegacy]), null, 2), - "utf-8", - ); - - const loaded = await loadAccounts(); - - expect(loaded).not.toBeNull(); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); - expect(existsSync(canonicalPath)).toBe(true); - expect(existsSync(legacyWorktreePath)).toBe(false); - }); - - it("rejects forged commondir aliasing and keeps storage scoped to the current worktree", async () => { - const worktreeName = "repo-pr-hostile"; - const { mainRepo, worktreeRepo } = await prepareWorktreeFixture({ worktreeName }); - const worktreeGitDir = join(mainRepo, ".git", "worktrees", worktreeName); - const foreignRepo = join(testWorkDir, "repo-foreign"); - const foreignGitDir = join(foreignRepo, ".git"); - const foreignAccount: StoredAccountFixture = { - refreshToken: "foreign-refresh", - accountId: "foreign-account", - addedAt: now + 2, - lastUsed: now + 2, - }; - - await fs.mkdir(foreignGitDir, { recursive: true }); - await fs.writeFile(join(worktreeGitDir, "commondir"), `${foreignGitDir}\n`, "utf-8"); - - setStoragePath(worktreeRepo); - const canonicalPath = getStoragePath(); - const safeCanonicalPath = join( - getConfigDir(), - "projects", - getProjectStorageKey(worktreeRepo), - "openai-codex-accounts.json", - ); - const foreignCanonicalPath = join( - getConfigDir(), - "projects", - getProjectStorageKey(foreignRepo), - "openai-codex-accounts.json", - ); - await fs.mkdir(dirname(safeCanonicalPath), { recursive: true }); - await fs.mkdir(dirname(foreignCanonicalPath), { recursive: true }); - await fs.writeFile( - safeCanonicalPath, - JSON.stringify(buildStorage([accountFromLegacy]), null, 2), - "utf-8", - ); - await fs.writeFile( - foreignCanonicalPath, - JSON.stringify(buildStorage([foreignAccount]), null, 2), - "utf-8", - ); - - const loaded = await loadAccounts(); - - expect(canonicalPath).toBe(safeCanonicalPath); - expect(canonicalPath).not.toBe(foreignCanonicalPath); - expect(loaded).not.toBeNull(); - expect(loaded?.accounts).toHaveLength(1); - expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); - expect(existsSync(canonicalPath)).toBe(true); - - const foreignRaw = await fs.readFile(foreignCanonicalPath, "utf-8"); - const foreignStorage = normalizeAccountStorage(JSON.parse(foreignRaw) as unknown); - expect(foreignStorage?.accounts[0]?.accountId).toBe("foreign-account"); - }); - }); - - describe("saveAccounts EPERM/EBUSY retry logic", () => { - const testWorkDir = join(tmpdir(), "codex-retry-" + Math.random().toString(36).slice(2)); - let testStoragePath: string; - - beforeEach(async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - await fs.mkdir(testWorkDir, { recursive: true }); - testStoragePath = join(testWorkDir, "accounts.json"); - setStoragePathDirect(testStoragePath); - }); - - afterEach(async () => { - vi.useRealTimers(); - setStoragePathDirect(null); - await fs.rm(testWorkDir, { recursive: true, force: true }); - }); - - it("retries on EPERM and succeeds on second attempt", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - const originalRename = fs.rename.bind(fs); - let attemptCount = 0; - const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { - attemptCount++; - if (attemptCount === 1) { - const err = new Error("EPERM error") as NodeJS.ErrnoException; - err.code = "EPERM"; - throw err; - } - return originalRename(oldPath as string, newPath as string); - }); - - await saveAccounts(storage); - expect(attemptCount).toBe(2); - expect(existsSync(testStoragePath)).toBe(true); - - renameSpy.mockRestore(); - }); - - it("retries on EBUSY and succeeds on third attempt", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - const originalRename = fs.rename.bind(fs); - let attemptCount = 0; - const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { - attemptCount++; - if (attemptCount <= 2) { - const err = new Error("EBUSY error") as NodeJS.ErrnoException; - err.code = "EBUSY"; - throw err; - } - return originalRename(oldPath as string, newPath as string); - }); - - await saveAccounts(storage); - expect(attemptCount).toBe(3); - expect(existsSync(testStoragePath)).toBe(true); - - renameSpy.mockRestore(); - }); - - it("throws after 5 failed EPERM retries", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - let attemptCount = 0; - const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async () => { - attemptCount++; - const err = new Error("EPERM error") as NodeJS.ErrnoException; - err.code = "EPERM"; - throw err; - }); - - await expect(saveAccounts(storage)).rejects.toThrow("Failed to save accounts"); - expect(attemptCount).toBe(5); - - renameSpy.mockRestore(); - }); - - it("throws immediately on non-EPERM/EBUSY errors", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - let attemptCount = 0; - const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async () => { - attemptCount++; - const err = new Error("EACCES error") as NodeJS.ErrnoException; - err.code = "EACCES"; - throw err; - }); - - await expect(saveAccounts(storage)).rejects.toThrow("Failed to save accounts"); - expect(attemptCount).toBe(1); - - renameSpy.mockRestore(); - }); - - it("throws when temp file is written with size 0", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - const statSpy = vi.spyOn(fs, "stat").mockResolvedValue({ - size: 0, - isFile: () => true, - isDirectory: () => false, - } as unknown as Awaited>); - - await expect(saveAccounts(storage)).rejects.toThrow("Failed to save accounts"); - expect(statSpy).toHaveBeenCalled(); - - statSpy.mockRestore(); - }); - - it("retries backup copyFile on transient EBUSY and succeeds", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - // Seed a primary file so backup creation path runs on next save. - await saveAccounts(storage); - - const originalCopy = fs.copyFile.bind(fs); - let copyAttempts = 0; - const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { - copyAttempts += 1; - if (copyAttempts === 1) { - const err = new Error("EBUSY copy") as NodeJS.ErrnoException; - err.code = "EBUSY"; - throw err; - } - return originalCopy(src as string, dest as string); - }); - try { - await saveAccounts({ - ...storage, - accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], - }); - - expect(copyAttempts).toBe(2); - } finally { - copySpy.mockRestore(); - } - }); - - it("retries backup copyFile on transient EPERM and succeeds", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - // Seed a primary file so backup creation path runs on next save. - await saveAccounts(storage); - - const originalCopy = fs.copyFile.bind(fs); - let copyAttempts = 0; - const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { - copyAttempts += 1; - if (copyAttempts === 1) { - const err = new Error("EPERM copy") as NodeJS.ErrnoException; - err.code = "EPERM"; - throw err; - } - return originalCopy(src as string, dest as string); - }); - try { - await saveAccounts({ - ...storage, - accounts: [{ refreshToken: "token-next", addedAt: now, lastUsed: now }], - }); - - expect(copyAttempts).toBe(2); - } finally { - copySpy.mockRestore(); - } - }); - - it("retries staged backup rename on transient EBUSY and succeeds", async () => { - const now = Date.now(); - const storagePath = getStoragePath(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], - }; - - // Seed a primary file so backup creation path runs on next save. - await saveAccounts(storage); - - const originalRename = fs.rename.bind(fs); - let stagedRenameAttempts = 0; - const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (oldPath, newPath) => { - const sourcePath = String(oldPath); - if (sourcePath.includes(".rotate.")) { - stagedRenameAttempts += 1; - if (stagedRenameAttempts === 1) { - const err = new Error("EBUSY staged rename") as NodeJS.ErrnoException; - err.code = "EBUSY"; - throw err; - } - } - return originalRename(oldPath as string, newPath as string); - }); - try { - await saveAccounts({ - ...storage, - accounts: [{ refreshToken: "token-next", addedAt: now + 1, lastUsed: now + 1 }], - }); - - expect(stagedRenameAttempts).toBe(2); - const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token"); - } finally { - renameSpy.mockRestore(); - } - }); - - it("rotates backups and retains historical snapshots", async () => { - const now = Date.now(); - const storagePath = getStoragePath(); - - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], - }); - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }], - }); - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }], - }); - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }], - }); - - const latestBackupRaw = await fs.readFile(`${storagePath}.bak`, "utf-8"); - const historicalBackupRaw = await fs.readFile(`${storagePath}.bak.1`, "utf-8"); - const oldestBackupRaw = await fs.readFile(`${storagePath}.bak.2`, "utf-8"); - const latestBackup = JSON.parse(latestBackupRaw) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const historicalBackup = JSON.parse(historicalBackupRaw) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const oldestBackup = JSON.parse(oldestBackupRaw) as { - accounts?: Array<{ refreshToken?: string }>; - }; - - expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); - expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); - expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); - }); - - it("preserves historical backups when creating the latest backup fails", async () => { - const now = Date.now(); - const storagePath = getStoragePath(); - - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], - }); - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }], - }); - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }], - }); - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }], - }); - - const originalCopy = fs.copyFile.bind(fs); - const copySpy = vi.spyOn(fs, "copyFile").mockImplementation(async (src, dest) => { - if (src === storagePath) { - const err = new Error("ENOSPC backup copy") as NodeJS.ErrnoException; - err.code = "ENOSPC"; - throw err; - } - return originalCopy(src as string, dest as string); - }); - try { - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-5", addedAt: now + 4, lastUsed: now + 4 }], - }); - } finally { - copySpy.mockRestore(); - } - - const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const historicalBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.1`, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const oldestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.2`, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - - expect(primary.accounts?.[0]?.refreshToken).toBe("token-5"); - expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); - expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); - expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); - }); - - it("keeps rotating backup order deterministic across parallel saves", async () => { - const now = Date.now(); - const storagePath = getStoragePath(); - - await saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-0", addedAt: now, lastUsed: now }], - }); - - await Promise.all([ - saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-1", addedAt: now + 1, lastUsed: now + 1 }], - }), - saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-2", addedAt: now + 2, lastUsed: now + 2 }], - }), - saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-3", addedAt: now + 3, lastUsed: now + 3 }], - }), - saveAccounts({ - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-4", addedAt: now + 4, lastUsed: now + 4 }], - }), - ]); - - const latestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak`, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const historicalBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.1`, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - const oldestBackup = JSON.parse(await fs.readFile(`${storagePath}.bak.2`, "utf-8")) as { - accounts?: Array<{ refreshToken?: string }>; - }; - - expect(primary.accounts?.[0]?.refreshToken).toBe("token-4"); - expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); - expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); - expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); - }); - }); - - describe("clearAccounts edge cases", () => { - it("removes primary, backup, and wal artifacts", async () => { - const now = Date.now(); - const storage = { - version: 3 as const, - activeIndex: 0, - accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], - }; - - const storagePath = getStoragePath(); - await saveAccounts(storage); - await fs.writeFile(`${storagePath}.bak`, JSON.stringify(storage), "utf-8"); - await fs.writeFile(`${storagePath}.bak.1`, JSON.stringify(storage), "utf-8"); - await fs.writeFile(`${storagePath}.bak.2`, JSON.stringify(storage), "utf-8"); - await fs.writeFile(`${storagePath}.wal`, JSON.stringify(storage), "utf-8"); - - expect(existsSync(storagePath)).toBe(true); - expect(existsSync(`${storagePath}.bak`)).toBe(true); - expect(existsSync(`${storagePath}.bak.1`)).toBe(true); - expect(existsSync(`${storagePath}.bak.2`)).toBe(true); - expect(existsSync(`${storagePath}.wal`)).toBe(true); - - await clearAccounts(); - - expect(existsSync(storagePath)).toBe(false); - expect(existsSync(`${storagePath}.bak`)).toBe(false); - expect(existsSync(`${storagePath}.bak.1`)).toBe(false); - expect(existsSync(`${storagePath}.bak.2`)).toBe(false); - expect(existsSync(`${storagePath}.wal`)).toBe(false); - }); - - it("logs error for non-ENOENT errors during clear", async () => { - const unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValue( - Object.assign(new Error("EACCES error"), { code: "EACCES" }) - ); - - await clearAccounts(); - - expect(unlinkSpy).toHaveBeenCalled(); - unlinkSpy.mockRestore(); - }); - }); + const _origCODEX_HOME = process.env.CODEX_HOME; + const _origCODEX_MULTI_AUTH_DIR = process.env.CODEX_MULTI_AUTH_DIR; + + beforeEach(() => { + delete process.env.CODEX_HOME; + delete process.env.CODEX_MULTI_AUTH_DIR; + }); + + afterEach(() => { + if (_origCODEX_HOME !== undefined) process.env.CODEX_HOME = _origCODEX_HOME; + else delete process.env.CODEX_HOME; + if (_origCODEX_MULTI_AUTH_DIR !== undefined) + process.env.CODEX_MULTI_AUTH_DIR = _origCODEX_MULTI_AUTH_DIR; + else delete process.env.CODEX_MULTI_AUTH_DIR; + }); + describe("deduplication", () => { + it("remaps activeIndex after deduplication using active account key", () => { + const now = Date.now(); + + const raw = { + version: 1, + activeIndex: 1, + accounts: [ + { + accountId: "acctA", + refreshToken: "tokenA", + addedAt: now - 2000, + lastUsed: now - 2000, + }, + { + accountId: "acctA", + refreshToken: "tokenA", + addedAt: now - 1000, + lastUsed: now - 1000, + }, + { + accountId: "acctB", + refreshToken: "tokenB", + addedAt: now, + lastUsed: now, + }, + ], + }; + + const normalized = normalizeAccountStorage(raw); + expect(normalized).not.toBeNull(); + expect(normalized?.accounts).toHaveLength(2); + expect(normalized?.accounts[0]?.accountId).toBe("acctA"); + expect(normalized?.accounts[1]?.accountId).toBe("acctB"); + expect(normalized?.activeIndex).toBe(0); + }); + + it("deduplicates accounts by keeping the most recently used record", () => { + const now = Date.now(); + + const accounts = [ + { + accountId: "acctA", + refreshToken: "tokenA", + addedAt: now - 2000, + lastUsed: now - 1000, + }, + { + accountId: "acctA", + refreshToken: "tokenA", + addedAt: now - 1500, + lastUsed: now, + }, + ]; + + const deduped = deduplicateAccounts(accounts); + expect(deduped).toHaveLength(1); + expect(deduped[0]?.addedAt).toBe(now - 1500); + expect(deduped[0]?.lastUsed).toBe(now); + }); + }); + + describe("import/export (TDD)", () => { + const testWorkDir = join( + tmpdir(), + "codex-test-" + Math.random().toString(36).slice(2), + ); + const exportPath = join(testWorkDir, "export.json"); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join( + testWorkDir, + "accounts-" + Math.random().toString(36).slice(2) + ".json", + ); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("should export accounts to a file", async () => { + // @ts-expect-error - exportAccounts doesn't exist yet + const { exportAccounts } = await import("../lib/storage.js"); + + const storage = { + version: 3, + activeIndex: 0, + accounts: [ + { accountId: "test", refreshToken: "ref", addedAt: 1, lastUsed: 2 }, + ], + }; + // @ts-expect-error + await saveAccounts(storage); + + // @ts-expect-error + await exportAccounts(exportPath); + + expect(existsSync(exportPath)).toBe(true); + const exported = JSON.parse(await fs.readFile(exportPath, "utf-8")); + expect(exported.accounts[0].accountId).toBe("test"); + }); + + it("should fail export if file exists and force is false", async () => { + // @ts-expect-error + const { exportAccounts } = await import("../lib/storage.js"); + await fs.writeFile(exportPath, "exists"); + + // @ts-expect-error + await expect(exportAccounts(exportPath, false)).rejects.toThrow( + /already exists/, + ); + }); + + it("should import accounts from a file and merge", async () => { + // @ts-expect-error + const { importAccounts } = await import("../lib/storage.js"); + + const existing = { + version: 3, + activeIndex: 0, + accounts: [ + { + accountId: "existing", + refreshToken: "ref1", + addedAt: 1, + lastUsed: 2, + }, + ], + }; + // @ts-expect-error + await saveAccounts(existing); + + const toImport = { + version: 3, + activeIndex: 0, + accounts: [ + { accountId: "new", refreshToken: "ref2", addedAt: 3, lastUsed: 4 }, + ], + }; + await fs.writeFile(exportPath, JSON.stringify(toImport)); + + // @ts-expect-error + await importAccounts(exportPath); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(2); + expect(loaded?.accounts.map((a) => a.accountId)).toContain("new"); + }); + + it("should serialize concurrent transactional updates without losing accounts", async () => { + await saveAccounts({ + version: 3, + activeIndex: 0, + accounts: [], + }); + + const addAccount = async ( + accountId: string, + delayMs: number, + ): Promise => { + await withAccountStorageTransaction(async (current, persist) => { + const snapshot = current ?? { + version: 3 as const, + activeIndex: 0, + accounts: [], + }; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + await persist({ + ...snapshot, + accounts: [ + ...snapshot.accounts, + { + accountId, + refreshToken: `ref-${accountId}`, + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + }); + }; + + await Promise.all([addAccount("acct-a", 20), addAccount("acct-b", 0)]); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(2); + expect( + new Set(loaded?.accounts.map((account) => account.accountId)), + ).toEqual(new Set(["acct-a", "acct-b"])); + }); + + it("should enforce MAX_ACCOUNTS during import", async () => { + // @ts-expect-error + const { importAccounts } = await import("../lib/storage.js"); + + const manyAccounts = Array.from({ length: 21 }, (_, i) => ({ + accountId: `acct${i}`, + refreshToken: `ref${i}`, + addedAt: Date.now(), + lastUsed: Date.now(), + })); + + const toImport = { + version: 3, + activeIndex: 0, + accounts: manyAccounts, + }; + await fs.writeFile(exportPath, JSON.stringify(toImport)); + + // @ts-expect-error + await expect(importAccounts(exportPath)).rejects.toThrow( + /exceed maximum/, + ); + }); + + it("should fail export when no accounts exist", async () => { + const { exportAccounts } = await import("../lib/storage.js"); + setStoragePathDirect(testStoragePath); + await expect(exportAccounts(exportPath)).rejects.toThrow( + /No accounts to export/, + ); + }); + + it("should fail import when file does not exist", async () => { + const { importAccounts } = await import("../lib/storage.js"); + const nonexistentPath = join(testWorkDir, "nonexistent-file.json"); + await expect(importAccounts(nonexistentPath)).rejects.toThrow( + /Import file not found/, + ); + }); + + it("should fail import when file contains invalid JSON", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await fs.writeFile(exportPath, "not valid json {["); + await expect(importAccounts(exportPath)).rejects.toThrow(/Invalid JSON/); + }); + + it("should fail import when file contains invalid format", async () => { + const { importAccounts } = await import("../lib/storage.js"); + await fs.writeFile(exportPath, JSON.stringify({ invalid: "format" })); + await expect(importAccounts(exportPath)).rejects.toThrow( + /Invalid account storage format/, + ); + }); + }); + + describe("filename migration (TDD)", () => { + it("should migrate from old filename to new filename", async () => { + // This test is tricky because it depends on the internal state of getStoragePath() + // which we are about to change. + + const oldName = "openai-codex-accounts.json"; + const newName = "codex-accounts.json"; + + // We'll need to mock/verify that loadAccounts checks for oldName if newName is missing + // Since we haven't implemented it yet, this is just a placeholder for the logic + expect(true).toBe(true); + }); + }); + + describe("StorageError and formatStorageErrorHint", () => { + describe("StorageError class", () => { + it("should store code, path, and hint properties", () => { + const err = new StorageError( + "Failed to write file", + "EACCES", + "/path/to/file.json", + "Permission denied. Check folder permissions.", + ); + + expect(err.name).toBe("StorageError"); + expect(err.message).toBe("Failed to write file"); + expect(err.code).toBe("EACCES"); + expect(err.path).toBe("/path/to/file.json"); + expect(err.hint).toBe("Permission denied. Check folder permissions."); + }); + + it("should be instanceof Error", () => { + const err = new StorageError("test", "CODE", "/path", "hint"); + expect(err instanceof Error).toBe(true); + expect(err instanceof StorageError).toBe(true); + }); + }); + + describe("formatStorageErrorHint", () => { + const testPath = "/home/user/.codex/accounts.json"; + + it("should return permission hint for EACCES on Windows", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + + const err = { code: "EACCES" } as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("antivirus"); + expect(hint).toContain(testPath); + + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it("should return chmod hint for EACCES on Unix", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "darwin" }); + + const err = { code: "EACCES" } as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("chmod"); + expect(hint).toContain(testPath); + + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it("should return permission hint for EPERM", () => { + const err = { code: "EPERM" } as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("Permission denied"); + expect(hint).toContain(testPath); + }); + + it("should return file locked hint for EBUSY", () => { + const err = { code: "EBUSY" } as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("locked"); + expect(hint).toContain("another program"); + }); + + it("should return disk full hint for ENOSPC", () => { + const err = { code: "ENOSPC" } as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("Disk is full"); + }); + + it("should return empty file hint for EEMPTY", () => { + const err = { code: "EEMPTY" } as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("empty"); + }); + + it("should return generic hint for unknown error codes", () => { + const err = { code: "UNKNOWN_CODE" } as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("Failed to write"); + expect(hint).toContain(testPath); + }); + + it("should handle errors without code property", () => { + const err = new Error("Some error") as NodeJS.ErrnoException; + const hint = formatStorageErrorHint(err, testPath); + + expect(hint).toContain("Failed to write"); + expect(hint).toContain(testPath); + }); + }); + }); + + describe("selectNewestAccount logic", () => { + it("when lastUsed are equal, prefers newer addedAt", () => { + const now = Date.now(); + const accounts = [ + { + accountId: "A", + refreshToken: "t1", + addedAt: now - 1000, + lastUsed: now, + }, + { + accountId: "A", + refreshToken: "t1", + addedAt: now - 500, + lastUsed: now, + }, + ]; + const deduped = deduplicateAccounts(accounts); + expect(deduped).toHaveLength(1); + expect(deduped[0]?.addedAt).toBe(now - 500); + }); + + it("when candidate lastUsed is less than current, keeps current", () => { + const now = Date.now(); + const accounts = [ + { accountId: "A", refreshToken: "t1", addedAt: now, lastUsed: now }, + { + accountId: "A", + refreshToken: "t1", + addedAt: now - 500, + lastUsed: now - 1000, + }, + ]; + const deduped = deduplicateAccounts(accounts); + expect(deduped).toHaveLength(1); + expect(deduped[0]?.lastUsed).toBe(now); + }); + + it("handles accounts without lastUsed or addedAt", () => { + const accounts = [ + { accountId: "A", refreshToken: "t1" }, + { accountId: "A", refreshToken: "t1", lastUsed: 100 }, + ]; + const deduped = deduplicateAccounts(accounts); + expect(deduped).toHaveLength(1); + expect(deduped[0]?.lastUsed).toBe(100); + }); + }); + + describe("deduplicateAccountsByKey edge cases", () => { + it("uses refreshToken as key when accountId is empty", () => { + const accounts = [ + { accountId: "A", refreshToken: "t1", lastUsed: 100 }, + { accountId: "", refreshToken: "t2", lastUsed: 200 }, + { accountId: "C", refreshToken: "t3", lastUsed: 300 }, + ]; + const deduped = deduplicateAccounts(accounts); + expect(deduped).toHaveLength(3); + }); + + it("handles empty array", () => { + const deduped = deduplicateAccounts([]); + expect(deduped).toHaveLength(0); + }); + + it("handles null/undefined in array", () => { + const accounts = [ + { accountId: "A", refreshToken: "t1" }, + null as never, + { accountId: "B", refreshToken: "t2" }, + ]; + const deduped = deduplicateAccounts(accounts); + expect(deduped).toHaveLength(2); + }); + }); + + describe("deduplicateAccountsByEmail edge cases", () => { + it("preserves accounts without email", () => { + const accounts = [ + { email: "test@example.com", lastUsed: 100, addedAt: 50 }, + { lastUsed: 200, addedAt: 100 }, + { email: "", lastUsed: 300, addedAt: 150 }, + ]; + const deduped = deduplicateAccountsByEmail(accounts); + expect(deduped).toHaveLength(3); + }); + + it("handles email with whitespace", () => { + const accounts = [ + { email: " test@example.com ", lastUsed: 100, addedAt: 50 }, + { email: "test@example.com", lastUsed: 200, addedAt: 100 }, + ]; + const deduped = deduplicateAccountsByEmail(accounts); + expect(deduped).toHaveLength(1); + }); + + it("treats email casing as the same logical account", () => { + const accounts = [ + { + email: "Test@Example.com", + refreshToken: "old", + lastUsed: 100, + addedAt: 10, + }, + { + email: "test@example.com", + refreshToken: "new", + lastUsed: 200, + addedAt: 20, + }, + ]; + const deduped = deduplicateAccountsByEmail(accounts); + expect(deduped).toHaveLength(1); + expect(deduped[0]?.refreshToken).toBe("new"); + expect(deduped[0]?.email).toBe("test@example.com"); + }); + + it("handles null existing account edge case", () => { + const accounts = [ + { email: "test@example.com", lastUsed: 100 }, + { email: "test@example.com", lastUsed: 200 }, + ]; + const deduped = deduplicateAccountsByEmail(accounts); + expect(deduped.length).toBeGreaterThanOrEqual(1); + }); + + it("when addedAt differs but lastUsed is same, uses addedAt to decide", () => { + const now = Date.now(); + const accounts = [ + { email: "test@example.com", lastUsed: now, addedAt: now - 1000 }, + { email: "test@example.com", lastUsed: now, addedAt: now - 500 }, + ]; + const deduped = deduplicateAccountsByEmail(accounts); + expect(deduped).toHaveLength(1); + expect(deduped[0]?.addedAt).toBe(now - 500); + }); + }); + + describe("normalizeAccountStorage edge cases", () => { + it("returns null for non-object data", () => { + expect(normalizeAccountStorage(null)).toBeNull(); + expect(normalizeAccountStorage("string")).toBeNull(); + expect(normalizeAccountStorage(123)).toBeNull(); + expect(normalizeAccountStorage([])).toBeNull(); + }); + + it("returns null for invalid version", () => { + const result = normalizeAccountStorage({ version: 2, accounts: [] }); + expect(result).toBeNull(); + }); + + it("returns null for non-array accounts", () => { + expect( + normalizeAccountStorage({ version: 3, accounts: "not-array" }), + ).toBeNull(); + expect(normalizeAccountStorage({ version: 3, accounts: {} })).toBeNull(); + }); + + it("handles missing activeIndex", () => { + const data = { + version: 3, + accounts: [{ refreshToken: "t1", accountId: "A" }], + }; + const result = normalizeAccountStorage(data); + expect(result?.activeIndex).toBe(0); + }); + + it("handles non-finite activeIndex", () => { + const data = { + version: 3, + activeIndex: NaN, + accounts: [{ refreshToken: "t1", accountId: "A" }], + }; + const result = normalizeAccountStorage(data); + expect(result?.activeIndex).toBe(0); + }); + + it("handles Infinity activeIndex", () => { + const data = { + version: 3, + activeIndex: Infinity, + accounts: [{ refreshToken: "t1", accountId: "A" }], + }; + const result = normalizeAccountStorage(data); + expect(result?.activeIndex).toBe(0); + }); + + it("clamps out-of-bounds activeIndex", () => { + const data = { + version: 3, + activeIndex: 100, + accounts: [ + { refreshToken: "t1", accountId: "A" }, + { refreshToken: "t2", accountId: "B" }, + ], + }; + const result = normalizeAccountStorage(data); + expect(result?.activeIndex).toBe(1); + }); + + it("filters out accounts with empty refreshToken", () => { + const data = { + version: 3, + accounts: [ + { refreshToken: "valid", accountId: "A" }, + { refreshToken: " ", accountId: "B" }, + { refreshToken: "", accountId: "C" }, + ], + }; + const result = normalizeAccountStorage(data); + expect(result?.accounts).toHaveLength(1); + }); + + it("remaps activeKey when deduplication changes indices", () => { + const now = Date.now(); + const data = { + version: 3, + activeIndex: 2, + accounts: [ + { refreshToken: "t1", accountId: "A", lastUsed: now - 100 }, + { refreshToken: "t1", accountId: "A", lastUsed: now }, + { refreshToken: "t2", accountId: "B", lastUsed: now - 50 }, + ], + }; + const result = normalizeAccountStorage(data); + expect(result?.accounts).toHaveLength(2); + expect(result?.activeIndex).toBe(1); + }); + + it("handles v1 to v3 migration", () => { + const data = { + version: 1, + activeIndex: 0, + accounts: [ + { + refreshToken: "t1", + accountId: "A", + accessToken: "acc1", + expiresAt: Date.now() + 3600000, + }, + ], + }; + const result = normalizeAccountStorage(data); + expect(result?.version).toBe(3); + expect(result?.accounts).toHaveLength(1); + }); + + it("preserves activeIndexByFamily when valid", () => { + const data = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 1, "gpt-5.x": 0 }, + accounts: [ + { refreshToken: "t1", accountId: "A" }, + { refreshToken: "t2", accountId: "B" }, + ], + }; + const result = normalizeAccountStorage(data); + expect(result?.activeIndexByFamily).toBeDefined(); + }); + + it("handles activeIndexByFamily with non-finite values", () => { + const data = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: NaN, "gpt-5.x": Infinity }, + accounts: [{ refreshToken: "t1", accountId: "A" }], + }; + const result = normalizeAccountStorage(data); + expect(result?.activeIndexByFamily).toBeDefined(); + }); + + it("handles account with only accountId, no refreshToken key match", () => { + const data = { + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "t1", accountId: "" }], + }; + const result = normalizeAccountStorage(data); + expect(result?.accounts).toHaveLength(1); + }); + }); + + describe("loadAccounts", () => { + const testWorkDir = join( + tmpdir(), + "codex-load-test-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("returns null when file does not exist", async () => { + const result = await loadAccounts(); + expect(result).toBeNull(); + }); + + it("returns null on parse error", async () => { + await fs.writeFile(testStoragePath, "not valid json{{{", "utf-8"); + const result = await loadAccounts(); + expect(result).toBeNull(); + }); + + it("returns normalized data on valid file", async () => { + const storage = { + version: 3, + activeIndex: 0, + accounts: [{ refreshToken: "t1", accountId: "A" }], + }; + await fs.writeFile(testStoragePath, JSON.stringify(storage), "utf-8"); + const result = await loadAccounts(); + expect(result?.accounts).toHaveLength(1); + }); + + it("logs schema validation warnings but still returns data", async () => { + const storage = { + version: 3, + activeIndex: 0, + accounts: [ + { refreshToken: "t1", accountId: "A", extraField: "ignored" }, + ], + }; + await fs.writeFile(testStoragePath, JSON.stringify(storage), "utf-8"); + const result = await loadAccounts(); + expect(result).not.toBeNull(); + }); + + it("migrates v1 to v3 and attempts to save", async () => { + const v1Storage = { + version: 1, + activeIndex: 0, + accounts: [ + { + refreshToken: "t1", + accountId: "A", + accessToken: "acc", + expiresAt: Date.now() + 3600000, + }, + ], + }; + await fs.writeFile(testStoragePath, JSON.stringify(v1Storage), "utf-8"); + const result = await loadAccounts(); + expect(result?.version).toBe(3); + const saved = JSON.parse(await fs.readFile(testStoragePath, "utf-8")); + expect(saved.version).toBe(3); + }); + + it("returns migrated data even when save fails (line 422-423 coverage)", async () => { + const v1Storage = { + version: 1, + activeIndex: 0, + accounts: [ + { + refreshToken: "t1", + accountId: "A", + accessToken: "acc", + expiresAt: Date.now() + 3600000, + }, + ], + }; + await fs.writeFile(testStoragePath, JSON.stringify(v1Storage), "utf-8"); + + // Make the file read-only to cause save to fail + await fs.chmod(testStoragePath, 0o444); + + const result = await loadAccounts(); + + // Should still return migrated data even though save failed + expect(result?.version).toBe(3); + expect(result?.accounts).toHaveLength(1); + + // Restore permissions for cleanup + await fs.chmod(testStoragePath, 0o644); + }); + }); + + describe("saveAccounts", () => { + const testWorkDir = join( + tmpdir(), + "codex-save-test-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, ".codex", "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("creates directory and saves file", async () => { + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "t1", + accountId: "A", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }; + await saveAccounts(storage); + expect(existsSync(testStoragePath)).toBe(true); + }); + + it("writes valid JSON", async () => { + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "t1", accountId: "A", addedAt: 1, lastUsed: 2 }, + ], + }; + await saveAccounts(storage); + const content = await fs.readFile(testStoragePath, "utf-8"); + const parsed = JSON.parse(content); + expect(parsed.version).toBe(3); + }); + }); + + describe("clearAccounts", () => { + const testWorkDir = join( + tmpdir(), + "codex-clear-test-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("deletes the file when it exists", async () => { + await fs.writeFile(testStoragePath, "{}"); + expect(existsSync(testStoragePath)).toBe(true); + await clearAccounts(); + expect(existsSync(testStoragePath)).toBe(false); + }); + + it("does not throw when file does not exist", async () => { + await expect(clearAccounts()).resolves.not.toThrow(); + }); + }); + + describe("setStoragePath", () => { + afterEach(() => { + setStoragePathDirect(null); + }); + + it("sets path to null when projectPath is null", () => { + setStoragePath(null); + const path = getStoragePath(); + expect(path).toContain(".codex"); + }); + + it("sets path to null when no project root found", () => { + setStoragePath("/nonexistent/path/that/does/not/exist"); + const path = getStoragePath(); + expect(path).toContain(".codex"); + }); + + it("sets project-scoped path under global .codex when project root found", () => { + setStoragePath(process.cwd()); + const path = getStoragePath(); + expect(path).toContain("openai-codex-accounts.json"); + expect(path).toContain(".codex"); + expect(path).toContain("projects"); + }); + + it("uses the same storage path for main repo and linked worktree", async () => { + const testWorkDir = join( + tmpdir(), + "codex-worktree-key-" + Math.random().toString(36).slice(2), + ); + const fakeHome = join(testWorkDir, "home"); + const mainRepo = join(testWorkDir, "repo-main"); + const mainGitDir = join(mainRepo, ".git"); + const worktreeRepo = join(testWorkDir, "repo-pr-8"); + const worktreeGitDir = join(mainGitDir, "worktrees", "repo-pr-8"); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + await fs.mkdir(mainGitDir, { recursive: true }); + await fs.mkdir(worktreeGitDir, { recursive: true }); + await fs.mkdir(worktreeRepo, { recursive: true }); + await fs.writeFile( + join(worktreeRepo, ".git"), + `gitdir: ${worktreeGitDir}\n`, + "utf-8", + ); + await fs.writeFile( + join(worktreeGitDir, "commondir"), + "../..\n", + "utf-8", + ); + await fs.writeFile( + join(worktreeGitDir, "gitdir"), + `${join(worktreeRepo, ".git")}\n`, + "utf-8", + ); + + setStoragePath(mainRepo); + const mainPath = getStoragePath(); + setStoragePath(worktreeRepo); + const worktreePath = getStoragePath(); + expect(worktreePath).toBe(mainPath); + } finally { + setStoragePathDirect(null); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + await fs.rm(testWorkDir, { recursive: true, force: true }); + } + }); + }); + + describe("getStoragePath", () => { + afterEach(() => { + setStoragePathDirect(null); + }); + + it("returns custom path when set directly", () => { + setStoragePathDirect("/custom/path/accounts.json"); + expect(getStoragePath()).toBe("/custom/path/accounts.json"); + }); + + it("returns global path when no custom path set", () => { + setStoragePathDirect(null); + const path = getStoragePath(); + expect(path).toContain("openai-codex-accounts.json"); + }); + }); + + describe("normalizeAccountStorage activeKey remapping", () => { + it("remaps activeIndex using activeKey when present", () => { + const now = Date.now(); + const data = { + version: 3, + activeIndex: 0, + accounts: [ + { refreshToken: "t1", accountId: "A", lastUsed: now }, + { refreshToken: "t2", accountId: "B", lastUsed: now - 100 }, + { refreshToken: "t3", accountId: "C", lastUsed: now - 200 }, + ], + }; + const result = normalizeAccountStorage(data); + expect(result).not.toBeNull(); + expect(result?.accounts).toHaveLength(3); + expect(result?.activeIndex).toBe(0); + }); + + it("remaps familyKey for activeIndexByFamily when indices change after dedup", () => { + const now = Date.now(); + const data = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { + codex: 2, + "gpt-5.x": 1, + }, + accounts: [ + { refreshToken: "t1", accountId: "A", lastUsed: now }, + { refreshToken: "t1", accountId: "A", lastUsed: now + 100 }, + { refreshToken: "t2", accountId: "B", lastUsed: now - 50 }, + ], + }; + const result = normalizeAccountStorage(data); + expect(result).not.toBeNull(); + expect(result?.accounts).toHaveLength(2); + expect(result?.activeIndexByFamily?.codex).toBeDefined(); + }); + }); + + describe("clearAccounts error handling", () => { + const testWorkDir = join( + tmpdir(), + "codex-clear-err-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("logs but does not throw on non-ENOENT errors", async () => { + const readOnlyDir = join(testWorkDir, "readonly"); + await fs.mkdir(readOnlyDir, { recursive: true }); + const readOnlyFile = join(readOnlyDir, "accounts.json"); + await fs.writeFile(readOnlyFile, "{}"); + setStoragePathDirect(readOnlyFile); + + await expect(clearAccounts()).resolves.not.toThrow(); + }); + }); + + describe("StorageError with cause", () => { + it("preserves the original error as cause", () => { + const originalError = new Error("Original error"); + const storageErr = new StorageError( + "Wrapper message", + "EACCES", + "/path/to/file", + "Permission hint", + originalError, + ); + expect((storageErr as unknown as { cause?: Error }).cause).toBe( + originalError, + ); + }); + + it("works without cause parameter", () => { + const storageErr = new StorageError( + "Wrapper message", + "EACCES", + "/path/to/file", + "Permission hint", + ); + expect( + (storageErr as unknown as { cause?: Error }).cause, + ).toBeUndefined(); + }); + }); + + describe("ensureGitignore edge cases", () => { + const testWorkDir = join( + tmpdir(), + "codex-gitignore-" + Math.random().toString(36).slice(2), + ); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + }); + + afterEach(async () => { + setStoragePathDirect(null); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("writes .gitignore in project root when storage path is externalized", async () => { + const fakeHome = join(testWorkDir, "home"); + const projectDir = join(testWorkDir, "project-externalized"); + const gitDir = join(projectDir, ".git"); + const gitignorePath = join(projectDir, ".gitignore"); + + await fs.mkdir(fakeHome, { recursive: true }); + await fs.mkdir(gitDir, { recursive: true }); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + setStoragePath(projectDir); + + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "t1", + accountId: "A", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }; + + await saveAccounts(storage); + + expect(existsSync(gitignorePath)).toBe(true); + const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); + expect(gitignoreContent).toContain(".codex/"); + expect(getStoragePath()).toContain( + join(fakeHome, ".codex", "multi-auth", "projects"), + ); + }); + + it("creates .gitignore when it does not exist but .git dir exists (line 99-100 false branch)", async () => { + const projectDir = join(testWorkDir, "project"); + const codexDir = join(projectDir, ".codex"); + const gitDir = join(projectDir, ".git"); + const gitignorePath = join(projectDir, ".gitignore"); + + await fs.mkdir(codexDir, { recursive: true }); + await fs.mkdir(gitDir, { recursive: true }); + + testStoragePath = join(codexDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "t1", + accountId: "A", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }; + + await saveAccounts(storage); + + expect(existsSync(gitignorePath)).toBe(true); + const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); + expect(gitignoreContent).toContain(".codex/"); + }); + + it("appends to existing .gitignore without trailing newline (line 107 coverage)", async () => { + const projectDir = join(testWorkDir, "project2"); + const codexDir = join(projectDir, ".codex"); + const gitDir = join(projectDir, ".git"); + const gitignorePath = join(projectDir, ".gitignore"); + + await fs.mkdir(codexDir, { recursive: true }); + await fs.mkdir(gitDir, { recursive: true }); + await fs.writeFile(gitignorePath, "node_modules", "utf-8"); + + testStoragePath = join(codexDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "t1", + accountId: "A", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }; + + await saveAccounts(storage); + + const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); + expect(gitignoreContent).toBe("node_modules\n.codex/\n"); + }); + }); + + describe("legacy project storage migration", () => { + const testWorkDir = join( + tmpdir(), + "codex-legacy-migration-" + Math.random().toString(36).slice(2), + ); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + + afterEach(async () => { + setStoragePathDirect(null); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("removes legacy project storage file after successful migration", async () => { + const fakeHome = join(testWorkDir, "home"); + const projectDir = join(testWorkDir, "project"); + const projectGitDir = join(projectDir, ".git"); + const legacyProjectConfigDir = join(projectDir, ".codex"); + const legacyStoragePath = join( + legacyProjectConfigDir, + "openai-codex-accounts.json", + ); + + await fs.mkdir(fakeHome, { recursive: true }); + await fs.mkdir(projectGitDir, { recursive: true }); + await fs.mkdir(legacyProjectConfigDir, { recursive: true }); + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + setStoragePath(projectDir); + + const legacyStorage = { + version: 3, + activeIndex: 0, + accounts: [ + { + refreshToken: "legacy-refresh", + accountId: "legacy-account", + addedAt: 1, + lastUsed: 1, + }, + ], + }; + await fs.writeFile( + legacyStoragePath, + JSON.stringify(legacyStorage), + "utf-8", + ); + + const migrated = await loadAccounts(); + + expect(migrated).not.toBeNull(); + expect(migrated?.accounts).toHaveLength(1); + expect(existsSync(legacyStoragePath)).toBe(false); + expect(existsSync(getStoragePath())).toBe(true); + }); + }); + + describe("worktree-scoped storage migration", () => { + const testWorkDir = join( + tmpdir(), + "codex-worktree-migration-" + Math.random().toString(36).slice(2), + ); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalMultiAuthDir = process.env.CODEX_MULTI_AUTH_DIR; + + type StoredAccountFixture = { + refreshToken: string; + accountId: string; + addedAt: number; + lastUsed: number; + }; + + const now = Date.now(); + const accountFromLegacy: StoredAccountFixture = { + refreshToken: "legacy-refresh", + accountId: "legacy-account", + addedAt: now, + lastUsed: now, + }; + const accountFromCanonical: StoredAccountFixture = { + refreshToken: "canonical-refresh", + accountId: "canonical-account", + addedAt: now + 1, + lastUsed: now + 1, + }; + + async function prepareWorktreeFixture(options?: { + pointerStyle?: "default" | "windows"; + worktreeName?: string; + }): Promise<{ + fakeHome: string; + mainRepo: string; + worktreeRepo: string; + }> { + const fakeHome = join(testWorkDir, "home"); + const mainRepo = join(testWorkDir, "repo-main"); + const worktreeName = options?.worktreeName ?? "repo-pr-8"; + const worktreeRepo = join(testWorkDir, worktreeName); + const mainGitDir = join(mainRepo, ".git"); + const worktreeGitDir = join(mainGitDir, "worktrees", worktreeName); + + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + process.env.CODEX_MULTI_AUTH_DIR = join(fakeHome, ".codex", "multi-auth"); + + await fs.mkdir(mainGitDir, { recursive: true }); + await fs.mkdir(worktreeGitDir, { recursive: true }); + await fs.mkdir(worktreeRepo, { recursive: true }); + if (options?.pointerStyle === "windows") { + const winGitDirPointer = worktreeGitDir.replace(/\//g, "\\"); + await fs.writeFile( + join(worktreeRepo, ".git"), + `gitdir: ${winGitDirPointer}\n`, + "utf-8", + ); + await fs.writeFile( + join(worktreeGitDir, "commondir"), + "..\\..\\\n", + "utf-8", + ); + await fs.writeFile( + join(worktreeGitDir, "gitdir"), + `${join(worktreeRepo, ".git").replace(/\//g, "\\")}\n`, + "utf-8", + ); + } else { + await fs.writeFile( + join(worktreeRepo, ".git"), + `gitdir: ${worktreeGitDir}\n`, + "utf-8", + ); + await fs.writeFile( + join(worktreeGitDir, "commondir"), + "../..\n", + "utf-8", + ); + await fs.writeFile( + join(worktreeGitDir, "gitdir"), + `${join(worktreeRepo, ".git")}\n`, + "utf-8", + ); + } + + return { fakeHome, mainRepo, worktreeRepo }; + } + + function buildStorage(accounts: StoredAccountFixture[]) { + return { + version: 3 as const, + activeIndex: 0, + activeIndexByFamily: {}, + accounts, + }; + } + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + }); + + afterEach(async () => { + setStoragePathDirect(null); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalMultiAuthDir === undefined) + delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = originalMultiAuthDir; + await fs.rm(testWorkDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("migrates worktree-keyed storage to repo-shared canonical path", async () => { + const { worktreeRepo } = await prepareWorktreeFixture(); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + expect(legacyWorktreePath).not.toBe(canonicalPath); + + await fs.mkdir( + join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), + { + recursive: true, + }, + ); + await fs.writeFile( + legacyWorktreePath, + JSON.stringify(buildStorage([accountFromLegacy]), null, 2), + "utf-8", + ); + + const loaded = await loadAccounts(); + + expect(loaded).not.toBeNull(); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); + expect(existsSync(canonicalPath)).toBe(true); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + + it("merges canonical and legacy worktree storage when both exist", async () => { + const { worktreeRepo } = await prepareWorktreeFixture(); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + await fs.mkdir( + join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), + { + recursive: true, + }, + ); + await fs.mkdir( + join( + getConfigDir(), + "projects", + getProjectStorageKey(join(testWorkDir, "repo-main")), + ), + { + recursive: true, + }, + ); + + await fs.writeFile( + canonicalPath, + JSON.stringify(buildStorage([accountFromCanonical]), null, 2), + "utf-8", + ); + await fs.writeFile( + legacyWorktreePath, + JSON.stringify(buildStorage([accountFromLegacy]), null, 2), + "utf-8", + ); + + const loaded = await loadAccounts(); + + expect(loaded).not.toBeNull(); + expect(loaded?.accounts).toHaveLength(2); + const accountIds = + loaded?.accounts.map((account) => account.accountId) ?? []; + expect(accountIds).toContain("canonical-account"); + expect(accountIds).toContain("legacy-account"); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + + it("keeps legacy worktree file when migration persist fails", async () => { + const { worktreeRepo } = await prepareWorktreeFixture(); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const canonicalWalPath = `${canonicalPath}.wal`; + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + await fs.mkdir( + join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), + { + recursive: true, + }, + ); + await fs.writeFile( + legacyWorktreePath, + JSON.stringify(buildStorage([accountFromLegacy]), null, 2), + "utf-8", + ); + + const originalWriteFile = fs.writeFile.bind(fs); + const writeSpy = vi + .spyOn(fs, "writeFile") + .mockImplementation( + async (...args: Parameters) => { + const [targetPath] = args; + if ( + typeof targetPath === "string" && + targetPath === canonicalWalPath + ) { + const error = new Error( + "forced write failure", + ) as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return originalWriteFile(...args); + }, + ); + + const loaded = await loadAccounts(); + + writeSpy.mockRestore(); + expect(loaded).not.toBeNull(); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); + expect(existsSync(legacyWorktreePath)).toBe(true); + }); + + it("handles concurrent loadAccounts migration without duplicate race artifacts", async () => { + const { worktreeRepo } = await prepareWorktreeFixture({ + worktreeName: "repo-pr-race", + }); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + await fs.mkdir( + join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), + { + recursive: true, + }, + ); + await fs.mkdir(dirname(canonicalPath), { recursive: true }); + await fs.writeFile( + canonicalPath, + JSON.stringify(buildStorage([accountFromCanonical]), null, 2), + "utf-8", + ); + await fs.writeFile( + legacyWorktreePath, + JSON.stringify(buildStorage([accountFromLegacy]), null, 2), + "utf-8", + ); + + const results = await Promise.all([ + loadAccounts(), + loadAccounts(), + loadAccounts(), + loadAccounts(), + ]); + + for (const result of results) { + expect(result).not.toBeNull(); + expect(result?.accounts).toHaveLength(2); + } + + const persistedRaw = await fs.readFile(canonicalPath, "utf-8"); + const persistedNormalized = normalizeAccountStorage( + JSON.parse(persistedRaw) as unknown, + ); + expect(persistedNormalized).not.toBeNull(); + expect(persistedNormalized?.accounts).toHaveLength(2); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + + it("migrates worktree storage with Windows-style gitdir pointer fixtures", async () => { + const { worktreeRepo } = await prepareWorktreeFixture({ + pointerStyle: "windows", + worktreeName: "repo-pr-win-ptr", + }); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + expect(legacyWorktreePath).not.toBe(canonicalPath); + + await fs.mkdir( + join(getConfigDir(), "projects", getProjectStorageKey(worktreeRepo)), + { + recursive: true, + }, + ); + await fs.writeFile( + legacyWorktreePath, + JSON.stringify(buildStorage([accountFromLegacy]), null, 2), + "utf-8", + ); + + const loaded = await loadAccounts(); + + expect(loaded).not.toBeNull(); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); + expect(existsSync(canonicalPath)).toBe(true); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + + it("rejects forged commondir aliasing and keeps storage scoped to the current worktree", async () => { + const worktreeName = "repo-pr-hostile"; + const { mainRepo, worktreeRepo } = await prepareWorktreeFixture({ + worktreeName, + }); + const worktreeGitDir = join(mainRepo, ".git", "worktrees", worktreeName); + const foreignRepo = join(testWorkDir, "repo-foreign"); + const foreignGitDir = join(foreignRepo, ".git"); + const foreignAccount: StoredAccountFixture = { + refreshToken: "foreign-refresh", + accountId: "foreign-account", + addedAt: now + 2, + lastUsed: now + 2, + }; + + await fs.mkdir(foreignGitDir, { recursive: true }); + await fs.writeFile( + join(worktreeGitDir, "commondir"), + `${foreignGitDir}\n`, + "utf-8", + ); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const safeCanonicalPath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + const foreignCanonicalPath = join( + getConfigDir(), + "projects", + getProjectStorageKey(foreignRepo), + "openai-codex-accounts.json", + ); + await fs.mkdir(dirname(safeCanonicalPath), { recursive: true }); + await fs.mkdir(dirname(foreignCanonicalPath), { recursive: true }); + await fs.writeFile( + safeCanonicalPath, + JSON.stringify(buildStorage([accountFromLegacy]), null, 2), + "utf-8", + ); + await fs.writeFile( + foreignCanonicalPath, + JSON.stringify(buildStorage([foreignAccount]), null, 2), + "utf-8", + ); + + const loaded = await loadAccounts(); + + expect(canonicalPath).toBe(safeCanonicalPath); + expect(canonicalPath).not.toBe(foreignCanonicalPath); + expect(loaded).not.toBeNull(); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("legacy-account"); + expect(existsSync(canonicalPath)).toBe(true); + + const foreignRaw = await fs.readFile(foreignCanonicalPath, "utf-8"); + const foreignStorage = normalizeAccountStorage( + JSON.parse(foreignRaw) as unknown, + ); + expect(foreignStorage?.accounts[0]?.accountId).toBe("foreign-account"); + }); + }); + + describe("saveAccounts EPERM/EBUSY retry logic", () => { + const testWorkDir = join( + tmpdir(), + "codex-retry-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + vi.useRealTimers(); + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it("retries on EPERM and succeeds on second attempt", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount === 1) { + const err = new Error("EPERM error") as NodeJS.ErrnoException; + err.code = "EPERM"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + + renameSpy.mockRestore(); + }); + + it("retries on EBUSY and succeeds on third attempt", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount <= 2) { + const err = new Error("EBUSY error") as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(3); + expect(existsSync(testStoragePath)).toBe(true); + + renameSpy.mockRestore(); + }); + + it("throws after 5 failed EPERM retries", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + let attemptCount = 0; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async () => { + attemptCount++; + const err = new Error("EPERM error") as NodeJS.ErrnoException; + err.code = "EPERM"; + throw err; + }); + + await expect(saveAccounts(storage)).rejects.toThrow( + "Failed to save accounts", + ); + expect(attemptCount).toBe(5); + + renameSpy.mockRestore(); + }); + + it("throws immediately on non-EPERM/EBUSY errors", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + let attemptCount = 0; + const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async () => { + attemptCount++; + const err = new Error("EACCES error") as NodeJS.ErrnoException; + err.code = "EACCES"; + throw err; + }); + + await expect(saveAccounts(storage)).rejects.toThrow( + "Failed to save accounts", + ); + expect(attemptCount).toBe(1); + + renameSpy.mockRestore(); + }); + + it("throws when temp file is written with size 0", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + const statSpy = vi.spyOn(fs, "stat").mockResolvedValue({ + size: 0, + isFile: () => true, + isDirectory: () => false, + } as unknown as Awaited>); + + await expect(saveAccounts(storage)).rejects.toThrow( + "Failed to save accounts", + ); + expect(statSpy).toHaveBeenCalled(); + + statSpy.mockRestore(); + }); + + it("retries backup copyFile on transient EBUSY and succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalCopy = fs.copyFile.bind(fs); + let copyAttempts = 0; + const copySpy = vi + .spyOn(fs, "copyFile") + .mockImplementation(async (src, dest) => { + copyAttempts += 1; + if (copyAttempts === 1) { + const err = new Error("EBUSY copy") as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + } + return originalCopy(src as string, dest as string); + }); + try { + await saveAccounts({ + ...storage, + accounts: [ + { refreshToken: "token-next", addedAt: now, lastUsed: now }, + ], + }); + + expect(copyAttempts).toBe(2); + } finally { + copySpy.mockRestore(); + } + }); + + it("retries backup copyFile on transient EPERM and succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalCopy = fs.copyFile.bind(fs); + let copyAttempts = 0; + const copySpy = vi + .spyOn(fs, "copyFile") + .mockImplementation(async (src, dest) => { + copyAttempts += 1; + if (copyAttempts === 1) { + const err = new Error("EPERM copy") as NodeJS.ErrnoException; + err.code = "EPERM"; + throw err; + } + return originalCopy(src as string, dest as string); + }); + try { + await saveAccounts({ + ...storage, + accounts: [ + { refreshToken: "token-next", addedAt: now, lastUsed: now }, + ], + }); + + expect(copyAttempts).toBe(2); + } finally { + copySpy.mockRestore(); + } + }); + + it("retries staged backup rename on transient EBUSY and succeeds", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + + // Seed a primary file so backup creation path runs on next save. + await saveAccounts(storage); + + const originalRename = fs.rename.bind(fs); + let stagedRenameAttempts = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + const sourcePath = String(oldPath); + if (sourcePath.includes(".rotate.")) { + stagedRenameAttempts += 1; + if (stagedRenameAttempts === 1) { + const err = new Error( + "EBUSY staged rename", + ) as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + } + } + return originalRename(oldPath as string, newPath as string); + }); + try { + await saveAccounts({ + ...storage, + accounts: [ + { refreshToken: "token-next", addedAt: now + 1, lastUsed: now + 1 }, + ], + }); + + expect(stagedRenameAttempts).toBe(2); + const latestBackup = JSON.parse( + await fs.readFile(`${storagePath}.bak`, "utf-8"), + ) as { + accounts?: Array<{ refreshToken?: string }>; + }; + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token"); + } finally { + renameSpy.mockRestore(); + } + }); + + it("rotates backups and retains historical snapshots", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }, + ], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }, + ], + }); + + const latestBackupRaw = await fs.readFile(`${storagePath}.bak`, "utf-8"); + const historicalBackupRaw = await fs.readFile( + `${storagePath}.bak.1`, + "utf-8", + ); + const oldestBackupRaw = await fs.readFile( + `${storagePath}.bak.2`, + "utf-8", + ); + const latestBackup = JSON.parse(latestBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse(historicalBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse(oldestBackupRaw) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); + + it("preserves historical backups when creating the latest backup fails", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-2", addedAt: now + 1, lastUsed: now + 1 }, + ], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-3", addedAt: now + 2, lastUsed: now + 2 }, + ], + }); + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-4", addedAt: now + 3, lastUsed: now + 3 }, + ], + }); + + const originalCopy = fs.copyFile.bind(fs); + const copySpy = vi + .spyOn(fs, "copyFile") + .mockImplementation(async (src, dest) => { + if (src === storagePath) { + const err = new Error( + "ENOSPC backup copy", + ) as NodeJS.ErrnoException; + err.code = "ENOSPC"; + throw err; + } + return originalCopy(src as string, dest as string); + }); + try { + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-5", addedAt: now + 4, lastUsed: now + 4 }, + ], + }); + } finally { + copySpy.mockRestore(); + } + + const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const latestBackup = JSON.parse( + await fs.readFile(`${storagePath}.bak`, "utf-8"), + ) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse( + await fs.readFile(`${storagePath}.bak.1`, "utf-8"), + ) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse( + await fs.readFile(`${storagePath}.bak.2`, "utf-8"), + ) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(primary.accounts?.[0]?.refreshToken).toBe("token-5"); + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); + + it("keeps rotating backup order deterministic across parallel saves", async () => { + const now = Date.now(); + const storagePath = getStoragePath(); + + await saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-0", addedAt: now, lastUsed: now }], + }); + + await Promise.all([ + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-1", addedAt: now + 1, lastUsed: now + 1 }, + ], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-2", addedAt: now + 2, lastUsed: now + 2 }, + ], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-3", addedAt: now + 3, lastUsed: now + 3 }, + ], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [ + { refreshToken: "token-4", addedAt: now + 4, lastUsed: now + 4 }, + ], + }), + ]); + + const latestBackup = JSON.parse( + await fs.readFile(`${storagePath}.bak`, "utf-8"), + ) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const primary = JSON.parse(await fs.readFile(storagePath, "utf-8")) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const historicalBackup = JSON.parse( + await fs.readFile(`${storagePath}.bak.1`, "utf-8"), + ) as { + accounts?: Array<{ refreshToken?: string }>; + }; + const oldestBackup = JSON.parse( + await fs.readFile(`${storagePath}.bak.2`, "utf-8"), + ) as { + accounts?: Array<{ refreshToken?: string }>; + }; + + expect(primary.accounts?.[0]?.refreshToken).toBe("token-4"); + expect(latestBackup.accounts?.[0]?.refreshToken).toBe("token-3"); + expect(historicalBackup.accounts?.[0]?.refreshToken).toBe("token-2"); + expect(oldestBackup.accounts?.[0]?.refreshToken).toBe("token-1"); + }); + }); + + describe("clearAccounts edge cases", () => { + it("removes primary, backup, and wal artifacts", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now, lastUsed: now }], + }; + + const storagePath = getStoragePath(); + await saveAccounts(storage); + await fs.writeFile( + `${storagePath}.bak`, + JSON.stringify(storage), + "utf-8", + ); + await fs.writeFile( + `${storagePath}.bak.1`, + JSON.stringify(storage), + "utf-8", + ); + await fs.writeFile( + `${storagePath}.bak.2`, + JSON.stringify(storage), + "utf-8", + ); + await fs.writeFile( + `${storagePath}.wal`, + JSON.stringify(storage), + "utf-8", + ); + + expect(existsSync(storagePath)).toBe(true); + expect(existsSync(`${storagePath}.bak`)).toBe(true); + expect(existsSync(`${storagePath}.bak.1`)).toBe(true); + expect(existsSync(`${storagePath}.bak.2`)).toBe(true); + expect(existsSync(`${storagePath}.wal`)).toBe(true); + + await clearAccounts(); + + expect(existsSync(storagePath)).toBe(false); + expect(existsSync(`${storagePath}.bak`)).toBe(false); + expect(existsSync(`${storagePath}.bak.1`)).toBe(false); + expect(existsSync(`${storagePath}.bak.2`)).toBe(false); + expect(existsSync(`${storagePath}.wal`)).toBe(false); + }); + + it("logs error for non-ENOENT errors during clear", async () => { + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockRejectedValue( + Object.assign(new Error("EACCES error"), { code: "EACCES" }), + ); + + await clearAccounts(); + + expect(unlinkSpy).toHaveBeenCalled(); + unlinkSpy.mockRestore(); + }); + }); + + describe("clearQuotaCache", () => { + const tmpRoot = join( + tmpdir(), + `quota-cache-test-${Math.random().toString(36).slice(2)}`, + ); + let originalDir: string | undefined; + + beforeEach(async () => { + originalDir = process.env.CODEX_MULTI_AUTH_DIR; + process.env.CODEX_MULTI_AUTH_DIR = tmpRoot; + await fs.mkdir(tmpRoot, { recursive: true }); + }); + + afterEach(async () => { + if (originalDir === undefined) delete process.env.CODEX_MULTI_AUTH_DIR; + else process.env.CODEX_MULTI_AUTH_DIR = originalDir; + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + it("removes only the quota cache file", async () => { + const quotaPath = getQuotaCachePath(); + const accountsPath = join(tmpRoot, "openai-codex-accounts.json"); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + await fs.writeFile(accountsPath, "{}", "utf-8"); + + expect(existsSync(quotaPath)).toBe(true); + expect(existsSync(accountsPath)).toBe(true); + + await clearQuotaCache(); + + expect(existsSync(quotaPath)).toBe(false); + expect(existsSync(accountsPath)).toBe(true); + }); + + it("ignores missing quota cache file", async () => { + await expect(clearQuotaCache()).resolves.not.toThrow(); + }); + }); }); - From 617f984f857772be5e223f70832fd693802f0128 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 09:58:54 +0800 Subject: [PATCH 02/11] fix(auth): require fallback confirmation for destructive actions --- docs/troubleshooting.md | 8 + lib/cli.ts | 8 + test/cli.test.ts | 665 +++++++++++++++++++++++----------------- 3 files changed, 396 insertions(+), 285 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fbbcf1cc..0c0bbe86 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -109,22 +109,30 @@ Delete saved accounts only: ```powershell Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue ``` ```bash rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* ``` Reset local state (also clears flagged/problem accounts and quota cache; preserves settings and Codex CLI sync state): ```powershell Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.wal" -Force -ErrorAction SilentlyContinue +Remove-Item "$HOME\.codex\multi-auth\openai-codex-accounts.json.bak*" -Force -ErrorAction SilentlyContinue Remove-Item "$HOME\.codex\multi-auth\openai-codex-flagged-accounts.json" -Force -ErrorAction SilentlyContinue Remove-Item "$HOME\.codex\multi-auth\quota-cache.json" -Force -ErrorAction SilentlyContinue ``` ```bash rm -f ~/.codex/multi-auth/openai-codex-accounts.json +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.wal +rm -f ~/.codex/multi-auth/openai-codex-accounts.json.bak* rm -f ~/.codex/multi-auth/openai-codex-flagged-accounts.json rm -f ~/.codex/multi-auth/quota-cache.json ``` diff --git a/lib/cli.ts b/lib/cli.ts index 59b66f77..54b99452 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -207,9 +207,17 @@ async function promptLoginModeFallback( normalized === "fresh" || normalized === "clear" ) { + if (!(await promptDeleteAllTypedConfirm())) { + console.log("\nDelete saved accounts cancelled.\n"); + continue; + } return { mode: "fresh", deleteAll: true }; } if (normalized === "r" || normalized === "reset") { + if (!(await promptResetTypedConfirm())) { + console.log("\nReset local state cancelled.\n"); + continue; + } return { mode: "reset" }; } if (normalized === "c" || normalized === "check") diff --git a/test/cli.test.ts b/test/cli.test.ts index 39f1b753..d4852134 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,274 +1,296 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createInterface } from "node:readline/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:readline/promises", () => ({ - createInterface: vi.fn(), + createInterface: vi.fn(), })); const mockRl = { - question: vi.fn(), - close: vi.fn(), + question: vi.fn(), + close: vi.fn(), }; describe("CLI Module", () => { - beforeEach(() => { - vi.resetModules(); - process.env.FORCE_INTERACTIVE_MODE = "1"; - mockRl.question.mockReset(); - mockRl.close.mockReset(); - vi.mocked(createInterface).mockReturnValue(mockRl as any); - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - delete process.env.FORCE_INTERACTIVE_MODE; - vi.restoreAllMocks(); - }); - - describe("promptAddAnotherAccount", () => { - it("returns true for 'y' input", async () => { - mockRl.question.mockResolvedValueOnce("y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns true for 'yes' input", async () => { - mockRl.question.mockResolvedValueOnce("yes"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(2); - - expect(result).toBe(true); - }); - - it("returns true for 'Y' input (case insensitive)", async () => { - mockRl.question.mockResolvedValueOnce("Y"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(true); - }); - - it("returns false for 'n' input", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for empty input", async () => { - mockRl.question.mockResolvedValueOnce(""); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("returns false for random input", async () => { - mockRl.question.mockResolvedValueOnce("maybe"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - const result = await promptAddAnotherAccount(1); - - expect(result).toBe(false); - }); - - it("includes current count in prompt", async () => { - mockRl.question.mockResolvedValueOnce("n"); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - await promptAddAnotherAccount(5); - - expect(mockRl.question).toHaveBeenCalledWith( - expect.stringContaining("5 added") - ); - }); - - it("always closes readline interface", async () => { - mockRl.question.mockRejectedValueOnce(new Error("test error")); - - const { promptAddAnotherAccount } = await import("../lib/cli.js"); - - await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); - expect(mockRl.close).toHaveBeenCalled(); - }); - }); - - describe("promptLoginMode", () => { - it("returns 'add' for 'a' input", async () => { - mockRl.question.mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([ - { index: 0, email: "test@example.com" }, - ]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns 'add' for 'add' input", async () => { - mockRl.question.mockResolvedValueOnce("add"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("returns 'forecast' for 'p' input", async () => { - mockRl.question.mockResolvedValueOnce("p"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "forecast" }); - }); - - it("returns 'fix' for 'x' input", async () => { - mockRl.question.mockResolvedValueOnce("x"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fix" }); - }); - - it("returns 'settings' for 's' input", async () => { - mockRl.question.mockResolvedValueOnce("s"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "settings" }); - }); - - it("returns 'fresh' for 'f' input", async () => { - mockRl.question.mockResolvedValueOnce("f"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'fresh' for 'fresh' input", async () => { - mockRl.question.mockResolvedValueOnce("fresh"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "fresh", deleteAll: true }); - }); - - it("returns 'verify-flagged' for 'g' input", async () => { - mockRl.question.mockResolvedValueOnce("g"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "verify-flagged" }); - }); - - it("accepts uppercase quick shortcuts for advanced actions", async () => { - const { promptLoginMode } = await import("../lib/cli.js"); - - mockRl.question.mockResolvedValueOnce("P"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "forecast" }); - - mockRl.question.mockResolvedValueOnce("X"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "fix" }); - - mockRl.question.mockResolvedValueOnce("S"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "settings" }); - - mockRl.question.mockResolvedValueOnce("G"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "verify-flagged" }); - }); - - it("is case insensitive", async () => { - mockRl.question.mockResolvedValueOnce("A"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - }); - - it("re-prompts on invalid input then accepts valid", async () => { - mockRl.question - .mockResolvedValueOnce("invalid") - .mockResolvedValueOnce("zzz") - .mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); - expect(mockRl.question).toHaveBeenCalledTimes(3); - }); - - it("displays account list with email", async () => { - mockRl.question.mockResolvedValueOnce("a"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, email: "user1@example.com" }, - { index: 1, email: "user2@example.com" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 account(s)")); - }); - - it("displays account with accountId suffix when no email", async () => { - mockRl.question.mockResolvedValueOnce("f"); - const consoleSpy = vi.spyOn(console, "log"); - - const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([ - { index: 0, accountId: "acc_1234567890" }, - ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/1\.\s*567890/)); - }); + beforeEach(() => { + vi.resetModules(); + process.env.FORCE_INTERACTIVE_MODE = "1"; + mockRl.question.mockReset(); + mockRl.close.mockReset(); + vi.mocked(createInterface).mockReturnValue(mockRl as any); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + delete process.env.FORCE_INTERACTIVE_MODE; + vi.restoreAllMocks(); + }); + + describe("promptAddAnotherAccount", () => { + it("returns true for 'y' input", async () => { + mockRl.question.mockResolvedValueOnce("y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns true for 'yes' input", async () => { + mockRl.question.mockResolvedValueOnce("yes"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(2); + + expect(result).toBe(true); + }); + + it("returns true for 'Y' input (case insensitive)", async () => { + mockRl.question.mockResolvedValueOnce("Y"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(true); + }); + + it("returns false for 'n' input", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for empty input", async () => { + mockRl.question.mockResolvedValueOnce(""); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("returns false for random input", async () => { + mockRl.question.mockResolvedValueOnce("maybe"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + const result = await promptAddAnotherAccount(1); + + expect(result).toBe(false); + }); + + it("includes current count in prompt", async () => { + mockRl.question.mockResolvedValueOnce("n"); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + await promptAddAnotherAccount(5); + + expect(mockRl.question).toHaveBeenCalledWith( + expect.stringContaining("5 added"), + ); + }); + + it("always closes readline interface", async () => { + mockRl.question.mockRejectedValueOnce(new Error("test error")); + + const { promptAddAnotherAccount } = await import("../lib/cli.js"); + + await expect(promptAddAnotherAccount(1)).rejects.toThrow("test error"); + expect(mockRl.close).toHaveBeenCalled(); + }); + }); + + describe("promptLoginMode", () => { + it("returns 'add' for 'a' input", async () => { + mockRl.question.mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([ + { index: 0, email: "test@example.com" }, + ]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("returns 'add' for 'add' input", async () => { + mockRl.question.mockResolvedValueOnce("add"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("returns 'forecast' for 'p' input", async () => { + mockRl.question.mockResolvedValueOnce("p"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "forecast" }); + }); + + it("returns 'fix' for 'x' input", async () => { + mockRl.question.mockResolvedValueOnce("x"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fix" }); + }); + + it("returns 'settings' for 's' input", async () => { + mockRl.question.mockResolvedValueOnce("s"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "settings" }); + }); + + it("returns 'fresh' for 'f' input", async () => { + mockRl.question + .mockResolvedValueOnce("f") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'fresh' for 'fresh' input", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("DELETE"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "fresh", deleteAll: true }); + }); + + it("returns 'verify-flagged' for 'g' input", async () => { + mockRl.question.mockResolvedValueOnce("g"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "verify-flagged" }); + }); + + it("accepts uppercase quick shortcuts for advanced actions", async () => { + const { promptLoginMode } = await import("../lib/cli.js"); + + mockRl.question.mockResolvedValueOnce("P"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "forecast", + }); + + mockRl.question.mockResolvedValueOnce("X"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "fix", + }); + + mockRl.question.mockResolvedValueOnce("S"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "settings", + }); + + mockRl.question.mockResolvedValueOnce("G"); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "verify-flagged", + }); + }); + + it("is case insensitive", async () => { + mockRl.question.mockResolvedValueOnce("A"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + }); + + it("re-prompts on invalid input then accepts valid", async () => { + mockRl.question + .mockResolvedValueOnce("invalid") + .mockResolvedValueOnce("zzz") + .mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(mockRl.question).toHaveBeenCalledTimes(3); + }); + + it("displays account list with email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([ + { index: 0, email: "user1@example.com" }, + { index: 1, email: "user2@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("2 account(s)"), + ); + }); + + it("displays account with accountId suffix when no email", async () => { + mockRl.question.mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + await promptLoginMode([{ index: 0, accountId: "acc_1234567890" }]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/1\.\s*567890/), + ); + }); it("displays plain Account N when no email or accountId", async () => { - mockRl.question.mockResolvedValueOnce("f"); + mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0 }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Account")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Account"), + ); }); it("displays label with email when both present", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); - await promptLoginMode([{ index: 0, accountLabel: "Work", email: "work@example.com" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringMatching(/Work.*work@example\.com/)); + await promptLoginMode([ + { index: 0, accountLabel: "Work", email: "work@example.com" }, + ]); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/Work.*work@example\.com/), + ); }); it("displays only label when no email", async () => { mockRl.question.mockResolvedValueOnce("a"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptLoginMode } = await import("../lib/cli.js"); await promptLoginMode([{ index: 0, accountLabel: "Personal" }]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("1. Personal")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("1. Personal"), + ); }); }); @@ -321,16 +343,32 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); - + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + try { const { isNonInteractiveMode } = await import("../lib/cli.js"); expect(isNonInteractiveMode()).toBe(false); } finally { - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); @@ -344,63 +382,63 @@ describe("CLI Module", () => { it("returns first candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("1"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.close).toHaveBeenCalled(); }); it("returns second candidate by selection", async () => { mockRl.question.mockResolvedValueOnce("2"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[1]); }); it("returns default on empty input", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); + expect(result).toEqual(candidates[1]); }); it("returns default on quit input", async () => { mockRl.question.mockResolvedValueOnce("q"); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); }); it("re-prompts on invalid selection", async () => { - mockRl.question - .mockResolvedValueOnce("99") - .mockResolvedValueOnce("1"); - + mockRl.question.mockResolvedValueOnce("99").mockResolvedValueOnce("1"); + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [{ accountId: "acc1", label: "Account 1" }]; const result = await promptAccountSelection(candidates); - + expect(result).toEqual(candidates[0]); expect(mockRl.question).toHaveBeenCalledTimes(2); }); @@ -408,51 +446,59 @@ describe("CLI Module", () => { it("displays custom title", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection( [{ accountId: "acc1", label: "Account 1" }], - { title: "Custom Title" } + { title: "Custom Title" }, + ); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Custom Title"), ); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Custom Title")); }); it("shows default marker for default candidates", async () => { mockRl.question.mockResolvedValueOnce("1"); const consoleSpy = vi.spyOn(console, "log"); - + const { promptAccountSelection } = await import("../lib/cli.js"); await promptAccountSelection([ { accountId: "acc1", label: "Account 1", isDefault: true }, ]); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("(default)")); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("(default)"), + ); }); it("clamps defaultIndex to valid range", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 999 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: 999, + }); + expect(result).toEqual(candidates[1]); }); it("handles negative defaultIndex", async () => { mockRl.question.mockResolvedValueOnce(""); - + const { promptAccountSelection } = await import("../lib/cli.js"); const candidates = [ { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: -5 }); - + const result = await promptAccountSelection(candidates, { + defaultIndex: -5, + }); + expect(result).toEqual(candidates[0]); }); }); @@ -485,7 +531,9 @@ describe("CLI Module", () => { { accountId: "acc1", label: "Account 1" }, { accountId: "acc2", label: "Account 2" }, ]; - const result = await promptAccountSelection(candidates, { defaultIndex: 1 }); + const result = await promptAccountSelection(candidates, { + defaultIndex: 1, + }); expect(result).toEqual(candidates[1]); }); }); @@ -494,13 +542,19 @@ describe("CLI Module", () => { const { promptLoginMode } = await import("../lib/cli.js"); mockRl.question.mockResolvedValueOnce("check"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "check", + }); mockRl.question.mockResolvedValueOnce("deep"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "deep-check" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "deep-check", + }); mockRl.question.mockResolvedValueOnce("quit"); - await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ mode: "cancel" }); + await expect(promptLoginMode([{ index: 0 }])).resolves.toEqual({ + mode: "cancel", + }); }); it("evaluates CODEX_TUI/CODEX_DESKTOP/TERM_PROGRAM/ELECTRON branches when TTY is true", async () => { @@ -508,8 +562,16 @@ describe("CLI Module", () => { const { stdin, stdout } = await import("node:process"); const origInputTTY = stdin.isTTY; const origOutputTTY = stdout.isTTY; - Object.defineProperty(stdin, "isTTY", { value: true, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: true, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); try { process.env.CODEX_TUI = "1"; @@ -536,9 +598,42 @@ describe("CLI Module", () => { delete process.env.CODEX_DESKTOP; delete process.env.TERM_PROGRAM; delete process.env.ELECTRON_RUN_AS_NODE; - Object.defineProperty(stdin, "isTTY", { value: origInputTTY, writable: true, configurable: true }); - Object.defineProperty(stdout, "isTTY", { value: origOutputTTY, writable: true, configurable: true }); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); } }); }); }); +it("cancels fallback delete-all when typed confirmation does not match", async () => { + vi.mocked(createInterface).mockReturnValue(mockRl as any); + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); +}); + +it("cancels fallback reset when typed confirmation does not match", async () => { + vi.mocked(createInterface).mockReturnValue(mockRl as any); + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); +}); From 7194c46cb30837605929ed2952d5beacdc3e4243 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 12:19:35 +0800 Subject: [PATCH 03/11] fix(auth): retry quota cache cleanup on lock --- lib/quota-cache.ts | 28 ++++++++++++++++++---------- test/storage.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/lib/quota-cache.ts b/lib/quota-cache.ts index 18a71b56..a23078a7 100644 --- a/lib/quota-cache.ts +++ b/lib/quota-cache.ts @@ -270,16 +270,24 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { * Deletes the on-disk quota cache file, ignoring missing files and logging non-ENOENT errors. */ export async function clearQuotaCache(): Promise { - try { - await fs.unlink(QUOTA_CACHE_PATH); - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code !== "ENOENT") { - logWarn( - `Failed to clear quota cache ${QUOTA_CACHE_LABEL}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.unlink(QUOTA_CACHE_PATH); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + return; + } + if (!isRetryableFsError(error) || attempt >= 4) { + logWarn( + `Failed to clear quota cache ${QUOTA_CACHE_LABEL}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return; + } + await sleep(10 * 2 ** attempt); } } } diff --git a/test/storage.test.ts b/test/storage.test.ts index 18e3e005..4317873b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2230,5 +2230,29 @@ describe("storage", () => { it("ignores missing quota cache file", async () => { await expect(clearQuotaCache()).resolves.not.toThrow(); }); + + it("retries transient EPERM when clearing the quota cache", async () => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const realUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath && unlinkSpy.mock.calls.length === 1) { + const err = new Error("locked") as NodeJS.ErrnoException; + err.code = "EPERM"; + throw err; + } + return realUnlink(target); + }); + + await clearQuotaCache(); + + expect(existsSync(quotaPath)).toBe(false); + expect(unlinkSpy).toHaveBeenCalledTimes(2); + unlinkSpy.mockRestore(); + }); }); }); From 70710f0254d936e95ba72b6109c742005597f72f Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 12:37:39 +0800 Subject: [PATCH 04/11] fix(auth): harden destructive action consistency --- index.ts | 19 +- lib/codex-manager.ts | 16 +- lib/destructive-actions.ts | 54 +- lib/quota-cache.ts | 10 +- lib/storage.ts | 1786 ++++++++++++++++++-------------- test/codex-manager-cli.test.ts | 12 + 6 files changed, 1087 insertions(+), 810 deletions(-) diff --git a/index.ts b/index.ts index 109f1cbe..26d4e7e6 100644 --- a/index.ts +++ b/index.ts @@ -3707,10 +3707,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (menuResult.mode === "fresh") { startFresh = true; if (menuResult.deleteAll) { - await deleteSavedAccounts(); + const result = await deleteSavedAccounts(); invalidateAccountManagerCache(); console.log( - `\n${DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed}\n`, + `\n${ + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts + .completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs." + }\n`, ); } break; @@ -3718,10 +3723,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (menuResult.mode === "reset") { startFresh = true; - await resetLocalState(); + const result = await resetLocalState(); invalidateAccountManagerCache(); console.log( - `\n${DESTRUCTIVE_ACTION_COPY.resetLocalState.completed}\n`, + `\n${ + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs." + }\n`, ); break; } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d967ffb8..80e27819 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4197,9 +4197,11 @@ async function runAuthLogin(): Promise { DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, async () => { - await deleteSavedAccounts(); + const result = await deleteSavedAccounts(); console.log( - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed, + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", ); }, displaySettings, @@ -4211,8 +4213,14 @@ async function runAuthLogin(): Promise { DESTRUCTIVE_ACTION_COPY.resetLocalState.label, DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, async () => { - await resetLocalState(); - console.log(DESTRUCTIVE_ACTION_COPY.resetLocalState.completed); + const result = await resetLocalState(); + console.log( + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", + ); }, displaySettings, ); diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index f8e436ef..e5cc6bfc 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -75,6 +75,12 @@ export interface DeleteAccountResult { removedFlaggedCount: number; } +export interface DestructiveActionResult { + accountsCleared: boolean; + flaggedCleared: boolean; + quotaCacheCleared: boolean; +} + export async function deleteAccountAtIndex(options: { storage: AccountStorageV3; index: number; @@ -82,12 +88,22 @@ export async function deleteAccountAtIndex(options: { }): Promise { const target = options.storage.accounts.at(options.index); if (!target) return null; + const flagged = options.flaggedStorage ?? (await loadFlaggedAccounts()); + const nextStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; + const previousStorage: AccountStorageV3 = { + ...options.storage, + accounts: options.storage.accounts.map((account) => ({ ...account })), + activeIndexByFamily: { ...(options.storage.activeIndexByFamily ?? {}) }, + }; - options.storage.accounts.splice(options.index, 1); - clampActiveIndices(options.storage); - await saveAccounts(options.storage); + nextStorage.accounts.splice(options.index, 1); + clampActiveIndices(nextStorage); + await saveAccounts(nextStorage); - const flagged = options.flaggedStorage ?? (await loadFlaggedAccounts()); const remainingFlagged = flagged.accounts.filter( (account) => account.refreshToken !== target.refreshToken, ); @@ -95,11 +111,16 @@ export async function deleteAccountAtIndex(options: { let updatedFlagged = flagged; if (removedFlaggedCount > 0) { updatedFlagged = { ...flagged, accounts: remainingFlagged }; - await saveFlaggedAccounts(updatedFlagged); + try { + await saveFlaggedAccounts(updatedFlagged); + } catch (error) { + await saveAccounts(previousStorage); + throw error; + } } return { - storage: options.storage, + storage: nextStorage, flagged: updatedFlagged, removedAccount: target, removedFlaggedCount, @@ -110,17 +131,26 @@ export async function deleteAccountAtIndex(options: { * Delete saved accounts without touching flagged/problem accounts, settings, or Codex CLI sync state. * Removes the accounts WAL and backups via the underlying storage helper. */ -export async function deleteSavedAccounts(): Promise { - await clearAccounts(); +export async function deleteSavedAccounts(): Promise { + return { + accountsCleared: await clearAccounts(), + flaggedCleared: true, + quotaCacheCleared: true, + }; } /** * Reset local multi-auth state: clears saved accounts, flagged/problem accounts, and quota cache. * Keeps unified settings and on-disk Codex CLI sync state; only the in-memory Codex CLI cache is cleared. */ -export async function resetLocalState(): Promise { - await clearAccounts(); - await clearFlaggedAccounts(); - await clearQuotaCache(); +export async function resetLocalState(): Promise { + const accountsCleared = await clearAccounts(); + const flaggedCleared = await clearFlaggedAccounts(); + const quotaCacheCleared = await clearQuotaCache(); clearCodexCliStateCache(); + return { + accountsCleared, + flaggedCleared, + quotaCacheCleared, + }; } diff --git a/lib/quota-cache.ts b/lib/quota-cache.ts index a23078a7..d69b4e0f 100644 --- a/lib/quota-cache.ts +++ b/lib/quota-cache.ts @@ -269,15 +269,15 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { /** * Deletes the on-disk quota cache file, ignoring missing files and logging non-ENOENT errors. */ -export async function clearQuotaCache(): Promise { +export async function clearQuotaCache(): Promise { for (let attempt = 0; attempt < 5; attempt += 1) { try { await fs.unlink(QUOTA_CACHE_PATH); - return; + return true; } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code; if (code === "ENOENT") { - return; + return true; } if (!isRetryableFsError(error) || attempt >= 4) { logWarn( @@ -285,9 +285,11 @@ export async function clearQuotaCache(): Promise { error instanceof Error ? error.message : String(error) }`, ); - return; + return false; } await sleep(10 * 2 ** attempt); } } + + return false; } diff --git a/lib/storage.ts b/lib/storage.ts index 3453a426..ddd681ed 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,29 +1,36 @@ -import { promises as fs, existsSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; import { createHash } from "node:crypto"; +import { existsSync, promises as fs } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; import { + type AccountMetadataV1, + type AccountMetadataV3, + type AccountStorageV1, + type AccountStorageV3, + type CooldownReason, + migrateV1ToV3, + type RateLimitStateV3, +} from "./storage/migrations.js"; +import { + findProjectRoot, getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, - findProjectRoot, resolvePath, resolveProjectStorageIdentityRoot, } from "./storage/paths.js"; -import { - migrateV1ToV3, - type CooldownReason, - type RateLimitStateV3, - type AccountMetadataV1, - type AccountStorageV1, - type AccountMetadataV3, - type AccountStorageV3, -} from "./storage/migrations.js"; -export type { CooldownReason, RateLimitStateV3, AccountMetadataV1, AccountStorageV1, AccountMetadataV3, AccountStorageV3 }; +export type { + CooldownReason, + RateLimitStateV3, + AccountMetadataV1, + AccountStorageV1, + AccountMetadataV3, + AccountStorageV3, +}; const log = createLogger("storage"); const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json"; @@ -53,72 +60,84 @@ export interface FlaggedAccountStorageV1 { * Custom error class for storage operations with platform-aware hints. */ export class StorageError extends Error { - readonly code: string; - readonly path: string; - readonly hint: string; - - constructor(message: string, code: string, path: string, hint: string, cause?: Error) { - super(message, { cause }); - this.name = "StorageError"; - this.code = code; - this.path = path; - this.hint = hint; - } + readonly code: string; + readonly path: string; + readonly hint: string; + + constructor( + message: string, + code: string, + path: string, + hint: string, + cause?: Error, + ) { + super(message, { cause }); + this.name = "StorageError"; + this.code = code; + this.path = path; + this.hint = hint; + } } /** * Generate platform-aware troubleshooting hint based on error code. */ export function formatStorageErrorHint(error: unknown, path: string): string { - const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; - const isWindows = process.platform === "win32"; - - switch (code) { - case "EACCES": - case "EPERM": - return isWindows - ? `Permission denied writing to ${path}. Check antivirus exclusions for this folder. Ensure you have write permissions.` - : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.codex`; - case "EBUSY": - return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`; - case "ENOSPC": - return `Disk is full. Free up space and try again. Path: ${path}`; - case "EEMPTY": - return `File written but is empty. This may indicate a disk or filesystem issue. Path: ${path}`; - default: - return isWindows - ? `Failed to write to ${path}. Check folder permissions and ensure path contains no special characters.` - : `Failed to write to ${path}. Check folder permissions and disk space.`; - } + const err = error as NodeJS.ErrnoException; + const code = err?.code || "UNKNOWN"; + const isWindows = process.platform === "win32"; + + switch (code) { + case "EACCES": + case "EPERM": + return isWindows + ? `Permission denied writing to ${path}. Check antivirus exclusions for this folder. Ensure you have write permissions.` + : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.codex`; + case "EBUSY": + return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`; + case "ENOSPC": + return `Disk is full. Free up space and try again. Path: ${path}`; + case "EEMPTY": + return `File written but is empty. This may indicate a disk or filesystem issue. Path: ${path}`; + default: + return isWindows + ? `Failed to write to ${path}. Check folder permissions and ensure path contains no special characters.` + : `Failed to write to ${path}. Check folder permissions and disk space.`; + } } let storageMutex: Promise = Promise.resolve(); function withStorageLock(fn: () => Promise): Promise { - const previousMutex = storageMutex; - let releaseLock: () => void; - storageMutex = new Promise((resolve) => { - releaseLock = resolve; - }); - return previousMutex.then(fn).finally(() => releaseLock()); + const previousMutex = storageMutex; + let releaseLock: () => void; + storageMutex = new Promise((resolve) => { + releaseLock = resolve; + }); + return previousMutex.then(fn).finally(() => releaseLock()); } type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { - accountId?: string; - email?: string; - refreshToken: string; - addedAt?: number; - lastUsed?: number; + accountId?: string; + email?: string; + refreshToken: string; + addedAt?: number; + lastUsed?: number; }; function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { - const email = typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; + const email = + typeof account.email === "string" ? account.email.trim().toLowerCase() : ""; const refreshToken = - typeof account.refreshToken === "string" ? account.refreshToken.trim().toLowerCase() : ""; - const accountId = typeof account.accountId === "string" ? account.accountId.trim().toLowerCase() : ""; + typeof account.refreshToken === "string" + ? account.refreshToken.trim().toLowerCase() + : ""; + const accountId = + typeof account.accountId === "string" + ? account.accountId.trim().toLowerCase() + : ""; if (!/^account\d+@example\.com$/.test(email)) { return false; } @@ -134,39 +153,51 @@ function looksLikeSyntheticFixtureAccount(account: AccountMetadataV3): boolean { return /^acc(_|-)?\d+$/.test(accountId); } -function looksLikeSyntheticFixtureStorage(storage: AccountStorageV3 | null): boolean { +function looksLikeSyntheticFixtureStorage( + storage: AccountStorageV3 | null, +): boolean { if (!storage || storage.accounts.length === 0) return false; - return storage.accounts.every((account) => looksLikeSyntheticFixtureAccount(account)); + return storage.accounts.every((account) => + looksLikeSyntheticFixtureAccount(account), + ); } async function ensureGitignore(storagePath: string): Promise { - if (!currentStoragePath) return; - - const configDir = dirname(storagePath); - const inferredProjectRoot = dirname(configDir); - const candidateRoots = [currentProjectRoot, inferredProjectRoot].filter( - (root): root is string => typeof root === "string" && root.length > 0, - ); - const projectRoot = candidateRoots.find((root) => existsSync(join(root, ".git"))); - if (!projectRoot) return; - const gitignorePath = join(projectRoot, ".gitignore"); - - try { - let content = ""; - if (existsSync(gitignorePath)) { - content = await fs.readFile(gitignorePath, "utf-8"); - const lines = content.split("\n").map((l) => l.trim()); - if (lines.includes(".codex") || lines.includes(".codex/") || lines.includes("/.codex") || lines.includes("/.codex/")) { - return; - } - } - - const newContent = content.endsWith("\n") || content === "" ? content : content + "\n"; - await fs.writeFile(gitignorePath, newContent + ".codex/\n", "utf-8"); - log.debug("Added .codex to .gitignore", { path: gitignorePath }); - } catch (error) { - log.warn("Failed to update .gitignore", { error: String(error) }); - } + if (!currentStoragePath) return; + + const configDir = dirname(storagePath); + const inferredProjectRoot = dirname(configDir); + const candidateRoots = [currentProjectRoot, inferredProjectRoot].filter( + (root): root is string => typeof root === "string" && root.length > 0, + ); + const projectRoot = candidateRoots.find((root) => + existsSync(join(root, ".git")), + ); + if (!projectRoot) return; + const gitignorePath = join(projectRoot, ".gitignore"); + + try { + let content = ""; + if (existsSync(gitignorePath)) { + content = await fs.readFile(gitignorePath, "utf-8"); + const lines = content.split("\n").map((l) => l.trim()); + if ( + lines.includes(".codex") || + lines.includes(".codex/") || + lines.includes("/.codex") || + lines.includes("/.codex/") + ) { + return; + } + } + + const newContent = + content.endsWith("\n") || content === "" ? content : content + "\n"; + await fs.writeFile(gitignorePath, newContent + ".codex/\n", "utf-8"); + log.debug("Added .codex to .gitignore", { path: gitignorePath }); + } catch (error) { + log.warn("Failed to update .gitignore", { error: String(error) }); + } } let currentStoragePath: string | null = null; @@ -197,7 +228,9 @@ function getAccountsBackupRecoveryCandidates(path: string): string[] { return candidates; } -async function getAccountsBackupRecoveryCandidatesWithDiscovery(path: string): Promise { +async function getAccountsBackupRecoveryCandidatesWithDiscovery( + path: string, +): Promise { const knownCandidates = getAccountsBackupRecoveryCandidates(path); const discoveredCandidates = new Set(); const candidatePrefix = `${basename(path)}.`; @@ -265,7 +298,10 @@ async function copyFileWithRetry( } } -async function renameFileWithRetry(sourcePath: string, destinationPath: string): Promise { +async function renameFileWithRetry( + sourcePath: string, + destinationPath: string, +): Promise { for (let attempt = 0; attempt < BACKUP_COPY_MAX_ATTEMPTS; attempt += 1) { try { await fs.rename(sourcePath, destinationPath); @@ -280,7 +316,10 @@ async function renameFileWithRetry(sourcePath: string, destinationPath: string): } const jitterMs = Math.floor(Math.random() * BACKUP_COPY_BASE_DELAY_MS); await new Promise((resolve) => - setTimeout(resolve, BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt + jitterMs), + setTimeout( + resolve, + BACKUP_COPY_BASE_DELAY_MS * 2 ** attempt + jitterMs, + ), ); } } @@ -301,7 +340,9 @@ async function createRotatingAccountsBackup(path: string): Promise { continue; } const stagedPath = buildStagedPath(currentPath, `slot-${i}`); - await copyFileWithRetry(previousPath, stagedPath, { allowMissingSource: true }); + await copyFileWithRetry(previousPath, stagedPath, { + allowMissingSource: true, + }); if (existsSync(stagedPath)) { stagedWrites.push({ targetPath: currentPath, stagedPath }); } @@ -314,7 +355,10 @@ async function createRotatingAccountsBackup(path: string): Promise { const latestStagedPath = buildStagedPath(latestBackupPath, "latest"); await copyFileWithRetry(path, latestStagedPath); if (existsSync(latestStagedPath)) { - stagedWrites.push({ targetPath: latestBackupPath, stagedPath: latestStagedPath }); + stagedWrites.push({ + targetPath: latestBackupPath, + stagedPath: latestStagedPath, + }); } for (const stagedWrite of stagedWrites) { @@ -334,9 +378,15 @@ async function createRotatingAccountsBackup(path: string): Promise { } } -function isRotatingBackupTempArtifact(storagePath: string, candidatePath: string): boolean { +function isRotatingBackupTempArtifact( + storagePath: string, + candidatePath: string, +): boolean { const backupPrefix = `${storagePath}${ACCOUNTS_BACKUP_SUFFIX}`; - if (!candidatePath.startsWith(backupPrefix) || !candidatePath.endsWith(".tmp")) { + if ( + !candidatePath.startsWith(backupPrefix) || + !candidatePath.endsWith(".tmp") + ) { return false; } @@ -354,10 +404,14 @@ function isRotatingBackupTempArtifact(storagePath: string, candidatePath: string return true; } -async function cleanupStaleRotatingBackupArtifacts(path: string): Promise { +async function cleanupStaleRotatingBackupArtifacts( + path: string, +): Promise { const directoryPath = dirname(path); try { - const directoryEntries = await fs.readdir(directoryPath, { withFileTypes: true }); + const directoryEntries = await fs.readdir(directoryPath, { + withFileTypes: true, + }); const staleArtifacts = directoryEntries .filter((entry) => entry.isFile()) .map((entry) => join(directoryPath, entry.name)) @@ -404,39 +458,47 @@ export function getLastAccountsSaveTimestamp(): number { } export function setStoragePath(projectPath: string | null): void { - if (!projectPath) { - currentStoragePath = null; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; - return; - } - - const projectRoot = findProjectRoot(projectPath); - if (projectRoot) { - currentProjectRoot = projectRoot; - const identityRoot = resolveProjectStorageIdentityRoot(projectRoot); - currentStoragePath = join(getProjectGlobalConfigDir(identityRoot), ACCOUNTS_FILE_NAME); - currentLegacyProjectStoragePath = join(getProjectConfigDir(projectRoot), ACCOUNTS_FILE_NAME); - const previousWorktreeScopedPath = join( - getProjectGlobalConfigDir(projectRoot), - ACCOUNTS_FILE_NAME, - ); - currentLegacyWorktreeStoragePath = - previousWorktreeScopedPath !== currentStoragePath ? previousWorktreeScopedPath : null; - } else { - currentStoragePath = null; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; - } + if (!projectPath) { + currentStoragePath = null; + currentLegacyProjectStoragePath = null; + currentLegacyWorktreeStoragePath = null; + currentProjectRoot = null; + return; + } + + const projectRoot = findProjectRoot(projectPath); + if (projectRoot) { + currentProjectRoot = projectRoot; + const identityRoot = resolveProjectStorageIdentityRoot(projectRoot); + currentStoragePath = join( + getProjectGlobalConfigDir(identityRoot), + ACCOUNTS_FILE_NAME, + ); + currentLegacyProjectStoragePath = join( + getProjectConfigDir(projectRoot), + ACCOUNTS_FILE_NAME, + ); + const previousWorktreeScopedPath = join( + getProjectGlobalConfigDir(projectRoot), + ACCOUNTS_FILE_NAME, + ); + currentLegacyWorktreeStoragePath = + previousWorktreeScopedPath !== currentStoragePath + ? previousWorktreeScopedPath + : null; + } else { + currentStoragePath = null; + currentLegacyProjectStoragePath = null; + currentLegacyWorktreeStoragePath = null; + currentProjectRoot = null; + } } export function setStoragePathDirect(path: string | null): void { - currentStoragePath = path; - currentLegacyProjectStoragePath = null; - currentLegacyWorktreeStoragePath = null; - currentProjectRoot = null; + currentStoragePath = path; + currentLegacyProjectStoragePath = null; + currentLegacyWorktreeStoragePath = null; + currentProjectRoot = null; } /** @@ -444,10 +506,10 @@ export function setStoragePathDirect(path: string | null): void { * @returns Absolute path to the accounts.json file */ export function getStoragePath(): string { - if (currentStoragePath) { - return currentStoragePath; - } - return join(getConfigDir(), ACCOUNTS_FILE_NAME); + if (currentStoragePath) { + return currentStoragePath; + } + return join(getConfigDir(), ACCOUNTS_FILE_NAME); } export function getFlaggedAccountsPath(): string { @@ -459,176 +521,196 @@ function getLegacyFlaggedAccountsPath(): string { } async function migrateLegacyProjectStorageIfNeeded( - persist: (storage: AccountStorageV3) => Promise = saveAccounts, + persist: (storage: AccountStorageV3) => Promise = saveAccounts, ): Promise { - if (!currentStoragePath) { - return null; - } - - const candidatePaths = [currentLegacyWorktreeStoragePath, currentLegacyProjectStoragePath] - .filter( - (path): path is string => typeof path === "string" && path.length > 0 && path !== currentStoragePath, - ) - .filter((path, index, all) => all.indexOf(path) === index); - - if (candidatePaths.length === 0) { - return null; - } - - const existingCandidatePaths = candidatePaths.filter((legacyPath) => existsSync(legacyPath)); - if (existingCandidatePaths.length === 0) { - return null; - } - - let targetStorage = await loadNormalizedStorageFromPath(currentStoragePath, "current account storage"); - let migrated = false; - - for (const legacyPath of existingCandidatePaths) { - const legacyStorage = await loadNormalizedStorageFromPath(legacyPath, "legacy account storage"); - if (!legacyStorage) { - continue; - } - - const mergedStorage = mergeStorageForMigration(targetStorage, legacyStorage); - const fallbackStorage = targetStorage ?? legacyStorage; - - try { - await persist(mergedStorage); - targetStorage = mergedStorage; - migrated = true; - } catch (error) { - targetStorage = fallbackStorage; - log.warn("Failed to persist migrated account storage", { - from: legacyPath, - to: currentStoragePath, - error: String(error), - }); - continue; - } - - try { - await fs.unlink(legacyPath); - log.info("Removed legacy account storage file after migration", { - path: legacyPath, - }); - } catch (unlinkError) { - const code = (unlinkError as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn("Failed to remove legacy account storage file after migration", { - path: legacyPath, - error: String(unlinkError), - }); - } - } - - log.info("Migrated legacy project account storage", { - from: legacyPath, - to: currentStoragePath, - accounts: mergedStorage.accounts.length, - }); - } - - if (migrated) { - return targetStorage; - } - if (targetStorage && !existsSync(currentStoragePath)) { - return targetStorage; - } - return null; + if (!currentStoragePath) { + return null; + } + + const candidatePaths = [ + currentLegacyWorktreeStoragePath, + currentLegacyProjectStoragePath, + ] + .filter( + (path): path is string => + typeof path === "string" && + path.length > 0 && + path !== currentStoragePath, + ) + .filter((path, index, all) => all.indexOf(path) === index); + + if (candidatePaths.length === 0) { + return null; + } + + const existingCandidatePaths = candidatePaths.filter((legacyPath) => + existsSync(legacyPath), + ); + if (existingCandidatePaths.length === 0) { + return null; + } + + let targetStorage = await loadNormalizedStorageFromPath( + currentStoragePath, + "current account storage", + ); + let migrated = false; + + for (const legacyPath of existingCandidatePaths) { + const legacyStorage = await loadNormalizedStorageFromPath( + legacyPath, + "legacy account storage", + ); + if (!legacyStorage) { + continue; + } + + const mergedStorage = mergeStorageForMigration( + targetStorage, + legacyStorage, + ); + const fallbackStorage = targetStorage ?? legacyStorage; + + try { + await persist(mergedStorage); + targetStorage = mergedStorage; + migrated = true; + } catch (error) { + targetStorage = fallbackStorage; + log.warn("Failed to persist migrated account storage", { + from: legacyPath, + to: currentStoragePath, + error: String(error), + }); + continue; + } + + try { + await fs.unlink(legacyPath); + log.info("Removed legacy account storage file after migration", { + path: legacyPath, + }); + } catch (unlinkError) { + const code = (unlinkError as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn( + "Failed to remove legacy account storage file after migration", + { + path: legacyPath, + error: String(unlinkError), + }, + ); + } + } + + log.info("Migrated legacy project account storage", { + from: legacyPath, + to: currentStoragePath, + accounts: mergedStorage.accounts.length, + }); + } + + if (migrated) { + return targetStorage; + } + if (targetStorage && !existsSync(currentStoragePath)) { + return targetStorage; + } + return null; } async function loadNormalizedStorageFromPath( - path: string, - label: string, + path: string, + label: string, ): Promise { - try { - const { normalized, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn(`${label} schema validation warnings`, { - path, - errors: schemaErrors.slice(0, 5), - }); - } - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.warn(`Failed to load ${label}`, { - path, - error: String(error), - }); - } - return null; - } + try { + const { normalized, schemaErrors } = await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn(`${label} schema validation warnings`, { + path, + errors: schemaErrors.slice(0, 5), + }); + } + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn(`Failed to load ${label}`, { + path, + error: String(error), + }); + } + return null; + } } function mergeStorageForMigration( - current: AccountStorageV3 | null, - incoming: AccountStorageV3, + current: AccountStorageV3 | null, + incoming: AccountStorageV3, ): AccountStorageV3 { - if (!current) { - return incoming; - } - - const merged = normalizeAccountStorage({ - version: 3, - activeIndex: current.activeIndex, - activeIndexByFamily: current.activeIndexByFamily, - accounts: [...current.accounts, ...incoming.accounts], - }); - if (!merged) { - return current; - } - return merged; + if (!current) { + return incoming; + } + + const merged = normalizeAccountStorage({ + version: 3, + activeIndex: current.activeIndex, + activeIndexByFamily: current.activeIndexByFamily, + accounts: [...current.accounts, ...incoming.accounts], + }); + if (!merged) { + return current; + } + return merged; } function selectNewestAccount( - current: T | undefined, - candidate: T, + current: T | undefined, + candidate: T, ): T { - if (!current) return candidate; - const currentLastUsed = current.lastUsed || 0; - const candidateLastUsed = candidate.lastUsed || 0; - if (candidateLastUsed > currentLastUsed) return candidate; - if (candidateLastUsed < currentLastUsed) return current; - const currentAddedAt = current.addedAt || 0; - const candidateAddedAt = candidate.addedAt || 0; - return candidateAddedAt >= currentAddedAt ? candidate : current; + if (!current) return candidate; + const currentLastUsed = current.lastUsed || 0; + const candidateLastUsed = candidate.lastUsed || 0; + if (candidateLastUsed > currentLastUsed) return candidate; + if (candidateLastUsed < currentLastUsed) return current; + const currentAddedAt = current.addedAt || 0; + const candidateAddedAt = candidate.addedAt || 0; + return candidateAddedAt >= currentAddedAt ? candidate : current; } function deduplicateAccountsByKey(accounts: T[]): T[] { - const keyToIndex = new Map(); - const indicesToKeep = new Set(); - - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account) continue; - const key = account.accountId || account.refreshToken; - if (!key) continue; - - const existingIndex = keyToIndex.get(key); - if (existingIndex === undefined) { - keyToIndex.set(key, i); - continue; - } - - const existing = accounts[existingIndex]; - const newest = selectNewestAccount(existing, account); - keyToIndex.set(key, newest === account ? i : existingIndex); - } - - for (const idx of keyToIndex.values()) { - indicesToKeep.add(idx); - } - - const result: T[] = []; - for (let i = 0; i < accounts.length; i += 1) { - if (indicesToKeep.has(i)) { - const account = accounts[i]; - if (account) result.push(account); - } - } - return result; + const keyToIndex = new Map(); + const indicesToKeep = new Set(); + + for (let i = 0; i < accounts.length; i += 1) { + const account = accounts[i]; + if (!account) continue; + const key = account.accountId || account.refreshToken; + if (!key) continue; + + const existingIndex = keyToIndex.get(key); + if (existingIndex === undefined) { + keyToIndex.set(key, i); + continue; + } + + const existing = accounts[existingIndex]; + const newest = selectNewestAccount(existing, account); + keyToIndex.set(key, newest === account ? i : existingIndex); + } + + for (const idx of keyToIndex.values()) { + indicesToKeep.add(idx); + } + + const result: T[] = []; + for (let i = 0; i < accounts.length; i += 1) { + if (indicesToKeep.has(i)) { + const account = accounts[i]; + if (account) result.push(account); + } + } + return result; } /** @@ -637,10 +719,15 @@ function deduplicateAccountsByKey(accounts: T[]): T[] { * @param accounts - Array of accounts to deduplicate * @returns New array with duplicates removed */ -export function deduplicateAccounts( - accounts: T[], -): T[] { - return deduplicateAccountsByKey(accounts); +export function deduplicateAccounts< + T extends { + accountId?: string; + refreshToken: string; + lastUsed?: number; + addedAt?: number; + }, +>(accounts: T[]): T[] { + return deduplicateAccountsByKey(accounts); } /** @@ -649,98 +736,105 @@ export function deduplicateAccounts( - accounts: T[], -): T[] { - - const emailToNewestIndex = new Map(); - const indicesToKeep = new Set(); - - for (let i = 0; i < accounts.length; i += 1) { - const account = accounts[i]; - if (!account) continue; - - const email = normalizeEmailKey(account.email); - if (!email) { - indicesToKeep.add(i); - continue; - } - - const existingIndex = emailToNewestIndex.get(email); - if (existingIndex === undefined) { - emailToNewestIndex.set(email, i); - continue; - } - - const existing = accounts[existingIndex]; - // istanbul ignore next -- defensive code: existingIndex always refers to valid account - if (!existing) { - emailToNewestIndex.set(email, i); - continue; - } - - const existingLastUsed = existing.lastUsed || 0; - const candidateLastUsed = account.lastUsed || 0; - const existingAddedAt = existing.addedAt || 0; - const candidateAddedAt = account.addedAt || 0; - - const isNewer = - candidateLastUsed > existingLastUsed || - (candidateLastUsed === existingLastUsed && candidateAddedAt > existingAddedAt); - - if (isNewer) { - emailToNewestIndex.set(email, i); - } - } - - for (const idx of emailToNewestIndex.values()) { - indicesToKeep.add(idx); - } - - const result: T[] = []; - for (let i = 0; i < accounts.length; i += 1) { - if (indicesToKeep.has(i)) { - const account = accounts[i]; - if (account) result.push(account); - } - } - return result; +export function deduplicateAccountsByEmail< + T extends { email?: string; lastUsed?: number; addedAt?: number }, +>(accounts: T[]): T[] { + const emailToNewestIndex = new Map(); + const indicesToKeep = new Set(); + + for (let i = 0; i < accounts.length; i += 1) { + const account = accounts[i]; + if (!account) continue; + + const email = normalizeEmailKey(account.email); + if (!email) { + indicesToKeep.add(i); + continue; + } + + const existingIndex = emailToNewestIndex.get(email); + if (existingIndex === undefined) { + emailToNewestIndex.set(email, i); + continue; + } + + const existing = accounts[existingIndex]; + // istanbul ignore next -- defensive code: existingIndex always refers to valid account + if (!existing) { + emailToNewestIndex.set(email, i); + continue; + } + + const existingLastUsed = existing.lastUsed || 0; + const candidateLastUsed = account.lastUsed || 0; + const existingAddedAt = existing.addedAt || 0; + const candidateAddedAt = account.addedAt || 0; + + const isNewer = + candidateLastUsed > existingLastUsed || + (candidateLastUsed === existingLastUsed && + candidateAddedAt > existingAddedAt); + + if (isNewer) { + emailToNewestIndex.set(email, i); + } + } + + for (const idx of emailToNewestIndex.values()) { + indicesToKeep.add(idx); + } + + const result: T[] = []; + for (let i = 0; i < accounts.length; i += 1) { + if (indicesToKeep.has(i)) { + const account = accounts[i]; + if (account) result.push(account); + } + } + return result; } function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); + return !!value && typeof value === "object" && !Array.isArray(value); } function clampIndex(index: number, length: number): number { - if (length <= 0) return 0; - return Math.max(0, Math.min(index, length - 1)); + if (length <= 0) return 0; + return Math.max(0, Math.min(index, length - 1)); } -function toAccountKey(account: Pick): string { - return account.accountId || account.refreshToken; +function toAccountKey( + account: Pick, +): string { + return account.accountId || account.refreshToken; } -function extractActiveKey(accounts: unknown[], activeIndex: number): string | undefined { - const candidate = accounts[activeIndex]; - if (!isRecord(candidate)) return undefined; +function extractActiveKey( + accounts: unknown[], + activeIndex: number, +): string | undefined { + const candidate = accounts[activeIndex]; + if (!isRecord(candidate)) return undefined; - const accountId = - typeof candidate.accountId === "string" && candidate.accountId.trim() - ? candidate.accountId - : undefined; - const refreshToken = - typeof candidate.refreshToken === "string" && candidate.refreshToken.trim() - ? candidate.refreshToken - : undefined; + const accountId = + typeof candidate.accountId === "string" && candidate.accountId.trim() + ? candidate.accountId + : undefined; + const refreshToken = + typeof candidate.refreshToken === "string" && candidate.refreshToken.trim() + ? candidate.refreshToken + : undefined; - return accountId || refreshToken; + return accountId || refreshToken; } /** @@ -749,95 +843,99 @@ function extractActiveKey(accounts: unknown[], activeIndex: number): string | un * @param data - Raw storage data (unknown format) * @returns Normalized AccountStorageV3 or null if invalid */ -export function normalizeAccountStorage(data: unknown): AccountStorageV3 | null { - if (!isRecord(data)) { - log.warn("Invalid storage format, ignoring"); - return null; - } - - if (data.version !== 1 && data.version !== 3) { - log.warn("Unknown storage version, ignoring", { - version: (data as { version?: unknown }).version, - }); - return null; - } - - const rawAccounts = data.accounts; - if (!Array.isArray(rawAccounts)) { - log.warn("Invalid storage format, ignoring"); - return null; - } - - const activeIndexValue = - typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) - ? data.activeIndex - : 0; - - const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length); - const activeKey = extractActiveKey(rawAccounts, rawActiveIndex); - - const fromVersion = data.version as AnyAccountStorage["version"]; - const baseStorage: AccountStorageV3 = - fromVersion === 1 - ? migrateV1ToV3(data as unknown as AccountStorageV1) - : (data as unknown as AccountStorageV3); - - const validAccounts = rawAccounts.filter( - (account): account is AccountMetadataV3 => - isRecord(account) && typeof account.refreshToken === "string" && !!account.refreshToken.trim(), - ); - - const deduplicatedAccounts = deduplicateAccountsByEmail( - deduplicateAccountsByKey(validAccounts), - ); - - const activeIndex = (() => { - if (deduplicatedAccounts.length === 0) return 0; - - if (activeKey) { - const mappedIndex = deduplicatedAccounts.findIndex( - (account) => toAccountKey(account) === activeKey, - ); - if (mappedIndex >= 0) return mappedIndex; - } - - return clampIndex(rawActiveIndex, deduplicatedAccounts.length); - })(); - - const activeIndexByFamily: Partial> = {}; - const rawFamilyIndices = isRecord(baseStorage.activeIndexByFamily) - ? (baseStorage.activeIndexByFamily as Record) - : {}; - - for (const family of MODEL_FAMILIES) { - const rawIndexValue = rawFamilyIndices[family]; - const rawIndex = - typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue) - ? rawIndexValue - : rawActiveIndex; - - const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length); - const familyKey = extractActiveKey(rawAccounts, clampedRawIndex); - - let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length); - if (familyKey && deduplicatedAccounts.length > 0) { - const idx = deduplicatedAccounts.findIndex( - (account) => toAccountKey(account) === familyKey, - ); - if (idx >= 0) { - mappedIndex = idx; - } - } - - activeIndexByFamily[family] = mappedIndex; - } - - return { - version: 3, - accounts: deduplicatedAccounts, - activeIndex, - activeIndexByFamily, - }; +export function normalizeAccountStorage( + data: unknown, +): AccountStorageV3 | null { + if (!isRecord(data)) { + log.warn("Invalid storage format, ignoring"); + return null; + } + + if (data.version !== 1 && data.version !== 3) { + log.warn("Unknown storage version, ignoring", { + version: (data as { version?: unknown }).version, + }); + return null; + } + + const rawAccounts = data.accounts; + if (!Array.isArray(rawAccounts)) { + log.warn("Invalid storage format, ignoring"); + return null; + } + + const activeIndexValue = + typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex) + ? data.activeIndex + : 0; + + const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length); + const activeKey = extractActiveKey(rawAccounts, rawActiveIndex); + + const fromVersion = data.version as AnyAccountStorage["version"]; + const baseStorage: AccountStorageV3 = + fromVersion === 1 + ? migrateV1ToV3(data as unknown as AccountStorageV1) + : (data as unknown as AccountStorageV3); + + const validAccounts = rawAccounts.filter( + (account): account is AccountMetadataV3 => + isRecord(account) && + typeof account.refreshToken === "string" && + !!account.refreshToken.trim(), + ); + + const deduplicatedAccounts = deduplicateAccountsByEmail( + deduplicateAccountsByKey(validAccounts), + ); + + const activeIndex = (() => { + if (deduplicatedAccounts.length === 0) return 0; + + if (activeKey) { + const mappedIndex = deduplicatedAccounts.findIndex( + (account) => toAccountKey(account) === activeKey, + ); + if (mappedIndex >= 0) return mappedIndex; + } + + return clampIndex(rawActiveIndex, deduplicatedAccounts.length); + })(); + + const activeIndexByFamily: Partial> = {}; + const rawFamilyIndices = isRecord(baseStorage.activeIndexByFamily) + ? (baseStorage.activeIndexByFamily as Record) + : {}; + + for (const family of MODEL_FAMILIES) { + const rawIndexValue = rawFamilyIndices[family]; + const rawIndex = + typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue) + ? rawIndexValue + : rawActiveIndex; + + const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length); + const familyKey = extractActiveKey(rawAccounts, clampedRawIndex); + + let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length); + if (familyKey && deduplicatedAccounts.length > 0) { + const idx = deduplicatedAccounts.findIndex( + (account) => toAccountKey(account) === familyKey, + ); + if (idx >= 0) { + mappedIndex = idx; + } + } + + activeIndexByFamily[family] = mappedIndex; + } + + return { + version: 3, + accounts: deduplicatedAccounts, + activeIndex, + activeIndexByFamily, + }; } /** @@ -846,7 +944,7 @@ export function normalizeAccountStorage(data: unknown): AccountStorageV3 | null * @returns AccountStorageV3 if file exists and is valid, null otherwise */ export async function loadAccounts(): Promise { - return loadAccountsInternal(saveAccounts); + return loadAccountsInternal(saveAccounts); } function parseAndNormalizeStorage(data: unknown): { @@ -856,7 +954,9 @@ function parseAndNormalizeStorage(data: unknown): { } { const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data); const normalized = normalizeAccountStorage(data); - const storedVersion = isRecord(data) ? (data as { version?: unknown }).version : undefined; + const storedVersion = isRecord(data) + ? (data as { version?: unknown }).version + : undefined; return { normalized, storedVersion, schemaErrors }; } @@ -870,7 +970,9 @@ async function loadAccountsFromPath(path: string): Promise<{ return parseAndNormalizeStorage(data); } -async function loadAccountsFromJournal(path: string): Promise { +async function loadAccountsFromJournal( + path: string, +): Promise { const walPath = getAccountsWalPath(path); try { const raw = await fs.readFile(walPath, "utf-8"); @@ -878,7 +980,8 @@ async function loadAccountsFromJournal(path: string): Promise; if (entry.version !== 1) return null; - if (typeof entry.content !== "string" || typeof entry.checksum !== "string") return null; + if (typeof entry.content !== "string" || typeof entry.checksum !== "string") + return null; const computed = computeSha256(entry.content); if (computed !== entry.checksum) { log.warn("Account journal checksum mismatch", { path: walPath }); @@ -892,14 +995,17 @@ async function loadAccountsFromJournal(path: string): Promise Promise) | null, + persistMigration: ((storage: AccountStorageV3) => Promise) | null, ): Promise { const path = getStoragePath(); await cleanupStaleRotatingBackupArtifacts(path); @@ -907,249 +1013,278 @@ async function loadAccountsInternal( ? await migrateLegacyProjectStorageIfNeeded(persistMigration) : null; - try { - const { normalized, storedVersion, schemaErrors } = await loadAccountsFromPath(path); - if (schemaErrors.length > 0) { - log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) }); - } - if (normalized && storedVersion !== normalized.version) { - log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version }); - if (persistMigration) { - try { - await persistMigration(normalized); - } catch (saveError) { - log.warn("Failed to persist migrated storage", { error: String(saveError) }); - } - } - } - - const primaryLooksSynthetic = looksLikeSyntheticFixtureStorage(normalized); - if (storageBackupEnabled && normalized && primaryLooksSynthetic) { - const backupCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const backupPath of backupCandidates) { - if (backupPath === path) continue; - try { - const backup = await loadAccountsFromPath(backupPath); - if (!backup.normalized) continue; - if (looksLikeSyntheticFixtureStorage(backup.normalized)) continue; - if (backup.normalized.accounts.length <= 0) continue; - log.warn("Detected synthetic primary account storage; promoting backup", { - path, - backupPath, - primaryAccounts: normalized.accounts.length, - backupAccounts: backup.normalized.accounts.length, - }); - if (persistMigration) { - try { - await persistMigration(backup.normalized); - } catch (persistError) { - log.warn("Failed to persist promoted backup storage", { - path, - error: String(persistError), - }); - } - } - return backup.normalized; - } catch (backupError) { - const backupCode = (backupError as NodeJS.ErrnoException).code; - if (backupCode !== "ENOENT") { - log.warn("Failed to load candidate backup for synthetic-primary promotion", { - path: backupPath, - error: String(backupError), + try { + const { normalized, storedVersion, schemaErrors } = + await loadAccountsFromPath(path); + if (schemaErrors.length > 0) { + log.warn("Account storage schema validation warnings", { + errors: schemaErrors.slice(0, 5), + }); + } + if (normalized && storedVersion !== normalized.version) { + log.info("Migrating account storage to v3", { + from: storedVersion, + to: normalized.version, + }); + if (persistMigration) { + try { + await persistMigration(normalized); + } catch (saveError) { + log.warn("Failed to persist migrated storage", { + error: String(saveError), }); } } } - } - - return normalized; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" && migratedLegacyStorage) { - return migratedLegacyStorage; - } - - const recoveredFromWal = await loadAccountsFromJournal(path); - if (recoveredFromWal) { - if (persistMigration) { - try { - await persistMigration(recoveredFromWal); - } catch (persistError) { - log.warn("Failed to persist WAL-recovered storage", { - path, - error: String(persistError), - }); - } - } - return recoveredFromWal; - } - if (storageBackupEnabled) { - const backupCandidates = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); - for (const backupPath of backupCandidates) { - try { - const backup = await loadAccountsFromPath(backupPath); - if (backup.schemaErrors.length > 0) { - log.warn("Backup account storage schema validation warnings", { - path: backupPath, - errors: backup.schemaErrors.slice(0, 5), - }); - } - if (backup.normalized) { - log.warn("Recovered account storage from backup file", { path, backupPath }); + const primaryLooksSynthetic = looksLikeSyntheticFixtureStorage(normalized); + if (storageBackupEnabled && normalized && primaryLooksSynthetic) { + const backupCandidates = + await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + for (const backupPath of backupCandidates) { + if (backupPath === path) continue; + try { + const backup = await loadAccountsFromPath(backupPath); + if (!backup.normalized) continue; + if (looksLikeSyntheticFixtureStorage(backup.normalized)) continue; + if (backup.normalized.accounts.length <= 0) continue; + log.warn( + "Detected synthetic primary account storage; promoting backup", + { + path, + backupPath, + primaryAccounts: normalized.accounts.length, + backupAccounts: backup.normalized.accounts.length, + }, + ); if (persistMigration) { try { await persistMigration(backup.normalized); } catch (persistError) { - log.warn("Failed to persist recovered backup storage", { + log.warn("Failed to persist promoted backup storage", { path, error: String(persistError), }); } } return backup.normalized; + } catch (backupError) { + const backupCode = (backupError as NodeJS.ErrnoException).code; + if (backupCode !== "ENOENT") { + log.warn( + "Failed to load candidate backup for synthetic-primary promotion", + { + path: backupPath, + error: String(backupError), + }, + ); + } } - } catch (backupError) { - const backupCode = (backupError as NodeJS.ErrnoException).code; - if (backupCode !== "ENOENT") { - log.warn("Failed to load backup account storage", { - path: backupPath, - error: String(backupError), + } + } + + return normalized; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" && migratedLegacyStorage) { + return migratedLegacyStorage; + } + + const recoveredFromWal = await loadAccountsFromJournal(path); + if (recoveredFromWal) { + if (persistMigration) { + try { + await persistMigration(recoveredFromWal); + } catch (persistError) { + log.warn("Failed to persist WAL-recovered storage", { + path, + error: String(persistError), }); } } + return recoveredFromWal; } - } - if (code !== "ENOENT") { - log.error("Failed to load account storage", { error: String(error) }); - } - return null; - } + if (storageBackupEnabled) { + const backupCandidates = + await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + for (const backupPath of backupCandidates) { + try { + const backup = await loadAccountsFromPath(backupPath); + if (backup.schemaErrors.length > 0) { + log.warn("Backup account storage schema validation warnings", { + path: backupPath, + errors: backup.schemaErrors.slice(0, 5), + }); + } + if (backup.normalized) { + log.warn("Recovered account storage from backup file", { + path, + backupPath, + }); + if (persistMigration) { + try { + await persistMigration(backup.normalized); + } catch (persistError) { + log.warn("Failed to persist recovered backup storage", { + path, + error: String(persistError), + }); + } + } + return backup.normalized; + } + } catch (backupError) { + const backupCode = (backupError as NodeJS.ErrnoException).code; + if (backupCode !== "ENOENT") { + log.warn("Failed to load backup account storage", { + path: backupPath, + error: String(backupError), + }); + } + } + } + } + + if (code !== "ENOENT") { + log.error("Failed to load account storage", { error: String(error) }); + } + return null; + } } async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { - const path = getStoragePath(); - const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; - const tempPath = `${path}.${uniqueSuffix}.tmp`; - const walPath = getAccountsWalPath(path); + const path = getStoragePath(); + const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; + const tempPath = `${path}.${uniqueSuffix}.tmp`; + const walPath = getAccountsWalPath(path); - try { - await fs.mkdir(dirname(path), { recursive: true }); - await ensureGitignore(path); + try { + await fs.mkdir(dirname(path), { recursive: true }); + await ensureGitignore(path); - if (looksLikeSyntheticFixtureStorage(storage)) { - try { - const existing = await loadNormalizedStorageFromPath(path, "existing account storage"); - if (existing && existing.accounts.length > 0 && !looksLikeSyntheticFixtureStorage(existing)) { - throw new StorageError( - "Refusing to overwrite non-synthetic account storage with synthetic fixture payload", - "EINVALID", + if (looksLikeSyntheticFixtureStorage(storage)) { + try { + const existing = await loadNormalizedStorageFromPath( path, - "Detected synthetic fixture-like account payload. Use explicit account import/login commands instead.", + "existing account storage", ); + if ( + existing && + existing.accounts.length > 0 && + !looksLikeSyntheticFixtureStorage(existing) + ) { + throw new StorageError( + "Refusing to overwrite non-synthetic account storage with synthetic fixture payload", + "EINVALID", + path, + "Detected synthetic fixture-like account payload. Use explicit account import/login commands instead.", + ); + } + } catch (error) { + if (error instanceof StorageError) { + throw error; + } + // Ignore existing-file probe failures and continue with normal save flow. } - } catch (error) { - if (error instanceof StorageError) { - throw error; + } + + if (storageBackupEnabled && existsSync(path)) { + try { + await createRotatingAccountsBackup(path); + } catch (backupError) { + log.warn("Failed to create account storage backup", { + path, + backupPath: getAccountsBackupPath(path), + error: String(backupError), + }); } - // Ignore existing-file probe failures and continue with normal save flow. } - } - if (storageBackupEnabled && existsSync(path)) { - try { - await createRotatingAccountsBackup(path); - } catch (backupError) { - log.warn("Failed to create account storage backup", { - path, - backupPath: getAccountsBackupPath(path), - error: String(backupError), - }); + const content = JSON.stringify(storage, null, 2); + const journalEntry: AccountsJournalEntry = { + version: 1, + createdAt: Date.now(), + path, + checksum: computeSha256(content), + content, + }; + await fs.writeFile(walPath, JSON.stringify(journalEntry), { + encoding: "utf-8", + mode: 0o600, + }); + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); + + const stats = await fs.stat(tempPath); + if (stats.size === 0) { + const emptyError = Object.assign( + new Error("File written but size is 0"), + { code: "EEMPTY" }, + ); + throw emptyError; } - } - const content = JSON.stringify(storage, null, 2); - const journalEntry: AccountsJournalEntry = { - version: 1, - createdAt: Date.now(), - path, - checksum: computeSha256(content), - content, - }; - await fs.writeFile(walPath, JSON.stringify(journalEntry), { - encoding: "utf-8", - mode: 0o600, - }); - await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); - - const stats = await fs.stat(tempPath); - if (stats.size === 0) { - const emptyError = Object.assign(new Error("File written but size is 0"), { code: "EEMPTY" }); - throw emptyError; - } - - // Retry rename with exponential backoff for Windows EPERM/EBUSY - let lastError: NodeJS.ErrnoException | null = null; - for (let attempt = 0; attempt < 5; attempt++) { - try { - await fs.rename(tempPath, path); - lastAccountsSaveTimestamp = Date.now(); + // Retry rename with exponential backoff for Windows EPERM/EBUSY + let lastError: NodeJS.ErrnoException | null = null; + for (let attempt = 0; attempt < 5; attempt++) { + try { + await fs.rename(tempPath, path); + lastAccountsSaveTimestamp = Date.now(); + try { + await fs.unlink(walPath); + } catch { + // Best effort cleanup. + } + return; + } catch (renameError) { + const code = (renameError as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EBUSY") { + lastError = renameError as NodeJS.ErrnoException; + await new Promise((r) => setTimeout(r, 10 * 2 ** attempt)); + continue; + } + throw renameError; + } + } + if (lastError) throw lastError; + } catch (error) { try { - await fs.unlink(walPath); + await fs.unlink(tempPath); } catch { - // Best effort cleanup. + // Ignore cleanup failure. } - return; - } catch (renameError) { - const code = (renameError as NodeJS.ErrnoException).code; - if (code === "EPERM" || code === "EBUSY") { - lastError = renameError as NodeJS.ErrnoException; - await new Promise(r => setTimeout(r, 10 * Math.pow(2, attempt))); - continue; - } - throw renameError; - } - } - if (lastError) throw lastError; - } catch (error) { - try { - await fs.unlink(tempPath); - } catch { - // Ignore cleanup failure. - } - - const err = error as NodeJS.ErrnoException; - const code = err?.code || "UNKNOWN"; - const hint = formatStorageErrorHint(error, path); - - log.error("Failed to save accounts", { - path, - code, - message: err?.message, - hint, - }); - - throw new StorageError( - `Failed to save accounts: ${err?.message || "Unknown error"}`, - code, - path, - hint, - err instanceof Error ? err : undefined - ); - } + + const err = error as NodeJS.ErrnoException; + const code = err?.code || "UNKNOWN"; + const hint = formatStorageErrorHint(error, path); + + log.error("Failed to save accounts", { + path, + code, + message: err?.message, + hint, + }); + + throw new StorageError( + `Failed to save accounts: ${err?.message || "Unknown error"}`, + code, + path, + hint, + err instanceof Error ? err : undefined, + ); + } } export async function withAccountStorageTransaction( - handler: ( - current: AccountStorageV3 | null, - persist: (storage: AccountStorageV3) => Promise, - ) => Promise, + handler: ( + current: AccountStorageV3 | null, + persist: (storage: AccountStorageV3) => Promise, + ) => Promise, ): Promise { - return withStorageLock(async () => { - const current = await loadAccountsInternal(saveAccountsUnlocked); - return handler(current, saveAccountsUnlocked); - }); + return withStorageLock(async () => { + const current = await loadAccountsInternal(saveAccountsUnlocked); + return handler(current, saveAccountsUnlocked); + }); } /** @@ -1160,40 +1295,48 @@ export async function withAccountStorageTransaction( * @throws StorageError with platform-aware hints on failure */ export async function saveAccounts(storage: AccountStorageV3): Promise { - return withStorageLock(async () => { - await saveAccountsUnlocked(storage); - }); + return withStorageLock(async () => { + await saveAccountsUnlocked(storage); + }); } /** * Deletes the account storage file from disk. * Silently ignores if file doesn't exist. */ -export async function clearAccounts(): Promise { - return withStorageLock(async () => { - const path = getStoragePath(); - const walPath = getAccountsWalPath(path); - const backupPaths = getAccountsBackupRecoveryCandidates(path); - const clearPath = async (targetPath: string): Promise => { - try { - await fs.unlink(targetPath); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - log.error("Failed to clear account storage artifact", { - path: targetPath, - error: String(error), - }); - } - } - }; - - try { - await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]); - } catch { - // Individual path cleanup is already best-effort with per-artifact logging. - } - }); +export async function clearAccounts(): Promise { + return withStorageLock(async () => { + const path = getStoragePath(); + const walPath = getAccountsWalPath(path); + const backupPaths = getAccountsBackupRecoveryCandidates(path); + let hadError = false; + const clearPath = async (targetPath: string): Promise => { + try { + await fs.unlink(targetPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + hadError = true; + log.error("Failed to clear account storage artifact", { + path: targetPath, + error: String(error), + }); + } + } + }; + + try { + await Promise.all([ + clearPath(path), + clearPath(walPath), + ...backupPaths.map(clearPath), + ]); + } catch { + // Individual path cleanup is already best-effort with per-artifact logging. + } + + return !hadError; + }); } function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { @@ -1205,14 +1348,22 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { for (const rawAccount of data.accounts) { if (!isRecord(rawAccount)) continue; const refreshToken = - typeof rawAccount.refreshToken === "string" ? rawAccount.refreshToken.trim() : ""; + typeof rawAccount.refreshToken === "string" + ? rawAccount.refreshToken.trim() + : ""; if (!refreshToken) continue; - const flaggedAt = typeof rawAccount.flaggedAt === "number" ? rawAccount.flaggedAt : Date.now(); + const flaggedAt = + typeof rawAccount.flaggedAt === "number" + ? rawAccount.flaggedAt + : Date.now(); const isAccountIdSource = ( value: unknown, ): value is AccountMetadataV3["accountIdSource"] => - value === "token" || value === "id_token" || value === "org" || value === "manual"; + value === "token" || + value === "id_token" || + value === "org" || + value === "manual"; const isSwitchReason = ( value: unknown, ): value is AccountMetadataV3["lastSwitchReason"] => @@ -1220,12 +1371,18 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { const isCooldownReason = ( value: unknown, ): value is AccountMetadataV3["cooldownReason"] => - value === "auth-failure" || value === "network-error" || value === "rate-limit"; + value === "auth-failure" || + value === "network-error" || + value === "rate-limit"; - let rateLimitResetTimes: AccountMetadataV3["rateLimitResetTimes"] | undefined; + let rateLimitResetTimes: + | AccountMetadataV3["rateLimitResetTimes"] + | undefined; if (isRecord(rawAccount.rateLimitResetTimes)) { const normalizedRateLimits: Record = {}; - for (const [key, value] of Object.entries(rawAccount.rateLimitResetTimes)) { + for (const [key, value] of Object.entries( + rawAccount.rateLimitResetTimes, + )) { if (typeof value === "number") { normalizedRateLimits[key] = value; } @@ -1247,21 +1404,43 @@ function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { const normalized: FlaggedAccountMetadataV1 = { refreshToken, - addedAt: typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt, - lastUsed: typeof rawAccount.lastUsed === "number" ? rawAccount.lastUsed : flaggedAt, - accountId: typeof rawAccount.accountId === "string" ? rawAccount.accountId : undefined, + addedAt: + typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt, + lastUsed: + typeof rawAccount.lastUsed === "number" + ? rawAccount.lastUsed + : flaggedAt, + accountId: + typeof rawAccount.accountId === "string" + ? rawAccount.accountId + : undefined, accountIdSource, - accountLabel: typeof rawAccount.accountLabel === "string" ? rawAccount.accountLabel : undefined, - email: typeof rawAccount.email === "string" ? rawAccount.email : undefined, - enabled: typeof rawAccount.enabled === "boolean" ? rawAccount.enabled : undefined, + accountLabel: + typeof rawAccount.accountLabel === "string" + ? rawAccount.accountLabel + : undefined, + email: + typeof rawAccount.email === "string" ? rawAccount.email : undefined, + enabled: + typeof rawAccount.enabled === "boolean" + ? rawAccount.enabled + : undefined, lastSwitchReason, rateLimitResetTimes, coolingDownUntil: - typeof rawAccount.coolingDownUntil === "number" ? rawAccount.coolingDownUntil : undefined, + typeof rawAccount.coolingDownUntil === "number" + ? rawAccount.coolingDownUntil + : undefined, cooldownReason, flaggedAt, - flaggedReason: typeof rawAccount.flaggedReason === "string" ? rawAccount.flaggedReason : undefined, - lastError: typeof rawAccount.lastError === "string" ? rawAccount.lastError : undefined, + flaggedReason: + typeof rawAccount.flaggedReason === "string" + ? rawAccount.flaggedReason + : undefined, + lastError: + typeof rawAccount.lastError === "string" + ? rawAccount.lastError + : undefined, }; byRefreshToken.set(refreshToken, normalized); } @@ -1283,7 +1462,10 @@ export async function loadFlaggedAccounts(): Promise { } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { - log.error("Failed to load flagged account storage", { path, error: String(error) }); + log.error("Failed to load flagged account storage", { + path, + error: String(error), + }); return empty; } } @@ -1321,7 +1503,9 @@ export async function loadFlaggedAccounts(): Promise { } } -export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Promise { +export async function saveFlaggedAccounts( + storage: FlaggedAccountStorageV1, +): Promise { return withStorageLock(async () => { const path = getFlaggedAccountsPath(); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; @@ -1338,21 +1522,31 @@ export async function saveFlaggedAccounts(storage: FlaggedAccountStorageV1): Pro } catch { // Ignore cleanup failures. } - log.error("Failed to save flagged account storage", { path, error: String(error) }); + log.error("Failed to save flagged account storage", { + path, + error: String(error), + }); throw error; } }); } -export async function clearFlaggedAccounts(): Promise { +export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { try { await fs.unlink(getFlaggedAccountsPath()); + return true; } catch (error) { const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return true; + } if (code !== "ENOENT") { - log.error("Failed to clear flagged account storage", { error: String(error) }); + log.error("Failed to clear flagged account storage", { + error: String(error), + }); } + return false; } }); } @@ -1363,23 +1557,31 @@ export async function clearFlaggedAccounts(): Promise { * @param force - If true, overwrite existing file (default: true) * @throws Error if file exists and force is false, or if no accounts to export */ -export async function exportAccounts(filePath: string, force = true): Promise { - const resolvedPath = resolvePath(filePath); - - if (!force && existsSync(resolvedPath)) { - throw new Error(`File already exists: ${resolvedPath}`); - } - - const storage = await withAccountStorageTransaction((current) => Promise.resolve(current)); - if (!storage || storage.accounts.length === 0) { - throw new Error("No accounts to export"); - } - - await fs.mkdir(dirname(resolvedPath), { recursive: true }); - - const content = JSON.stringify(storage, null, 2); - await fs.writeFile(resolvedPath, content, { encoding: "utf-8", mode: 0o600 }); - log.info("Exported accounts", { path: resolvedPath, count: storage.accounts.length }); +export async function exportAccounts( + filePath: string, + force = true, +): Promise { + const resolvedPath = resolvePath(filePath); + + if (!force && existsSync(resolvedPath)) { + throw new Error(`File already exists: ${resolvedPath}`); + } + + const storage = await withAccountStorageTransaction((current) => + Promise.resolve(current), + ); + if (!storage || storage.accounts.length === 0) { + throw new Error("No accounts to export"); + } + + await fs.mkdir(dirname(resolvedPath), { recursive: true }); + + const content = JSON.stringify(storage, null, 2); + await fs.writeFile(resolvedPath, content, { encoding: "utf-8", mode: 0o600 }); + log.info("Exported accounts", { + path: resolvedPath, + count: storage.accounts.length, + }); } /** @@ -1388,61 +1590,73 @@ export async function exportAccounts(filePath: string, force = true): Promise { - const resolvedPath = resolvePath(filePath); - - // Check file exists with friendly error - if (!existsSync(resolvedPath)) { - throw new Error(`Import file not found: ${resolvedPath}`); - } - - const content = await fs.readFile(resolvedPath, "utf-8"); - - let imported: unknown; - try { - imported = JSON.parse(content); - } catch { - throw new Error(`Invalid JSON in import file: ${resolvedPath}`); - } - - const normalized = normalizeAccountStorage(imported); - if (!normalized) { - throw new Error("Invalid account storage format"); - } - - const { imported: importedCount, total, skipped: skippedCount } = - await withAccountStorageTransaction(async (existing, persist) => { - const existingAccounts = existing?.accounts ?? []; - const existingActiveIndex = existing?.activeIndex ?? 0; - - const merged = [...existingAccounts, ...normalized.accounts]; - - if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - const deduped = deduplicateAccountsByEmail(deduplicateAccounts(merged)); - if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { - throw new Error( - `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})` - ); - } - } - - const deduplicatedAccounts = deduplicateAccountsByEmail(deduplicateAccounts(merged)); - - const newStorage: AccountStorageV3 = { - version: 3, - accounts: deduplicatedAccounts, - activeIndex: existingActiveIndex, - activeIndexByFamily: existing?.activeIndexByFamily, - }; - - await persist(newStorage); - - const imported = deduplicatedAccounts.length - existingAccounts.length; - const skipped = normalized.accounts.length - imported; - return { imported, total: deduplicatedAccounts.length, skipped }; - }); - - log.info("Imported accounts", { path: resolvedPath, imported: importedCount, skipped: skippedCount, total }); - - return { imported: importedCount, total, skipped: skippedCount }; +export async function importAccounts( + filePath: string, +): Promise<{ imported: number; total: number; skipped: number }> { + const resolvedPath = resolvePath(filePath); + + // Check file exists with friendly error + if (!existsSync(resolvedPath)) { + throw new Error(`Import file not found: ${resolvedPath}`); + } + + const content = await fs.readFile(resolvedPath, "utf-8"); + + let imported: unknown; + try { + imported = JSON.parse(content); + } catch { + throw new Error(`Invalid JSON in import file: ${resolvedPath}`); + } + + const normalized = normalizeAccountStorage(imported); + if (!normalized) { + throw new Error("Invalid account storage format"); + } + + const { + imported: importedCount, + total, + skipped: skippedCount, + } = await withAccountStorageTransaction(async (existing, persist) => { + const existingAccounts = existing?.accounts ?? []; + const existingActiveIndex = existing?.activeIndex ?? 0; + + const merged = [...existingAccounts, ...normalized.accounts]; + + if (merged.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + const deduped = deduplicateAccountsByEmail(deduplicateAccounts(merged)); + if (deduped.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) { + throw new Error( + `Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${deduped.length})`, + ); + } + } + + const deduplicatedAccounts = deduplicateAccountsByEmail( + deduplicateAccounts(merged), + ); + + const newStorage: AccountStorageV3 = { + version: 3, + accounts: deduplicatedAccounts, + activeIndex: existingActiveIndex, + activeIndexByFamily: existing?.activeIndexByFamily, + }; + + await persist(newStorage); + + const imported = deduplicatedAccounts.length - existingAccounts.length; + const skipped = normalized.accounts.length - imported; + return { imported, total: deduplicatedAccounts.length, skipped }; + }); + + log.info("Imported accounts", { + path: resolvedPath, + imported: importedCount, + skipped: skippedCount, + total, + }); + + return { imported: importedCount, total, skipped: skippedCount }; } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 334a2bff..6b7bf384 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -219,6 +219,8 @@ describe("codex manager cli commands", () => { vi.clearAllMocks(); loadAccountsMock.mockReset(); loadFlaggedAccountsMock.mockReset(); + deleteSavedAccountsMock.mockReset(); + resetLocalStateMock.mockReset(); saveAccountsMock.mockReset(); saveFlaggedAccountsMock.mockReset(); queuedRefreshMock.mockReset(); @@ -235,6 +237,16 @@ describe("codex manager cli commands", () => { selectMock.mockReset(); deleteAccountAtIndexMock.mockReset(); deleteAccountAtIndexMock.mockResolvedValue(null); + deleteSavedAccountsMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }); + resetLocalStateMock.mockResolvedValue({ + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, model: "gpt-5-codex", From 78bdb5b90fb7ff924102f4c4a7bdbac3f201dffc Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 21:47:26 +0800 Subject: [PATCH 05/11] test: cover reset safety regressions --- test/destructive-actions.test.ts | 167 +++++++++++++++++++++++++++++++ test/quota-cache.test.ts | 25 +++++ test/storage.test.ts | 157 +++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 test/destructive-actions.test.ts diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts new file mode 100644 index 00000000..6516ae96 --- /dev/null +++ b/test/destructive-actions.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const clearAccountsMock = vi.fn(); +const clearFlaggedAccountsMock = vi.fn(); +const clearQuotaCacheMock = vi.fn(); +const clearCodexCliStateCacheMock = vi.fn(); +const loadFlaggedAccountsMock = vi.fn(); +const saveAccountsMock = vi.fn(); +const saveFlaggedAccountsMock = vi.fn(); + +vi.mock("../lib/codex-cli/state.js", () => ({ + clearCodexCliStateCache: clearCodexCliStateCacheMock, +})); + +vi.mock("../lib/prompts/codex.js", () => ({ + MODEL_FAMILIES: ["codex", "gpt-5.x"] as const, +})); + +vi.mock("../lib/quota-cache.js", () => ({ + clearQuotaCache: clearQuotaCacheMock, +})); + +vi.mock("../lib/storage.js", () => ({ + clearAccounts: clearAccountsMock, + clearFlaggedAccounts: clearFlaggedAccountsMock, + loadFlaggedAccounts: loadFlaggedAccountsMock, + saveAccounts: saveAccountsMock, + saveFlaggedAccounts: saveFlaggedAccountsMock, +})); + +describe("destructive actions", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + clearAccountsMock.mockResolvedValue(true); + clearFlaggedAccountsMock.mockResolvedValue(true); + clearQuotaCacheMock.mockResolvedValue(true); + loadFlaggedAccountsMock.mockResolvedValue({ version: 1, accounts: [] }); + saveAccountsMock.mockResolvedValue(undefined); + saveFlaggedAccountsMock.mockResolvedValue(undefined); + }); + + it("returns delete-only results without pretending kept data was cleared", async () => { + const { deleteSavedAccounts } = await import( + "../lib/destructive-actions.js" + ); + + await expect(deleteSavedAccounts()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).not.toHaveBeenCalled(); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("rethrows the original flagged-save failure after a successful rollback", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + await expect(deleteAccountAtIndex({ storage, index: 1 })).rejects.toBe( + flaggedSaveError, + ); + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); + + it("preserves both the flagged-save failure and rollback failure", async () => { + const flaggedSaveError = Object.assign(new Error("flagged save failed"), { + code: "EPERM", + }); + const rollbackError = Object.assign(new Error("rollback failed"), { + code: "EPERM", + }); + saveAccountsMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(rollbackError); + saveFlaggedAccountsMock.mockRejectedValueOnce(flaggedSaveError); + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + flaggedAt: 1, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + try { + await deleteAccountAtIndex({ storage, index: 1 }); + throw new Error("expected deleteAccountAtIndex to throw"); + } catch (error) { + expect(error).toBeInstanceOf(AggregateError); + const aggregateError = error as AggregateError; + expect(aggregateError.message).toBe( + "Deleting the account partially failed and rollback also failed.", + ); + expect(aggregateError.errors).toEqual([ + flaggedSaveError, + rollbackError, + ]); + } + expect(saveAccountsMock).toHaveBeenCalledTimes(2); + expect(storage.accounts).toHaveLength(2); + }); +}); diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index 54b5ffb6..b296a588 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -79,6 +79,31 @@ describe("quota cache", () => { expect(loaded).toEqual({ byAccountId: {}, byEmail: {} }); }); + it("resolves the quota cache path from the current CODEX_MULTI_AUTH_DIR on each call", async () => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + const firstPath = getQuotaCachePath(); + const nextTempDir = await fs.mkdtemp( + join(tmpdir(), "codex-multi-auth-quota-next-"), + ); + + try { + process.env.CODEX_MULTI_AUTH_DIR = nextTempDir; + const nextPath = getQuotaCachePath(); + + expect(nextPath).not.toBe(firstPath); + expect(nextPath).toBe(join(nextTempDir, "quota-cache.json")); + + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + await expect(fs.access(nextPath)).resolves.toBeUndefined(); + + await clearQuotaCache(); + await expect(fs.access(nextPath)).rejects.toThrow(); + } finally { + await fs.rm(nextTempDir, { recursive: true, force: true }); + } + }); + it("retries transient EBUSY while loading cache", async () => { const { loadQuotaCache, getQuotaCachePath } = await import("../lib/quota-cache.js"); diff --git a/test/storage.test.ts b/test/storage.test.ts index 4317873b..b1bff7ff 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -6,10 +6,12 @@ import { clearQuotaCache, getQuotaCachePath } from "../lib/quota-cache.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { clearAccounts, + clearFlaggedAccounts, deduplicateAccounts, deduplicateAccountsByEmail, exportAccounts, formatStorageErrorHint, + getFlaggedAccountsPath, getStoragePath, importAccounts, loadAccounts, @@ -872,6 +874,130 @@ describe("storage", () => { it("does not throw when file does not exist", async () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing saved account artifacts", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const walPath = `${testStoragePath}.wal`; + await fs.writeFile(walPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearAccounts()).resolves.toBe(true); + expect(existsSync(testStoragePath)).toBe(false); + expect(existsSync(walPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + }); + + describe("clearFlaggedAccounts", () => { + const testWorkDir = join( + tmpdir(), + "codex-clear-flagged-test-" + Math.random().toString(36).slice(2), + ); + let testStoragePath: string; + + beforeEach(async () => { + await fs.mkdir(testWorkDir, { recursive: true }); + testStoragePath = join(testWorkDir, "accounts.json"); + setStoragePathDirect(testStoragePath); + }); + + afterEach(async () => { + setStoragePathDirect(null); + await fs.rm(testWorkDir, { recursive: true, force: true }); + }); + + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged account storage", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged account storage exhausts retryable %s failures", + async (code) => { + const flaggedPath = getFlaggedAccountsPath(); + await fs.mkdir(dirname(flaggedPath), { recursive: true }); + await fs.writeFile(flaggedPath, "{}"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === flaggedPath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("setStoragePath", () => { @@ -1033,6 +1159,37 @@ describe("storage", () => { await expect(clearAccounts()).resolves.not.toThrow(); }); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing saved accounts exhausts retryable %s failures", + async (code) => { + await fs.writeFile(testStoragePath, "{}"); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (targetPath) => { + if (targetPath === testStoragePath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearAccounts()).resolves.toBe(false); + expect(existsSync(testStoragePath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter( + ([targetPath]) => targetPath === testStoragePath, + ), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); describe("StorageError with cause", () => { From 84599b925bb2c0406d05f2f8818239d46eaba8ac Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 21:52:23 +0800 Subject: [PATCH 06/11] fix(auth): close reset and delete safety gaps --- lib/cli.ts | 2 +- lib/codex-manager.ts | 4 + lib/destructive-actions.ts | 31 +++++- lib/quota-cache.ts | 28 +++--- lib/storage.ts | 29 +++++- test/cli.test.ts | 172 ++++++++++++++++++++++++++++----- test/codex-manager-cli.test.ts | 83 ++++++++++++++++ test/storage-flagged.test.ts | 79 +++++++++++++++ 8 files changed, 385 insertions(+), 43 deletions(-) diff --git a/lib/cli.ts b/lib/cli.ts index 54b99452..8fe514fb 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -277,7 +277,7 @@ export async function promptLoginMode( return { mode: "fresh", deleteAll: true }; case "reset-all": if (!(await promptResetTypedConfirm())) { - console.log("\nReset cancelled.\n"); + console.log("\nReset local state cancelled.\n"); continue; } return { mode: "reset" }; diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 80e27819..a47c58ff 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -4213,6 +4213,10 @@ async function runAuthLogin(): Promise { DESTRUCTIVE_ACTION_COPY.resetLocalState.label, DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, async () => { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } const result = await resetLocalState(); console.log( result.accountsCleared && diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index e5cc6bfc..4f2e840a 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -81,6 +81,12 @@ export interface DestructiveActionResult { quotaCacheCleared: boolean; } +function asError(error: unknown, fallbackMessage: string): Error { + return error instanceof Error + ? error + : new Error(`${fallbackMessage}: ${String(error)}`); +} + export async function deleteAccountAtIndex(options: { storage: AccountStorageV3; index: number; @@ -114,8 +120,25 @@ export async function deleteAccountAtIndex(options: { try { await saveFlaggedAccounts(updatedFlagged); } catch (error) { - await saveAccounts(previousStorage); - throw error; + const originalError = asError( + error, + "Failed to save flagged account storage after deleting an account", + ); + try { + await saveAccounts(previousStorage); + } catch (rollbackError) { + throw new AggregateError( + [ + originalError, + asError( + rollbackError, + "Failed to roll back account storage after flagged save failure", + ), + ], + "Deleting the account partially failed and rollback also failed.", + ); + } + throw originalError; } } @@ -134,8 +157,8 @@ export async function deleteAccountAtIndex(options: { export async function deleteSavedAccounts(): Promise { return { accountsCleared: await clearAccounts(), - flaggedCleared: true, - quotaCacheCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, }; } diff --git a/lib/quota-cache.ts b/lib/quota-cache.ts index d69b4e0f..0fa647c6 100644 --- a/lib/quota-cache.ts +++ b/lib/quota-cache.ts @@ -30,8 +30,7 @@ interface QuotaCacheFile { byEmail: Record; } -const QUOTA_CACHE_PATH = join(getCodexMultiAuthDir(), "quota-cache.json"); -const QUOTA_CACHE_LABEL = basename(QUOTA_CACHE_PATH); +const QUOTA_CACHE_FILE_NAME = "quota-cache.json"; const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); function isRetryableFsError(error: unknown): boolean { @@ -150,7 +149,11 @@ async function readCacheFileWithRetry(path: string): Promise { * @returns The absolute path to the quota-cache.json file */ export function getQuotaCachePath(): string { - return QUOTA_CACHE_PATH; + return join(getCodexMultiAuthDir(), QUOTA_CACHE_FILE_NAME); +} + +function getQuotaCacheLabel(path: string): string { + return basename(path); } /** @@ -172,12 +175,13 @@ export function getQuotaCachePath(): string { * will be empty if the on-disk file is absent, malformed, or could not be read. */ export async function loadQuotaCache(): Promise { - if (!existsSync(QUOTA_CACHE_PATH)) { + const quotaCachePath = getQuotaCachePath(); + if (!existsSync(quotaCachePath)) { return { byAccountId: {}, byEmail: {} }; } try { - const content = await readCacheFileWithRetry(QUOTA_CACHE_PATH); + const content = await readCacheFileWithRetry(quotaCachePath); const parsed = JSON.parse(content) as unknown; if (!isRecord(parsed)) { return { byAccountId: {}, byEmail: {} }; @@ -195,7 +199,7 @@ export async function loadQuotaCache(): Promise { }; } catch (error) { logWarn( - `Failed to load quota cache from ${QUOTA_CACHE_LABEL}: ${ + `Failed to load quota cache from ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); @@ -228,10 +232,11 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { byAccountId: data.byAccountId, byEmail: data.byEmail, }; + const quotaCachePath = getQuotaCachePath(); try { await fs.mkdir(getCodexMultiAuthDir(), { recursive: true }); - const tempPath = `${QUOTA_CACHE_PATH}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + const tempPath = `${quotaCachePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600, @@ -240,7 +245,7 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { try { for (let attempt = 0; attempt < 5; attempt += 1) { try { - await fs.rename(tempPath, QUOTA_CACHE_PATH); + await fs.rename(tempPath, quotaCachePath); renamed = true; break; } catch (error) { @@ -259,7 +264,7 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { } } catch (error) { logWarn( - `Failed to save quota cache to ${QUOTA_CACHE_LABEL}: ${ + `Failed to save quota cache to ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); @@ -270,9 +275,10 @@ export async function saveQuotaCache(data: QuotaCacheData): Promise { * Deletes the on-disk quota cache file, ignoring missing files and logging non-ENOENT errors. */ export async function clearQuotaCache(): Promise { + const quotaCachePath = getQuotaCachePath(); for (let attempt = 0; attempt < 5; attempt += 1) { try { - await fs.unlink(QUOTA_CACHE_PATH); + await fs.unlink(quotaCachePath); return true; } catch (error) { const code = (error as NodeJS.ErrnoException | undefined)?.code; @@ -281,7 +287,7 @@ export async function clearQuotaCache(): Promise { } if (!isRetryableFsError(error) || attempt >= 4) { logWarn( - `Failed to clear quota cache ${QUOTA_CACHE_LABEL}: ${ + `Failed to clear quota cache ${getQuotaCacheLabel(quotaCachePath)}: ${ error instanceof Error ? error.message : String(error) }`, ); diff --git a/lib/storage.ts b/lib/storage.ts index ddd681ed..f7496994 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -117,6 +117,31 @@ function withStorageLock(fn: () => Promise): Promise { return previousMutex.then(fn).finally(() => releaseLock()); } +async function unlinkWithRetry(path: string): Promise { + let lastError: NodeJS.ErrnoException | null = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.unlink(path); + return; + } catch (error) { + const unlinkError = error as NodeJS.ErrnoException; + const code = unlinkError.code; + if (code === "ENOENT") { + return; + } + if ((code === "EPERM" || code === "EBUSY") && attempt < 4) { + lastError = unlinkError; + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + continue; + } + throw unlinkError; + } + } + if (lastError) { + throw lastError; + } +} + type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { @@ -1312,7 +1337,7 @@ export async function clearAccounts(): Promise { let hadError = false; const clearPath = async (targetPath: string): Promise => { try { - await fs.unlink(targetPath); + await unlinkWithRetry(targetPath); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { @@ -1534,7 +1559,7 @@ export async function saveFlaggedAccounts( export async function clearFlaggedAccounts(): Promise { return withStorageLock(async () => { try { - await fs.unlink(getFlaggedAccountsPath()); + await unlinkWithRetry(getFlaggedAccountsPath()); return true; } catch (error) { const code = (error as NodeJS.ErrnoException).code; diff --git a/test/cli.test.ts b/test/cli.test.ts index d4852134..a2750841 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -173,6 +173,153 @@ describe("CLI Module", () => { expect(result).toEqual({ mode: "fresh", deleteAll: true }); }); + it("returns 'reset' for fallback reset confirmation", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + }); + + it("cancels fallback delete-all when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("fresh") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nDelete saved accounts cancelled.\n", + ); + }); + + it("cancels fallback reset when typed confirmation does not match", async () => { + mockRl.question + .mockResolvedValueOnce("reset") + .mockResolvedValueOnce("nope") + .mockResolvedValueOnce("a"); + const consoleSpy = vi.spyOn(console, "log"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + }); + + it("returns reset for TTY reset-all confirmation", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi.fn().mockResolvedValue({ type: "reset-all" }); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("RESET"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(showAuthMenuMock).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + + it("uses reset local state cancellation copy in TTY reset-all flow", async () => { + const { stdin, stdout } = await import("node:process"); + const origInputTTY = stdin.isTTY; + const origOutputTTY = stdout.isTTY; + const showAuthMenuMock = vi + .fn() + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "add" }); + const consoleSpy = vi.spyOn(console, "log"); + + Object.defineProperty(stdin, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: true, + writable: true, + configurable: true, + }); + + try { + vi.resetModules(); + vi.doMock("../lib/ui/auth-menu.js", async () => { + const actual = await vi.importActual("../lib/ui/auth-menu.js"); + return { + ...(actual as Record), + isTTY: vi.fn(() => true), + showAuthMenu: showAuthMenuMock, + }; + }); + mockRl.question.mockResolvedValueOnce("nope"); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "add" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + } finally { + vi.doUnmock("../lib/ui/auth-menu.js"); + Object.defineProperty(stdin, "isTTY", { + value: origInputTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(stdout, "isTTY", { + value: origOutputTTY, + writable: true, + configurable: true, + }); + } + }); + it("returns 'verify-flagged' for 'g' input", async () => { mockRl.question.mockResolvedValueOnce("g"); @@ -612,28 +759,3 @@ describe("CLI Module", () => { }); }); }); -it("cancels fallback delete-all when typed confirmation does not match", async () => { - vi.mocked(createInterface).mockReturnValue(mockRl as any); - mockRl.question - .mockResolvedValueOnce("fresh") - .mockResolvedValueOnce("nope") - .mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); -}); - -it("cancels fallback reset when typed confirmation does not match", async () => { - vi.mocked(createInterface).mockReturnValue(mockRl as any); - mockRl.question - .mockResolvedValueOnce("reset") - .mockResolvedValueOnce("nope") - .mockResolvedValueOnce("a"); - - const { promptLoginMode } = await import("../lib/cli.js"); - const result = await promptLoginMode([{ index: 0 }]); - - expect(result).toEqual({ mode: "add" }); -}); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 6b7bf384..cd23440b 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2259,6 +2259,89 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("waits for an in-flight menu quota refresh before resetting local state", async () => { + const now = Date.now(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const fetchStarted = createDeferred(); + const fetchDeferred = createDeferred<{ + status: number; + model: string; + primary: Record; + secondary: Record; + }>(); + + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + accountId: "acc-first", + accessToken: "access-first", + expiresAt: now + 3_600_000, + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + enabled: true, + }, + ], + }); + loadDashboardDisplaySettingsMock.mockResolvedValue({ + showPerAccountRows: true, + showQuotaDetails: true, + showForecastReasons: true, + showRecommendations: true, + showLiveProbeNotes: true, + menuAutoFetchLimits: true, + menuShowFetchStatus: true, + menuQuotaTtlMs: 60_000, + menuSortEnabled: true, + menuSortMode: "ready-first", + menuSortPinCurrent: true, + menuSortQuickSwitchVisibleRow: true, + }); + loadQuotaCacheMock.mockResolvedValue({ + byAccountId: {}, + byEmail: {}, + }); + fetchCodexQuotaSnapshotMock.mockImplementation(async () => { + fetchStarted.resolve(); + return fetchDeferred.promise; + }); + promptLoginModeMock + .mockResolvedValueOnce({ mode: "reset" }) + .mockResolvedValueOnce({ mode: "cancel" }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const runPromise = runCodexMultiAuthCli(["auth", "login"]); + + await fetchStarted.promise; + await Promise.resolve(); + + expect(resetLocalStateMock).not.toHaveBeenCalled(); + + fetchDeferred.resolve({ + status: 200, + model: "gpt-5-codex", + primary: {}, + secondary: {}, + }); + + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(saveQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(saveQuotaCacheMock.mock.invocationCallOrder[0]).toBeLessThan( + resetLocalStateMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(logSpy).toHaveBeenCalledWith( + "Reset local state. Saved accounts, flagged/problem accounts, and quota cache cleared; settings and Codex CLI sync state kept.", + ); + logSpy.mockRestore(); + }); + it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ diff --git a/test/storage-flagged.test.ts b/test/storage-flagged.test.ts index 7fce9e18..a97bd58c 100644 --- a/test/storage-flagged.test.ts +++ b/test/storage-flagged.test.ts @@ -169,6 +169,85 @@ describe("flagged account storage", () => { expect(existsSync(getFlaggedAccountsPath())).toBe(false); }); + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing flagged storage", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "retry-me", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const flaggedPath = getFlaggedAccountsPath(); + const realUnlink = fs.unlink.bind(fs); + let failedOnce = false; + const unlinkSpy = vi.spyOn(fs, "unlink").mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath && !failedOnce) { + failedOnce = true; + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return realUnlink(targetPath); + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(true); + expect(existsSync(flaggedPath)).toBe(false); + expect( + unlinkSpy.mock.calls.filter(([targetPath]) => targetPath === flaggedPath), + ).toHaveLength(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when clearing flagged storage exhausts retryable %s failures", + async (code) => { + await saveFlaggedAccounts({ + version: 1, + accounts: [ + { + refreshToken: "stuck-flagged", + flaggedAt: 1, + addedAt: 1, + lastUsed: 1, + }, + ], + }); + + const flaggedPath = getFlaggedAccountsPath(); + const unlinkSpy = vi.spyOn(fs, "unlink").mockImplementation(async (targetPath) => { + if (targetPath === flaggedPath) { + const error = new Error("still locked") as NodeJS.ErrnoException; + error.code = code; + throw error; + } + const error = new Error("missing") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + }); + + try { + await expect(clearFlaggedAccounts()).resolves.toBe(false); + expect(existsSync(flaggedPath)).toBe(true); + expect( + unlinkSpy.mock.calls.filter(([targetPath]) => targetPath === flaggedPath), + ).toHaveLength(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + it("cleans temporary file when flagged save fails", async () => { const flaggedPath = getFlaggedAccountsPath(); const originalRename = fs.rename.bind(fs); From 65371357b6e411ac8266477e61fe4f3f5d74e10a Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 22:50:46 +0800 Subject: [PATCH 07/11] fix(auth): close remaining destructive-action review gaps --- index.ts | 5 +- lib/codex-manager.ts | 2 +- lib/destructive-actions.ts | 18 +++++ lib/storage.ts | 5 -- test/codex-manager-cli.test.ts | 4 +- test/destructive-actions.test.ts | 81 +++++++++++++++++++++++ test/quota-cache.test.ts | 83 +++++++++++++++++++++++- test/release-main-prs-regression.test.ts | 2 +- 8 files changed, 185 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index 764b8a21..0565aa8c 100644 --- a/index.ts +++ b/index.ts @@ -3112,10 +3112,7 @@ while (attempted.size < Math.max(1, accountCount)) { }); if (deleted) { invalidateAccountManagerCache(); - const label = formatAccountLabel( - deleted.removedAccount, - menuResult.deleteAccountIndex, - ); + const label = `Account ${menuResult.deleteAccountIndex + 1}`; const flaggedNote = deleted.removedFlaggedCount > 0 ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 2f4fa218..748e0f91 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -3758,7 +3758,7 @@ async function handleManageAction( flaggedStorage, }); if (deleted) { - const label = formatAccountLabel(deleted.removedAccount, idx); + const label = `Account ${idx + 1}`; const flaggedNote = deleted.removedFlaggedCount > 0 ? ` Removed ${deleted.removedFlaggedCount} matching problem account${deleted.removedFlaggedCount === 1 ? "" : "s"}.` diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 4f2e840a..4df11872 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -68,6 +68,23 @@ export function clampActiveIndices(storage: AccountStorageV3): void { storage.activeIndexByFamily = activeIndexByFamily; } +function rebaseActiveIndicesAfterDelete( + storage: AccountStorageV3, + removedIndex: number, +): void { + if (storage.activeIndex > removedIndex) { + storage.activeIndex -= 1; + } + const activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of MODEL_FAMILIES) { + const rawIndex = activeIndexByFamily[family]; + if (typeof rawIndex === "number" && Number.isFinite(rawIndex) && rawIndex > removedIndex) { + activeIndexByFamily[family] = rawIndex - 1; + } + } + storage.activeIndexByFamily = activeIndexByFamily; +} + export interface DeleteAccountResult { storage: AccountStorageV3; flagged: FlaggedAccountStorageV1; @@ -107,6 +124,7 @@ export async function deleteAccountAtIndex(options: { }; nextStorage.accounts.splice(options.index, 1); + rebaseActiveIndicesAfterDelete(nextStorage, options.index); clampActiveIndices(nextStorage); await saveAccounts(nextStorage); diff --git a/lib/storage.ts b/lib/storage.ts index e81c5e7c..a54ee97f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -180,7 +180,6 @@ function withStorageLock(fn: () => Promise): Promise { } async function unlinkWithRetry(path: string): Promise { - let lastError: NodeJS.ErrnoException | null = null; for (let attempt = 0; attempt < 5; attempt += 1) { try { await fs.unlink(path); @@ -192,16 +191,12 @@ async function unlinkWithRetry(path: string): Promise { return; } if ((code === "EPERM" || code === "EBUSY") && attempt < 4) { - lastError = unlinkError; await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); continue; } throw unlinkError; } } - if (lastError) { - throw lastError; - } } type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 70bca821..fc2d16cc 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -459,8 +459,8 @@ describe("codex manager cli commands", () => { deleteAccountAtIndexMock.mockResolvedValue(null); deleteSavedAccountsMock.mockResolvedValue({ accountsCleared: true, - flaggedCleared: true, - quotaCacheCleared: true, + flaggedCleared: false, + quotaCacheCleared: false, }); resetLocalStateMock.mockResolvedValue({ accountsCleared: true, diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 6516ae96..04abc093 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -56,6 +56,87 @@ describe("destructive actions", () => { expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); }); + it("returns reset results and clears Codex CLI state", async () => { + clearAccountsMock.mockResolvedValueOnce(true); + clearFlaggedAccountsMock.mockResolvedValueOnce(false); + clearQuotaCacheMock.mockResolvedValueOnce(true); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).resolves.toEqual({ + accountsCleared: true, + flaggedCleared: false, + quotaCacheCleared: true, + }); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).toHaveBeenCalledTimes(1); + expect(clearCodexCliStateCacheMock).toHaveBeenCalledTimes(1); + }); + + it("does not clear Codex CLI state when resetLocalState aborts on an exception", async () => { + const resetError = Object.assign(new Error("flagged clear failed"), { + code: "EPERM", + }); + clearFlaggedAccountsMock.mockRejectedValueOnce(resetError); + + const { resetLocalState } = await import("../lib/destructive-actions.js"); + + await expect(resetLocalState()).rejects.toBe(resetError); + expect(clearAccountsMock).toHaveBeenCalledTimes(1); + expect(clearFlaggedAccountsMock).toHaveBeenCalledTimes(1); + expect(clearQuotaCacheMock).not.toHaveBeenCalled(); + expect(clearCodexCliStateCacheMock).not.toHaveBeenCalled(); + }); + + it("re-bases active indices before clamping when deleting an earlier account", async () => { + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 1, + activeIndexByFamily: { codex: 2, "gpt-5.x": 1 }, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-active", + addedAt: 2, + lastUsed: 2, + }, + { + refreshToken: "refresh-other", + addedAt: 3, + lastUsed: 3, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 0 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.storage.accounts.map((account) => account.refreshToken)).toEqual([ + "refresh-active", + "refresh-other", + ]); + expect(deleted?.storage.activeIndex).toBe(0); + expect(deleted?.storage.activeIndexByFamily).toEqual({ + codex: 1, + "gpt-5.x": 0, + }); + expect(saveAccountsMock).toHaveBeenCalledWith( + expect.objectContaining({ + activeIndex: 0, + activeIndexByFamily: { codex: 1, "gpt-5.x": 0 }, + }), + ); + }); + it("rethrows the original flagged-save failure after a successful rollback", async () => { const flaggedSaveError = Object.assign(new Error("flagged save failed"), { code: "EPERM", diff --git a/test/quota-cache.test.ts b/test/quota-cache.test.ts index b296a588..fd712784 100644 --- a/test/quota-cache.test.ts +++ b/test/quota-cache.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("quota cache", () => { let tempDir: string; @@ -20,7 +21,7 @@ describe("quota cache", () => { } else { process.env.CODEX_MULTI_AUTH_DIR = originalDir; } - await fs.rm(tempDir, { recursive: true, force: true }); + await removeWithRetry(tempDir, { recursive: true, force: true }); }); it("returns empty cache by default", async () => { @@ -100,10 +101,88 @@ describe("quota cache", () => { await clearQuotaCache(); await expect(fs.access(nextPath)).rejects.toThrow(); } finally { - await fs.rm(nextTempDir, { recursive: true, force: true }); + await removeWithRetry(nextTempDir, { recursive: true, force: true }); } }); + it.each(["EBUSY", "EPERM"] as const)( + "retries transient %s while clearing cache", + async (code) => { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + const realUnlink = fs.unlink.bind(fs); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + if (attempts < 3) { + const error = new Error( + `unlink failed: ${code}`, + ) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + } + return realUnlink(...args); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + await expect(fs.access(quotaCachePath)).rejects.toThrow(); + expect(attempts).toBe(3); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EBUSY", "EPERM"] as const)( + "returns false when clearQuotaCache exhausts %s retries", + async (code) => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + + try { + const { clearQuotaCache, getQuotaCachePath, saveQuotaCache } = + await import("../lib/quota-cache.js"); + await saveQuotaCache({ byAccountId: {}, byEmail: {} }); + const quotaCachePath = getQuotaCachePath(); + let attempts = 0; + const unlinkSpy = vi.spyOn(fs, "unlink"); + unlinkSpy.mockImplementation(async (...args) => { + if (String(args[0]) === quotaCachePath) { + attempts += 1; + const error = new Error(`locked: ${code}`) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return Promise.resolve(); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(attempts).toBe(5); + await expect(fs.access(quotaCachePath)).resolves.toBeUndefined(); + expect(warnMock).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to clear quota cache quota-cache.json: locked: ${code}`, + ), + ); + } finally { + unlinkSpy.mockRestore(); + } + } finally { + vi.doUnmock("../lib/logger.js"); + } + }, + ); + it("retries transient EBUSY while loading cache", async () => { const { loadQuotaCache, getQuotaCachePath } = await import("../lib/quota-cache.js"); diff --git a/test/release-main-prs-regression.test.ts b/test/release-main-prs-regression.test.ts index 6eb7929b..bd4d680b 100644 --- a/test/release-main-prs-regression.test.ts +++ b/test/release-main-prs-regression.test.ts @@ -199,7 +199,7 @@ describe("release-main-prs regressions", () => { return originalUnlink(targetPath); }); - await expect(clearFlaggedAccounts()).rejects.toThrow("EPERM primary delete"); + await expect(clearFlaggedAccounts()).resolves.toBe(false); const flagged = await loadFlaggedAccounts(); const syncResult = await syncAccountStorageFromCodexCli(null); From 010568a0e25a94ca1554d2ab94ede71a930eed4c Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 22:59:51 +0800 Subject: [PATCH 08/11] fix(ui): streamline destructive confirmations --- lib/ui/auth-menu.ts | 20 +++-------- test/auth-menu-hotkeys.test.ts | 63 ++++++++++++++++++++++++++++++++++ test/cli-auth-menu.test.ts | 28 +++++++++++++++ 3 files changed, 96 insertions(+), 15 deletions(-) diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index de2546a8..a28cb113 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -1,6 +1,5 @@ import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; -import { DESTRUCTIVE_ACTION_COPY } from "../destructive-actions.js"; import { ANSI, isTTY } from "./ansi.js"; import { confirm } from "./confirm.js"; import { formatCheckFlaggedLabel, UI_COPY } from "./copy.js"; @@ -82,6 +81,9 @@ export type AccountAction = | "set-current" | "cancel"; +const ANSI_ESCAPE_PATTERN = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"); +const CONTROL_CHAR_PATTERN = new RegExp("[\\u0000-\\u001f\\u007f]", "g"); + function resolveCliVersionLabel(): string | null { const raw = (process.env.CODEX_MULTI_AUTH_CLI_VERSION ?? "").trim(); if (raw.length === 0) return null; @@ -97,8 +99,8 @@ function mainMenuTitleWithVersion(): string { function sanitizeTerminalText(value: string | undefined): string | undefined { if (!value) return undefined; return value - .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHAR_PATTERN, "") .trim(); } @@ -774,18 +776,6 @@ export async function showAuthMenu( focusKey = "action:search"; continue; } - if (result.type === "delete-all") { - const confirmed = await confirm( - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.confirm, - ); - if (!confirmed) continue; - } - if (result.type === "reset-all") { - const confirmed = await confirm( - DESTRUCTIVE_ACTION_COPY.resetLocalState.confirm, - ); - if (!confirmed) continue; - } if (result.type === "delete-account") { const confirmed = await confirm( `Delete ${accountTitle(result.account)}?`, diff --git a/test/auth-menu-hotkeys.test.ts b/test/auth-menu-hotkeys.test.ts index 75ec2269..ada4583a 100644 --- a/test/auth-menu-hotkeys.test.ts +++ b/test/auth-menu-hotkeys.test.ts @@ -197,4 +197,67 @@ describe("auth-menu hotkeys", () => { const options = selectMock.mock.calls[0]?.[1] as { message?: string }; expect(options?.message).toBe("Accounts Dashboard (v0.1.6)"); }); + + it("returns delete-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "delete-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "delete-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("returns reset-all without an extra confirm prompt", async () => { + selectMock.mockResolvedValueOnce({ type: "reset-all" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts()); + + expect(result).toEqual({ type: "reset-all" }); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("sanitizes ANSI and control characters in rendered account labels", async () => { + const baseTime = 1_700_000_000_000; + selectMock.mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + await showAuthMenu([ + { + index: 0, + email: "\u001b[31mfirst@example.com\u0000", + status: "ok", + lastUsed: baseTime, + }, + { + index: 1, + accountLabel: "\u001b[32mFriendly \r\nLabel\u007f", + status: "ok", + lastUsed: baseTime, + }, + { + index: 2, + email: "", + accountLabel: " \r\n ", + accountId: "\u001b[33macc-id-42\u0007", + status: "ok", + lastUsed: baseTime, + }, + ]); + + const items = (selectMock.mock.calls[0]?.[0] as Array<{ + label: string; + value: { type: string }; + }>).filter((item) => item.value.type === "select-account"); + const labels = items.map((item) => item.label); + const strippedLabels = labels.map((label) => + label.replace(new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g"), ""), + ); + + expect(strippedLabels[0]).toContain("1. first@example.com"); + expect(strippedLabels[1]).toContain("2. Friendly Label"); + expect(strippedLabels[2]).toContain("3. acc-id-42"); + expect(strippedLabels.join("")).not.toMatch(/[\u0000\u0007\u007f]/); + }); }); diff --git a/test/cli-auth-menu.test.ts b/test/cli-auth-menu.test.ts index 0f06f2c3..e5563aa3 100644 --- a/test/cli-auth-menu.test.ts +++ b/test/cli-auth-menu.test.ts @@ -294,6 +294,34 @@ describe("CLI auth menu shortcuts", () => { consoleSpy.mockRestore(); }); + it("returns reset mode when reset-all is confirmed", async () => { + mockRl.question.mockResolvedValueOnce("RESET"); + showAuthMenu.mockResolvedValueOnce({ type: "reset-all" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "reset" }); + expect(mockRl.close).toHaveBeenCalled(); + }); + + it("cancels reset-all when typed confirmation is not RESET", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + mockRl.question.mockResolvedValueOnce("nope"); + showAuthMenu + .mockResolvedValueOnce({ type: "reset-all" }) + .mockResolvedValueOnce({ type: "cancel" }); + + const { promptLoginMode } = await import("../lib/cli.js"); + const result = await promptLoginMode([{ index: 0 }]); + + expect(result).toEqual({ mode: "cancel" }); + expect(consoleSpy).toHaveBeenCalledWith( + "\nReset local state cancelled.\n", + ); + consoleSpy.mockRestore(); + }); + it("cancels fresh action when typed confirmation is not DELETE", async () => { const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); mockRl.question.mockResolvedValueOnce("abort"); From 4ec0329006736d3b22625d6e9e850faf67ebfdfc Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 23:13:01 +0800 Subject: [PATCH 09/11] fix(auth): serialize destructive login actions --- lib/codex-manager.ts | 84 +++++++++++++++++++++------------- test/codex-manager-cli.test.ts | 73 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 32 deletions(-) diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 748e0f91..4119195a 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -92,6 +92,8 @@ type TokenSuccessWithAccount = TokenSuccess & { }; type PromptTone = "accent" | "success" | "warning" | "danger" | "muted"; +let destructiveActionInFlight = false; + function stylePromptText(text: string, tone: PromptTone): string { if (!output.isTTY) return text; const ui = getUiRuntimeOptions(); @@ -3892,41 +3894,59 @@ async function runAuthLogin(): Promise { continue; } if (menuResult.mode === "fresh" && menuResult.deleteAll) { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, - DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, - async () => { - const result = await deleteSavedAccounts(); - console.log( - result.accountsCleared - ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed - : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); + continue; + } + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.label, + DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.stage, + async () => { + const result = await deleteSavedAccounts(); + console.log( + result.accountsCleared + ? DESTRUCTIVE_ACTION_COPY.deleteSavedAccounts.completed + : "Delete saved accounts completed with warnings. Some saved account artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; + } continue; } if (menuResult.mode === "reset") { - await runActionPanel( - DESTRUCTIVE_ACTION_COPY.resetLocalState.label, - DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, - async () => { - const pendingQuotaRefresh = pendingMenuQuotaRefresh; - if (pendingQuotaRefresh) { - await pendingQuotaRefresh; - } - const result = await resetLocalState(); - console.log( - result.accountsCleared && - result.flaggedCleared && - result.quotaCacheCleared - ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed - : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", - ); - }, - displaySettings, - ); + if (destructiveActionInFlight) { + console.log("Another destructive action is already running. Wait for it to finish."); + continue; + } + destructiveActionInFlight = true; + try { + await runActionPanel( + DESTRUCTIVE_ACTION_COPY.resetLocalState.label, + DESTRUCTIVE_ACTION_COPY.resetLocalState.stage, + async () => { + const pendingQuotaRefresh = pendingMenuQuotaRefresh; + if (pendingQuotaRefresh) { + await pendingQuotaRefresh; + } + const result = await resetLocalState(); + console.log( + result.accountsCleared && + result.flaggedCleared && + result.quotaCacheCleared + ? DESTRUCTIVE_ACTION_COPY.resetLocalState.completed + : "Reset local state completed with warnings. Some local artifacts could not be removed; see logs.", + ); + }, + displaySettings, + ); + } finally { + destructiveActionInFlight = false; + } continue; } if (menuResult.mode === "manage") { diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index fc2d16cc..30d4c8c4 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3443,6 +3443,79 @@ describe("codex manager cli commands", () => { logSpy.mockRestore(); }); + it("skips a second destructive action while reset is already running", async () => { + const now = Date.now(); + const skipMessage = + "Another destructive action is already running. Wait for it to finish."; + const secondMenuAttempted = createDeferred(); + const skipLogged = createDeferred(); + const logSpy = vi.spyOn(console, "log").mockImplementation((message?: unknown) => { + if (message === skipMessage) { + skipLogged.resolve(); + } + }); + const firstResetStarted = createDeferred(); + const allowFirstResetToFinish = createDeferred(); + let menuPromptCall = 0; + + loadAccountsMock.mockImplementation(async () => ({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "first@example.com", + refreshToken: "refresh-first", + addedAt: now, + lastUsed: now, + }, + ], + })); + promptLoginModeMock.mockImplementation(async () => { + menuPromptCall += 1; + if (menuPromptCall === 2) { + secondMenuAttempted.resolve(); + } + if (menuPromptCall <= 2) { + return { mode: "reset" }; + } + return { mode: "cancel" }; + }); + resetLocalStateMock.mockImplementationOnce(async () => { + firstResetStarted.resolve(); + await allowFirstResetToFinish.promise; + return { + accountsCleared: true, + flaggedCleared: true, + quotaCacheCleared: true, + }; + }); + + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const firstRunPromise = runCodexMultiAuthCli(["auth", "login"]); + + await firstResetStarted.promise; + + const secondRunPromise = runCodexMultiAuthCli(["auth", "login"]); + await secondMenuAttempted.promise; + await skipLogged.promise; + + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + + allowFirstResetToFinish.resolve(); + + const [firstExitCode, secondExitCode] = await Promise.all([ + firstRunPromise, + secondRunPromise, + ]); + + expect(firstExitCode).toBe(0); + expect(secondExitCode).toBe(0); + expect(resetLocalStateMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(skipMessage); + logSpy.mockRestore(); + }); + it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue( From b0c83b9da387e8d50b7ccb9dedab46798c5edad1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 13 Mar 2026 23:23:02 +0800 Subject: [PATCH 10/11] fix(storage): reuse shared rename retries --- lib/storage.ts | 39 ++++++++++++--------------------------- test/storage.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index a54ee97f..36ee2aa0 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1854,34 +1854,19 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { throw emptyError; } - // Retry rename with exponential backoff for Windows EPERM/EBUSY - let lastError: NodeJS.ErrnoException | null = null; - for (let attempt = 0; attempt < 5; attempt++) { - try { - await fs.rename(tempPath, path); - try { - await fs.unlink(resetMarkerPath); - } catch { - // Best effort cleanup. - } - lastAccountsSaveTimestamp = Date.now(); - try { - await fs.unlink(walPath); - } catch { - // Best effort cleanup. - } - return; - } catch (renameError) { - const code = (renameError as NodeJS.ErrnoException).code; - if (code === "EPERM" || code === "EBUSY") { - lastError = renameError as NodeJS.ErrnoException; - await new Promise((r) => setTimeout(r, 10 * 2 ** attempt)); - continue; - } - throw renameError; - } + await renameFileWithRetry(tempPath, path); + try { + await fs.unlink(resetMarkerPath); + } catch { + // Best effort cleanup. + } + lastAccountsSaveTimestamp = Date.now(); + try { + await fs.unlink(walPath); + } catch { + // Best effort cleanup. } - if (lastError) throw lastError; + return; } catch (error) { try { await fs.unlink(tempPath); diff --git a/test/storage.test.ts b/test/storage.test.ts index 9ca00ad8..81d73663 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2799,6 +2799,37 @@ describe("storage", () => { renameSpy.mockRestore(); }); + it("retries on EAGAIN and cleans up the WAL after rename succeeds", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const walPath = `${testStoragePath}.wal`; + + const originalRename = fs.rename.bind(fs); + let attemptCount = 0; + const renameSpy = vi + .spyOn(fs, "rename") + .mockImplementation(async (oldPath, newPath) => { + attemptCount++; + if (attemptCount === 1) { + const err = new Error("EAGAIN error") as NodeJS.ErrnoException; + err.code = "EAGAIN"; + throw err; + } + return originalRename(oldPath as string, newPath as string); + }); + + await saveAccounts(storage); + expect(attemptCount).toBe(2); + expect(existsSync(testStoragePath)).toBe(true); + expect(existsSync(walPath)).toBe(false); + + renameSpy.mockRestore(); + }); + it("throws after 5 failed EPERM retries", async () => { const now = Date.now(); const storage = { From d67ca3d786c527ede1c1643bd327408a007e246f Mon Sep 17 00:00:00 2001 From: ndycode Date: Sat, 14 Mar 2026 00:05:26 +0800 Subject: [PATCH 11/11] fix(storage): close remaining review gaps --- index.ts | 1 - lib/cli.ts | 4 +- lib/codex-manager.ts | 7 +- lib/destructive-actions.ts | 3 +- lib/storage.ts | 17 +++-- lib/ui/auth-menu.ts | 12 ++-- test/auth-menu-hotkeys.test.ts | 40 ++++++++++++ test/cli-auth-menu.test.ts | 4 +- test/codex-manager-cli.test.ts | 8 +++ test/destructive-actions.test.ts | 53 +++++++++++++++ test/storage.test.ts | 109 ++++++++++++++++++++++++------- 11 files changed, 211 insertions(+), 47 deletions(-) diff --git a/index.ts b/index.ts index 0565aa8c..1db25ec2 100644 --- a/index.ts +++ b/index.ts @@ -3108,7 +3108,6 @@ while (attempted.size < Math.max(1, accountCount)) { const deleted = await deleteAccountAtIndex({ storage: workingStorage, index: menuResult.deleteAccountIndex, - flaggedStorage, }); if (deleted) { invalidateAccountManagerCache(); diff --git a/lib/cli.ts b/lib/cli.ts index 8fe514fb..363b1b2b 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -271,7 +271,7 @@ export async function promptLoginMode( return { mode: "settings" }; case "fresh": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; @@ -344,7 +344,7 @@ export async function promptLoginMode( continue; case "delete-all": if (!(await promptDeleteAllTypedConfirm())) { - console.log("\nDelete all cancelled.\n"); + console.log("\nDelete saved accounts cancelled.\n"); continue; } return { mode: "fresh", deleteAll: true }; diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 4119195a..0f4667b8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -68,7 +68,6 @@ import { type AccountMetadataV3, type AccountStorageV3, type FlaggedAccountMetadataV1, - type FlaggedAccountStorageV1, } from "./storage.js"; import type { AccountIdSource, TokenFailure, TokenResult } from "./types.js"; import { @@ -3743,7 +3742,6 @@ async function runDoctor(args: string[]): Promise { async function handleManageAction( storage: AccountStorageV3, menuResult: Awaited>, - flaggedStorage?: FlaggedAccountStorageV1, ): Promise { if (typeof menuResult.switchAccountIndex === "number") { const index = menuResult.switchAccountIndex; @@ -3757,7 +3755,6 @@ async function handleManageAction( const deleted = await deleteAccountAtIndex({ storage, index: idx, - flaggedStorage, }); if (deleted) { const label = `Account ${idx + 1}`; @@ -3952,11 +3949,11 @@ async function runAuthLogin(): Promise { if (menuResult.mode === "manage") { const requiresInteractiveOAuth = typeof menuResult.refreshAccountIndex === "number"; if (requiresInteractiveOAuth) { - await handleManageAction(currentStorage, menuResult, flaggedStorage); + await handleManageAction(currentStorage, menuResult); continue; } await runActionPanel("Applying Change", "Updating selected account", async () => { - await handleManageAction(currentStorage, menuResult, flaggedStorage); + await handleManageAction(currentStorage, menuResult); }, displaySettings); continue; } diff --git a/lib/destructive-actions.ts b/lib/destructive-actions.ts index 4df11872..66a8571e 100644 --- a/lib/destructive-actions.ts +++ b/lib/destructive-actions.ts @@ -107,11 +107,10 @@ function asError(error: unknown, fallbackMessage: string): Error { export async function deleteAccountAtIndex(options: { storage: AccountStorageV3; index: number; - flaggedStorage?: FlaggedAccountStorageV1; }): Promise { const target = options.storage.accounts.at(options.index); if (!target) return null; - const flagged = options.flaggedStorage ?? (await loadFlaggedAccounts()); + const flagged = await loadFlaggedAccounts(); const nextStorage: AccountStorageV3 = { ...options.storage, accounts: options.storage.accounts.map((account) => ({ ...account })), diff --git a/lib/storage.ts b/lib/storage.ts index 36ee2aa0..3435bf44 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -190,7 +190,7 @@ async function unlinkWithRetry(path: string): Promise { if (code === "ENOENT") { return; } - if ((code === "EPERM" || code === "EBUSY") && attempt < 4) { + if ((code === "EPERM" || code === "EBUSY" || code === "EAGAIN") && attempt < 4) { await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); continue; } @@ -2008,6 +2008,14 @@ export async function clearAccounts(): Promise { const walPath = getAccountsWalPath(path); const backupPaths = await getAccountsBackupRecoveryCandidatesWithDiscovery(path); + const legacyPaths = Array.from( + new Set( + [currentLegacyProjectStoragePath, currentLegacyWorktreeStoragePath].filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.length > 0, + ), + ), + ); await fs.writeFile( resetMarkerPath, JSON.stringify({ version: 1, createdAt: Date.now() }), @@ -2030,11 +2038,8 @@ export async function clearAccounts(): Promise { }; try { - await Promise.all([ - clearPath(path), - clearPath(walPath), - ...backupPaths.map(clearPath), - ]); + const artifacts = Array.from(new Set([path, walPath, ...backupPaths, ...legacyPaths])); + await Promise.all(artifacts.map(clearPath)); } catch { // Individual path cleanup is already best-effort with per-artifact logging. } diff --git a/lib/ui/auth-menu.ts b/lib/ui/auth-menu.ts index a28cb113..fbe9293a 100644 --- a/lib/ui/auth-menu.ts +++ b/lib/ui/auth-menu.ts @@ -494,7 +494,7 @@ async function promptSearchQuery(current: string): Promise { try { const suffix = current ? ` (${current})` : ""; const answer = await rl.question(`Search${suffix} (blank clears): `); - return answer.trim().toLowerCase(); + return (sanitizeTerminalText(answer) ?? "").toLowerCase(); } finally { rl.close(); } @@ -685,15 +685,15 @@ export async function showAuthMenu( typeof options.statusMessage === "function" ? options.statusMessage() : options.statusMessage; - return typeof raw === "string" && raw.trim().length > 0 - ? raw.trim() - : undefined; + const sanitized = typeof raw === "string" ? sanitizeTerminalText(raw) : undefined; + return sanitized && sanitized.length > 0 ? sanitized : undefined; }; const buildSubtitle = (): string | undefined => { const parts: string[] = []; - if (normalizedSearch.length > 0) { + const safeSearch = sanitizeTerminalText(normalizedSearch); + if (safeSearch && safeSearch.length > 0) { parts.push( - `${UI_COPY.mainMenu.searchSubtitlePrefix} ${normalizedSearch}`, + `${UI_COPY.mainMenu.searchSubtitlePrefix} ${safeSearch}`, ); } const statusText = resolveStatusMessage(); diff --git a/test/auth-menu-hotkeys.test.ts b/test/auth-menu-hotkeys.test.ts index ada4583a..91413196 100644 --- a/test/auth-menu-hotkeys.test.ts +++ b/test/auth-menu-hotkeys.test.ts @@ -3,6 +3,8 @@ import type { AccountInfo } from "../lib/ui/auth-menu.js"; const selectMock = vi.fn(); const confirmMock = vi.fn(async () => true); +const searchQuestionMock = vi.fn(); +const searchCloseMock = vi.fn(); vi.mock("../lib/ui/select.js", () => ({ select: selectMock, @@ -12,6 +14,13 @@ vi.mock("../lib/ui/confirm.js", () => ({ confirm: confirmMock, })); +vi.mock("node:readline/promises", () => ({ + createInterface: vi.fn(() => ({ + question: searchQuestionMock, + close: searchCloseMock, + })), +})); + function createAccounts(): AccountInfo[] { const baseTime = 1_700_000_000_000; return [ @@ -27,6 +36,8 @@ describe("auth-menu hotkeys", () => { vi.resetModules(); selectMock.mockReset(); confirmMock.mockReset(); + searchQuestionMock.mockReset(); + searchCloseMock.mockReset(); confirmMock.mockResolvedValue(true); previousCliVersion = process.env.CODEX_MULTI_AUTH_CLI_VERSION; delete process.env.CODEX_MULTI_AUTH_CLI_VERSION; @@ -138,6 +149,34 @@ describe("auth-menu hotkeys", () => { expect(selectMock).toHaveBeenCalledTimes(2); }); + it("sanitizes search subtitles and status messages", async () => { + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + searchQuestionMock.mockResolvedValueOnce(" \u001b[31mNeedle\u0007 "); + selectMock + .mockImplementationOnce( + async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { + if (!options.onInput) return null; + return options.onInput("/", { + cursor: 0, + items, + requestRerender: () => undefined, + }); + }, + ) + .mockResolvedValueOnce({ type: "cancel" }); + + const { showAuthMenu } = await import("../lib/ui/auth-menu.js"); + const result = await showAuthMenu(createAccounts(), { + statusMessage: () => "\u001b[32mNeeds\u0000 attention\u0007 ", + }); + + expect(result).toEqual({ type: "cancel" }); + expect(searchCloseMock).toHaveBeenCalledTimes(1); + const options = selectMock.mock.calls[1]?.[1] as { subtitle?: string }; + expect(options.subtitle).toBe("Search: needle | Needs attention"); + }); + it("supports help toggle hotkey (?) and requests rerender", async () => { let rerenderCalls = 0; selectMock.mockImplementationOnce(async (items: unknown[], options: { onInput?: (...args: unknown[]) => unknown }) => { @@ -258,6 +297,7 @@ describe("auth-menu hotkeys", () => { expect(strippedLabels[0]).toContain("1. first@example.com"); expect(strippedLabels[1]).toContain("2. Friendly Label"); expect(strippedLabels[2]).toContain("3. acc-id-42"); + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional test assertion expect(strippedLabels.join("")).not.toMatch(/[\u0000\u0007\u007f]/); }); }); diff --git a/test/cli-auth-menu.test.ts b/test/cli-auth-menu.test.ts index e5563aa3..cf76445c 100644 --- a/test/cli-auth-menu.test.ts +++ b/test/cli-auth-menu.test.ts @@ -290,7 +290,7 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); consoleSpy.mockRestore(); }); @@ -333,7 +333,7 @@ describe("CLI auth menu shortcuts", () => { const result = await promptLoginMode([{ index: 0 }]); expect(result).toEqual({ mode: "cancel" }); - expect(consoleSpy).toHaveBeenCalledWith("\nDelete all cancelled.\n"); + expect(consoleSpy).toHaveBeenCalledWith("\nDelete saved accounts cancelled.\n"); consoleSpy.mockRestore(); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 30d4c8c4..6358465e 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -157,12 +157,20 @@ vi.mock("../lib/destructive-actions.js", () => ({ DESTRUCTIVE_ACTION_COPY: { deleteSavedAccounts: { label: "Delete Saved Accounts", + typedConfirm: + "Type DELETE to delete saved accounts only (saved accounts: delete; flagged/problem accounts, settings, and Codex CLI sync state: keep): ", + confirm: + "Delete saved accounts? (Saved accounts: delete. Flagged/problem accounts: keep. Settings: keep. Codex CLI sync state: keep.)", stage: "Deleting saved accounts only", completed: "Deleted saved accounts. Saved accounts deleted; flagged/problem accounts, settings, and Codex CLI sync state kept.", }, resetLocalState: { label: "Reset Local State", + typedConfirm: + "Type RESET to reset local state (saved accounts + flagged/problem accounts: delete; settings + Codex CLI sync state: keep; quota cache: clear): ", + confirm: + "Reset local state? (Saved accounts: delete. Flagged/problem accounts: delete. Settings: keep. Codex CLI sync state: keep. Quota cache: clear.)", stage: "Clearing saved accounts, flagged/problem accounts, and quota cache", completed: diff --git a/test/destructive-actions.test.ts b/test/destructive-actions.test.ts index 04abc093..50083d9d 100644 --- a/test/destructive-actions.test.ts +++ b/test/destructive-actions.test.ts @@ -137,6 +137,59 @@ describe("destructive actions", () => { ); }); + it("reloads flagged storage at delete time so newer flagged entries are preserved", async () => { + loadFlaggedAccountsMock.mockResolvedValue({ + version: 1, + accounts: [ + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + flaggedAt: 2, + }, + { + refreshToken: "refresh-newer", + addedAt: 3, + lastUsed: 3, + flaggedAt: 3, + }, + ], + }); + + const { deleteAccountAtIndex } = await import( + "../lib/destructive-actions.js" + ); + + const storage = { + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + refreshToken: "refresh-keep", + addedAt: 1, + lastUsed: 1, + }, + { + refreshToken: "refresh-remove", + addedAt: 2, + lastUsed: 2, + }, + ], + }; + + const deleted = await deleteAccountAtIndex({ storage, index: 1 }); + + expect(deleted).not.toBeNull(); + expect(deleted?.flagged.accounts).toEqual([ + expect.objectContaining({ refreshToken: "refresh-newer" }), + ]); + expect(saveFlaggedAccountsMock).toHaveBeenCalledWith({ + version: 1, + accounts: [expect.objectContaining({ refreshToken: "refresh-newer" })], + }); + }); + it("rethrows the original flagged-save failure after a successful rollback", async () => { const flaggedSaveError = Object.assign(new Error("flagged save failed"), { code: "EPERM", diff --git a/test/storage.test.ts b/test/storage.test.ts index 81d73663..790ee247 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -1785,7 +1785,7 @@ describe("storage", () => { await expect(clearAccounts()).resolves.not.toThrow(); }); - it.each(["EPERM", "EBUSY"] as const)( + it.each(["EPERM", "EBUSY", "EAGAIN"] as const)( "retries transient %s when clearing saved account artifacts", async (code) => { await fs.writeFile(testStoragePath, "{}"); @@ -2513,6 +2513,36 @@ describe("storage", () => { expect(existsSync(legacyWorktreePath)).toBe(false); }); + it("clearAccounts removes legacy project and worktree account files for linked worktrees", async () => { + const { worktreeRepo } = await prepareWorktreeFixture(); + + setStoragePath(worktreeRepo); + const canonicalPath = getStoragePath(); + const legacyProjectPath = join(worktreeRepo, ".codex", "openai-codex-accounts.json"); + const legacyWorktreePath = join( + getConfigDir(), + "projects", + getProjectStorageKey(worktreeRepo), + "openai-codex-accounts.json", + ); + const storage = buildStorage([accountFromLegacy]); + + await fs.mkdir(dirname(canonicalPath), { recursive: true }); + await fs.mkdir(dirname(legacyProjectPath), { recursive: true }); + await fs.mkdir(dirname(legacyWorktreePath), { recursive: true }); + await Promise.all([ + fs.writeFile(canonicalPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyProjectPath, JSON.stringify(storage), "utf-8"), + fs.writeFile(legacyWorktreePath, JSON.stringify(storage), "utf-8"), + ]); + + await expect(clearAccounts()).resolves.toBe(true); + + expect(existsSync(canonicalPath)).toBe(false); + expect(existsSync(legacyProjectPath)).toBe(false); + expect(existsSync(legacyWorktreePath)).toBe(false); + }); + it("keeps legacy worktree file when migration persist fails", async () => { const { worktreeRepo } = await prepareWorktreeFixture(); @@ -3329,31 +3359,64 @@ describe("storage", () => { await expect(clearQuotaCache()).resolves.toBe(true); }); - it("retries transient EPERM when clearing the quota cache", async () => { - const quotaPath = getQuotaCachePath(); - await fs.mkdir(dirname(quotaPath), { recursive: true }); - await fs.writeFile(quotaPath, "{}", "utf-8"); + it.each(["EPERM", "EBUSY"] as const)( + "retries transient %s when clearing the quota cache", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); - const realUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi - .spyOn(fs, "unlink") - .mockImplementation(async (target) => { - if (target === quotaPath && unlinkSpy.mock.calls.length === 1) { - const err = new Error("locked") as NodeJS.ErrnoException; - err.code = "EPERM"; + const realUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath && unlinkSpy.mock.calls.length === 1) { + const err = new Error("locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + return realUnlink(target); + }); + + try { + await expect(clearQuotaCache()).resolves.toBe(true); + expect(existsSync(quotaPath)).toBe(false); + expect(unlinkSpy).toHaveBeenCalledTimes(2); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); + + it.each(["EPERM", "EBUSY"] as const)( + "returns false when quota-cache clear exhausts retryable %s failures", + async (code) => { + const quotaPath = getQuotaCachePath(); + await fs.mkdir(dirname(quotaPath), { recursive: true }); + await fs.writeFile(quotaPath, "{}", "utf-8"); + + const unlinkSpy = vi + .spyOn(fs, "unlink") + .mockImplementation(async (target) => { + if (target === quotaPath) { + const err = new Error("still locked") as NodeJS.ErrnoException; + err.code = code; + throw err; + } + const err = new Error("missing") as NodeJS.ErrnoException; + err.code = "ENOENT"; throw err; - } - return realUnlink(target); - }); + }); - try { - await expect(clearQuotaCache()).resolves.toBe(true); - expect(existsSync(quotaPath)).toBe(false); - expect(unlinkSpy).toHaveBeenCalledTimes(2); - } finally { - unlinkSpy.mockRestore(); - } - }); + try { + await expect(clearQuotaCache()).resolves.toBe(false); + expect(existsSync(quotaPath)).toBe(true); + expect(unlinkSpy).toHaveBeenCalledTimes(5); + } finally { + unlinkSpy.mockRestore(); + } + }, + ); }); });