diff --git a/index.ts b/index.ts index 147959c4..c6042135 100644 --- a/index.ts +++ b/index.ts @@ -63,6 +63,7 @@ import { getCodexTuiColorProfile, getCodexTuiGlyphMode, getLiveAccountSync, + getCodexCliDirectInjection, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, getSessionAffinity, @@ -227,6 +228,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let loaderMutex: Promise | null = null; let startupPrewarmTriggered = false; let lastCodexCliActiveSyncIndex: number | null = null; + let lastCodexCliActiveSyncSignature: string | null = null; + let codexCliSelectionSyncQueue = Promise.resolve(); let perProjectStorageWarningShown = false; let liveAccountSync: LiveAccountSync | null = null; let liveAccountSyncPath: string | null = null; @@ -924,6 +927,110 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; + const CODEX_CLI_SELECTION_SYNC_TIMEOUT_MS = 5_000; + let codexCliSelectionGeneration = 0; + + const queueCodexCliSelectionSync = async ({ + generation, + runSync, + }: { + generation: number; + runSync: (context: { isStale: () => boolean }) => Promise; + }, + ): Promise => { + const priorSync = codexCliSelectionSyncQueue; + let releaseSyncQueue!: () => void; + let syncTimedOut = false; + codexCliSelectionSyncQueue = new Promise((resolve) => { + releaseSyncQueue = resolve; + }); + await priorSync.catch(() => undefined); + try { + let timeoutHandle: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + runSync({ + isStale: () => + syncTimedOut || codexCliSelectionGeneration !== generation, + }), + new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + syncTimedOut = true; + // The underlying write cannot be cancelled once started; timing out only + // unblocks later queued sync attempts and lets the detached write finish. + logWarn( + `[${PLUGIN_NAME}] Codex CLI selection sync timed out`, + { + timeoutMs: CODEX_CLI_SELECTION_SYNC_TIMEOUT_MS, + }, + ); + resolve(false); + }, CODEX_CLI_SELECTION_SYNC_TIMEOUT_MS); + }), + ]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } + } finally { + releaseSyncQueue(); + } + }; + + const syncPersistedCodexCliSelection = async ( + index: number, + { + enabled, + reason, + }: { + enabled: boolean; + reason: "manual account selection" | "tool account switch"; + }, + ): Promise => { + const selectionGeneration = ++codexCliSelectionGeneration; + // Reload immediately after the persisted selection change so the cached + // manager cannot keep stale active-index state. + await reloadAccountManagerFromDisk(); + if (!enabled) { + return; + } + const synced = await queueCodexCliSelectionSync({ + generation: selectionGeneration, + runSync: async ({ isStale }) => { + if (isStale()) { + return false; + } + const activeAccountManager = await reloadAccountManagerFromDisk(); + try { + return await activeAccountManager.syncCodexCliActiveSelectionForIndex( + index, + ); + } catch (error) { + logWarn( + `[${PLUGIN_NAME}] Codex CLI selection sync failed for ${reason}`, + { + accountIndex: index, + code: + error && + typeof error === "object" && + "code" in error + ? String((error as NodeJS.ErrnoException).code ?? "") + : "", + error: + error instanceof Error ? error.message : String(error), + }, + ); + return false; + } + }, + }); + if (synced && codexCliSelectionGeneration === selectionGeneration) { + lastCodexCliActiveSyncIndex = index; + lastCodexCliActiveSyncSignature = null; + } + }; + // Event handler for session recovery and account selection const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { try { @@ -959,17 +1066,13 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { 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(); - } + const codexCliDirectInjectionEnabledForEvent = + getCodexCliDirectInjection(loadPluginConfig()); + await saveAccounts(storage); + await syncPersistedCodexCliSelection(index, { + enabled: codexCliDirectInjectionEnabledForEvent, + reason: "manual account selection", + }); await showToast(`Switched to account ${index + 1}`, "info"); } @@ -1107,6 +1210,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); const maxSameAccountRetries = failoverMode === "conservative" ? 2 : failoverMode === "balanced" ? 1 : 0; + const codexCliDirectInjectionEnabled = + getCodexCliDirectInjection(pluginConfig); const sessionRecoveryEnabled = getSessionRecovery(pluginConfig); const autoResumeEnabled = getAutoResume(pluginConfig); @@ -1153,6 +1258,184 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); }); + type CodexCliSelectionAccount = { + index: number; + accountId?: string; + email?: string; + refreshToken?: string; + addedAt?: number; + }; + + const buildCodexCliSelectionIdentityKey = ( + account: CodexCliSelectionAccount, + ): string => { + if (account.accountId) { + return `accountId:${account.accountId}`; + } + if (account.refreshToken) { + return `refreshToken:${account.refreshToken}`; + } + if (account.email) { + return `email:${account.email.trim().toLowerCase()}`; + } + if (account.addedAt !== undefined) { + return `addedAt:${account.addedAt}|index:${account.index}`; + } + return `index:${account.index}`; + }; + + const buildCodexCliSelectionSignature = ( + account: CodexCliSelectionAccount, + ): string => + [ + buildCodexCliSelectionIdentityKey(account), + `index:${account.index}`, + ].join("|"); + + const matchesCodexCliSelectionIdentity = ( + left: CodexCliSelectionAccount, + right: CodexCliSelectionAccount, + ): boolean => { + if ( + left.accountId && + right.accountId && + left.accountId === right.accountId + ) { + return true; + } + if ( + left.refreshToken && + right.refreshToken && + left.refreshToken === right.refreshToken + ) { + return true; + } + if (left.email && right.email) { + return left.email.trim().toLowerCase() === right.email.trim().toLowerCase(); + } + if ( + left.addedAt !== undefined && + right.addedAt !== undefined && + left.addedAt === right.addedAt && + left.index === right.index + ) { + return true; + } + return left.index === right.index; + }; + + const resolveLiveCodexCliSelectionAccount = ( + activeAccountManager: AccountManager, + account: CodexCliSelectionAccount, + ) => { + const indexedAccount = + typeof activeAccountManager.getAccountByIndex === "function" + ? activeAccountManager.getAccountByIndex(account.index) + : null; + if ( + indexedAccount && + matchesCodexCliSelectionIdentity(indexedAccount, account) + ) { + return indexedAccount; + } + const snapshot = + typeof activeAccountManager.getAccountsSnapshot === "function" + ? activeAccountManager.getAccountsSnapshot() + : []; + return ( + snapshot.find( + (candidate) => matchesCodexCliSelectionIdentity(candidate, account), + ) ?? + indexedAccount ?? + account + ); + }; + + const syncCodexCliSelectionNow = async ( + account: CodexCliSelectionAccount, + options?: { force?: boolean; generation?: number }, + ): Promise => { + if (!codexCliDirectInjectionEnabled) { + return false; + } + const selectionGenerationAtQueueTime = + options?.generation ?? codexCliSelectionGeneration; + const runSync = async (context: { + isStale: () => boolean; + }): Promise => { + const clearCachedSelection = ( + cachedIndex: number, + cachedSignature: string, + ): void => { + if ( + lastCodexCliActiveSyncIndex === cachedIndex && + lastCodexCliActiveSyncSignature === cachedSignature + ) { + lastCodexCliActiveSyncIndex = null; + lastCodexCliActiveSyncSignature = null; + } + }; + if (context.isStale()) { + return false; + } + const activeAccountManager = cachedAccountManager ?? accountManager; + const liveAccount = resolveLiveCodexCliSelectionAccount( + activeAccountManager, + account, + ); + if (!liveAccount) { + return false; + } + const liveSignature = buildCodexCliSelectionSignature(liveAccount); + if ( + !options?.force && + lastCodexCliActiveSyncIndex === liveAccount.index && + lastCodexCliActiveSyncSignature === liveSignature + ) { + return true; + } + let synced = false; + try { + synced = + await activeAccountManager.syncCodexCliActiveSelectionForIndex( + liveAccount.index, + ); + } catch (error) { + if (options?.force) { + clearCachedSelection(liveAccount.index, liveSignature); + } + logWarn( + `[${PLUGIN_NAME}] Codex CLI selection sync failed`, + { + accountIndex: liveAccount.index, + signature: liveSignature, + code: + error && + typeof error === "object" && + "code" in error + ? String((error as NodeJS.ErrnoException).code ?? "") + : "", + error: + error instanceof Error ? error.message : String(error), + }, + ); + return false; + } + if (!synced && options?.force) { + clearCachedSelection(liveAccount.index, liveSignature); + } + if (synced && !context.isStale()) { + lastCodexCliActiveSyncIndex = liveAccount.index; + lastCodexCliActiveSyncSignature = liveSignature; + } + return synced; + }; + return await queueCodexCliSelectionSync({ + generation: selectionGenerationAtQueueTime, + runSync, + }); + }; + // Return SDK configuration return { @@ -1292,6 +1575,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const requestCorrelationId = setCorrelationId( threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined, ); + const cliSelectionGenerationForRequest = codexCliSelectionGeneration; runtimeMetrics.lastRequestAt = Date.now(); const abortSignal = requestInit?.signal ?? init?.signal ?? null; @@ -1442,6 +1726,7 @@ while (attempted.size < Math.max(1, accountCount)) { ); let accountAuth = accountManager.toAuthDetails(account) as OAuthAuthDetails; + let accountAuthRefreshed = false; try { if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) { accountAuth = (await refreshAndUpdateToken( @@ -1451,6 +1736,7 @@ while (attempted.size < Math.max(1, accountCount)) { accountManager.updateFromAuth(account, accountAuth); accountManager.clearAuthFailures(account); accountManager.saveToDiskDebounced(); + accountAuthRefreshed = true; } } catch (err) { logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${(err as Error)?.message ?? String(err)}`); @@ -1581,16 +1867,20 @@ while (attempted.size < Math.max(1, accountCount)) { // 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++; + 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; - } + logWarn( + `Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`, + ); + continue; + } + await syncCodexCliSelectionNow(account, { + force: accountAuthRefreshed, + generation: cliSelectionGenerationForRequest, + }); let sameAccountRetryCount = 0; let successAccountForResponse = account; @@ -2078,12 +2368,14 @@ while (attempted.size < Math.max(1, accountCount)) { if (!fallbackAccount) continue; let fallbackAuth = accountManager.toAuthDetails(fallbackAccount) as OAuthAuthDetails; + let fallbackAuthRefreshed = false; try { if (shouldRefreshToken(fallbackAuth, tokenRefreshSkewMs)) { fallbackAuth = (await refreshAndUpdateToken( fallbackAuth, client, )) as OAuthAuthDetails; + fallbackAuthRefreshed = true; accountManager.updateFromAuth(fallbackAccount, fallbackAuth); accountManager.clearAuthFailures(fallbackAccount); accountManager.saveToDiskDebounced(); @@ -2196,6 +2488,15 @@ while (attempted.size < Math.max(1, accountCount)) { fallbackAccount.index, ); } + if ( + fallbackAccount.index !== account.index || + fallbackAuthRefreshed + ) { + await syncCodexCliSelectionNow(fallbackAccount, { + force: true, + generation: cliSelectionGenerationForRequest, + }); + } logInfo( `Recovered stream via failover attempt ${failoverAttempt} using account ${fallbackAccount.index + 1}.`, @@ -2312,10 +2613,9 @@ while (attempted.size < Math.max(1, accountCount)) { ); runtimeMetrics.successfulRequests++; runtimeMetrics.lastError = null; - if (lastCodexCliActiveSyncIndex !== successAccountForResponse.index) { - void accountManager.syncCodexCliActiveSelectionForIndex(successAccountForResponse.index); - lastCodexCliActiveSyncIndex = successAccountForResponse.index; - } + await syncCodexCliSelectionNow(successAccountForResponse, { + generation: cliSelectionGenerationForRequest, + }); return successResponse; } if (retryNextAccountBeforeFallback) { @@ -3567,9 +3867,12 @@ while (attempted.size < Math.max(1, accountCount)) { return `Switched to ${formatAccountLabel(account, targetIndex)} but failed to persist. Changes may be lost on restart.`; } - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); - } + const codexCliDirectInjectionEnabledForTool = + getCodexCliDirectInjection(loadPluginConfig()); + await syncPersistedCodexCliSelection(targetIndex, { + enabled: codexCliDirectInjectionEnabledForTool, + reason: "tool account switch", + }); const label = formatAccountLabel(account, targetIndex); if (ui.v2Enabled) { diff --git a/lib/accounts.ts b/lib/accounts.ts index f870b6b3..eb69ffda 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -385,16 +385,16 @@ export class AccountManager { account.lastUsed = nowMs(); account.lastSwitchReason = "rotation"; - void this.syncCodexCliActiveSelectionForIndex(account.index); return account; } - async syncCodexCliActiveSelectionForIndex(index: number): Promise { - if (!Number.isFinite(index)) return; - if (index < 0 || index >= this.accounts.length) return; + async syncCodexCliActiveSelectionForIndex(index: number): Promise { + if (!Number.isFinite(index)) return false; + if (index < 0 || index >= this.accounts.length) return false; const account = this.accounts[index]; - if (!account) return; - await setCodexCliActiveSelection({ + if (!account) return false; + if (account.enabled === false) return false; + return setCodexCliActiveSelection({ accountId: account.accountId, email: account.email, accessToken: account.access, diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 172648d3..0d743d36 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -274,6 +274,11 @@ type SettingsHubAction = type ExperimentalSettingsAction = | { type: "sync" } | { type: "backup" } + | { type: "open-rotation-quota" } + | { type: "toggle-direct-cli-injection" } + | { type: "toggle-session-affinity" } + | { type: "toggle-preemptive-quota" } + | { type: "toggle-pool-retry" } | { type: "toggle-refresh-guardian" } | { type: "decrease-refresh-interval" } | { type: "increase-refresh-interval" } @@ -1071,6 +1076,10 @@ function backendSettingsSnapshot( config: PluginConfig, ): Record { const snapshot: Record = {}; + const directInjectionDefault = + BACKEND_DEFAULTS.codexCliDirectInjection ?? true; + snapshot.codexCliDirectInjection = + config.codexCliDirectInjection ?? directInjectionDefault; for (const option of BACKEND_TOGGLE_OPTIONS) { snapshot[option.key] = config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; @@ -1130,7 +1139,7 @@ function buildBackendSettingsPreview( const threshold5h = config.preemptiveQuotaRemainingPercent5h ?? BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h ?? - 5; + 10; const threshold7d = config.preemptiveQuotaRemainingPercent7d ?? BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d ?? @@ -1171,6 +1180,9 @@ function buildBackendSettingsPreview( function buildBackendConfigPatch(config: PluginConfig): Partial { const patch: Partial = {}; + if (typeof config.codexCliDirectInjection === "boolean") { + patch.codexCliDirectInjection = config.codexCliDirectInjection; + } for (const option of BACKEND_TOGGLE_OPTIONS) { const value = config[option.key]; if (typeof value === "boolean") { @@ -2452,7 +2464,24 @@ async function promptBackendSettings( if (!result || result.type === "cancel") return null; if (result.type === "save") return draft; if (result.type === "reset") { - draft = cloneBackendPluginConfig(BACKEND_DEFAULTS); + draft = cloneBackendPluginConfig({ + ...BACKEND_DEFAULTS, + codexCliDirectInjection: draft.codexCliDirectInjection, + proactiveRefreshGuardian: draft.proactiveRefreshGuardian, + proactiveRefreshIntervalMs: draft.proactiveRefreshIntervalMs, + sessionAffinity: draft.sessionAffinity, + retryAllAccountsRateLimited: draft.retryAllAccountsRateLimited, + preemptiveQuotaEnabled: draft.preemptiveQuotaEnabled, + preemptiveQuotaRemainingPercent5h: + draft.preemptiveQuotaRemainingPercent5h ?? + BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h, + preemptiveQuotaRemainingPercent7d: + draft.preemptiveQuotaRemainingPercent7d ?? + BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d, + preemptiveQuotaMaxDeferralMs: + draft.preemptiveQuotaMaxDeferralMs ?? + BACKEND_DEFAULTS.preemptiveQuotaMaxDeferralMs, + }); for (const category of BACKEND_CATEGORY_OPTIONS) { focusByCategory[category.key] = getBackendCategoryInitialFocus(category); @@ -2540,6 +2569,31 @@ async function promptExperimentalSettings( value: { type: "backup" }, color: "green", }, + { + label: `${formatDashboardSettingState(draft.codexCliDirectInjection !== false)} ${UI_COPY.settings.experimentalDirectCliInjection}`, + value: { type: "toggle-direct-cli-injection" }, + color: "green", + }, + { + label: `${formatDashboardSettingState(draft.sessionAffinity !== false)} ${UI_COPY.settings.experimentalManualSessionLock}`, + value: { type: "toggle-session-affinity" }, + color: "yellow", + }, + { + label: `${formatDashboardSettingState(draft.retryAllAccountsRateLimited !== false)} ${UI_COPY.settings.experimentalPoolFallback}`, + value: { type: "toggle-pool-retry" }, + color: "green", + }, + { + label: `${formatDashboardSettingState(draft.preemptiveQuotaEnabled !== false)} ${UI_COPY.settings.experimentalQuotaRotation}`, + value: { type: "toggle-preemptive-quota" }, + color: "yellow", + }, + { + label: UI_COPY.settings.experimentalRotationQuotaSettings, + value: { type: "open-rotation-quota" }, + color: "green", + }, { label: `${formatDashboardSettingState(draft.proactiveRefreshGuardian ?? false)} ${UI_COPY.settings.experimentalRefreshGuard}`, value: { type: "toggle-refresh-guardian" }, @@ -2581,11 +2635,54 @@ async function promptExperimentalSettings( theme: ui.theme, selectedEmphasis: "minimal", onInput: (raw) => - raw.toLowerCase() === "q" ? { type: "back" } : undefined, + raw.toLowerCase() === "q" + ? { type: "back" } + : raw.toLowerCase() === "o" + ? { type: "open-rotation-quota" } + : undefined, }, ); if (!action || action.type === "back") return null; if (action.type === "save") return draft; + if (action.type === "open-rotation-quota") { + const category = getBackendCategory("rotation-quota"); + if (!category) continue; + const categoryResult = await promptBackendCategorySettings( + draft, + category, + "preemptiveQuotaEnabled", + ); + draft = categoryResult.draft; + continue; + } + if (action.type === "toggle-direct-cli-injection") { + draft = { + ...draft, + codexCliDirectInjection: !(draft.codexCliDirectInjection !== false), + }; + continue; + } + if (action.type === "toggle-session-affinity") { + draft = { + ...draft, + sessionAffinity: !(draft.sessionAffinity !== false), + }; + continue; + } + if (action.type === "toggle-preemptive-quota") { + draft = { + ...draft, + preemptiveQuotaEnabled: !(draft.preemptiveQuotaEnabled !== false), + }; + continue; + } + if (action.type === "toggle-pool-retry") { + draft = { + ...draft, + retryAllAccountsRateLimited: !(draft.retryAllAccountsRateLimited !== false), + }; + continue; + } if (action.type === "toggle-refresh-guardian") { draft = { ...draft, diff --git a/lib/config.ts b/lib/config.ts index f9e7ecf8..5ab2e0aa 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -143,6 +143,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { fetchTimeoutMs: 60_000, streamStallTimeoutMs: 45_000, liveAccountSync: true, + codexCliDirectInjection: true, liveAccountSyncDebounceMs: 250, liveAccountSyncPollMs: 2_000, sessionAffinity: true, @@ -155,7 +156,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { serverErrorCooldownMs: 4_000, storageBackupEnabled: true, preemptiveQuotaEnabled: true, - preemptiveQuotaRemainingPercent5h: 5, + preemptiveQuotaRemainingPercent5h: 10, preemptiveQuotaRemainingPercent7d: 5, preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, }; @@ -818,6 +819,14 @@ export function getLiveAccountSync(pluginConfig: PluginConfig): boolean { ); } +export function getCodexCliDirectInjection(pluginConfig: PluginConfig): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_DIRECT_CLI_INJECTION", + pluginConfig.codexCliDirectInjection, + true, + ); +} + /** * Get the debounce interval, in milliseconds, used when synchronizing live accounts. * @@ -1057,7 +1066,7 @@ export function getPreemptiveQuotaRemainingPercent5h(pluginConfig: PluginConfig) return resolveNumberSetting( "CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT", pluginConfig.preemptiveQuotaRemainingPercent5h, - 5, + 10, { min: 0, max: 100 }, ); } diff --git a/lib/schemas.ts b/lib/schemas.ts index dea8109c..197e4d0c 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -42,6 +42,7 @@ export const PluginConfigSchema = z.object({ fetchTimeoutMs: z.number().min(1_000).optional(), streamStallTimeoutMs: z.number().min(1_000).optional(), liveAccountSync: z.boolean().optional(), + codexCliDirectInjection: z.boolean().optional(), liveAccountSyncDebounceMs: z.number().min(50).optional(), liveAccountSyncPollMs: z.number().min(500).optional(), sessionAffinity: z.boolean().optional(), diff --git a/lib/ui/copy.ts b/lib/ui/copy.ts index 406e6a8a..abac053a 100644 --- a/lib/ui/copy.ts +++ b/lib/ui/copy.ts @@ -68,14 +68,19 @@ export const UI_COPY = { theme: "Color Theme", experimental: "Experimental", experimentalTitle: "Experimental", - experimentalSubtitle: "Preview sync and backup actions before they become stable", - experimentalHelpMenu: "Enter Select | Q Back", + experimentalSubtitle: "Preview live rotation, sync, and backup actions before they become stable", + experimentalHelpMenu: "Enter Select | O Quota | Q Back", experimentalHelpPreview: "A Apply | Q Back", experimentalHelpStatus: "Enter Select | Q Back", experimentalSync: "Sync Accounts to oc-chatgpt-multi-auth", experimentalApplySync: "Apply Sync", experimentalBackup: "Save Pool Backup", experimentalBackupPrompt: "Backup file name (.json): ", + experimentalDirectCliInjection: "Direct Codex CLI Injection", + experimentalManualSessionLock: "Manual Session Lock", + experimentalPoolFallback: "Retry Whole Pool on Rate Limit", + experimentalQuotaRotation: "Auto-Rotate on Quota Pressure", + experimentalRotationQuotaSettings: "Open Rotation & Quota Settings", experimentalRefreshGuard: "Enable Refresh Guard", experimentalRefreshInterval: "Refresh Guard Interval", experimentalDecreaseInterval: "Decrease Refresh Interval", diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts index 61c2b8b0..b0709b8c 100644 --- a/test/accounts-load-from-disk.test.ts +++ b/test/accounts-load-from-disk.test.ts @@ -19,7 +19,7 @@ vi.mock("../lib/codex-cli/state.js", () => ({ })); vi.mock("../lib/codex-cli/writer.js", () => ({ - setCodexCliActiveSelection: vi.fn().mockResolvedValue(undefined), + setCodexCliActiveSelection: vi.fn().mockResolvedValue(true), })); import { loadAccounts, saveAccounts } from "../lib/storage.js"; @@ -37,7 +37,7 @@ describe("AccountManager loadFromDisk", () => { storage: null, }); vi.mocked(loadCodexCliState).mockResolvedValue(null); - vi.mocked(setCodexCliActiveSelection).mockResolvedValue(undefined); + vi.mocked(setCodexCliActiveSelection).mockResolvedValue(true); }); it("persists Codex CLI source-of-truth storage when sync reports change", async () => { @@ -159,7 +159,7 @@ describe("AccountManager loadFromDisk", () => { expect(saveAccounts).not.toHaveBeenCalled(); }); - it("syncCodexCliActiveSelectionForIndex ignores invalid indices and syncs a valid one", async () => { + it("syncCodexCliActiveSelectionForIndex ignores invalid or disabled entries and syncs a valid one", async () => { const now = Date.now(); const manager = new AccountManager(undefined, { version: 3 as const, @@ -174,14 +174,26 @@ describe("AccountManager loadFromDisk", () => { addedAt: now, lastUsed: now, } as never, + { + refreshToken: "refresh-2", + accountId: "acct-2", + email: "two@example.com", + accessToken: "access-2", + expiresAt: now + 10_000, + addedAt: now + 1, + lastUsed: now, + enabled: false, + } as never, ], }); - await manager.syncCodexCliActiveSelectionForIndex(-1); - await manager.syncCodexCliActiveSelectionForIndex(9); + await expect(manager.syncCodexCliActiveSelectionForIndex(Number.NaN)).resolves.toBe(false); + await expect(manager.syncCodexCliActiveSelectionForIndex(-1)).resolves.toBe(false); + await expect(manager.syncCodexCliActiveSelectionForIndex(9)).resolves.toBe(false); + await expect(manager.syncCodexCliActiveSelectionForIndex(1)).resolves.toBe(false); expect(setCodexCliActiveSelection).not.toHaveBeenCalled(); - await manager.syncCodexCliActiveSelectionForIndex(0); + await expect(manager.syncCodexCliActiveSelectionForIndex(0)).resolves.toBe(true); expect(setCodexCliActiveSelection).toHaveBeenCalledTimes(1); expect(setCodexCliActiveSelection).toHaveBeenCalledWith( expect.objectContaining({ @@ -192,6 +204,29 @@ describe("AccountManager loadFromDisk", () => { ); }); + it("syncCodexCliActiveSelectionForIndex returns false when CLI selection write fails", async () => { + const now = Date.now(); + const manager = new AccountManager(undefined, { + version: 3 as const, + activeIndex: 0, + accounts: [ + { + refreshToken: "refresh-1", + accountId: "acct-1", + email: "one@example.com", + accessToken: "access-1", + expiresAt: now + 10_000, + addedAt: now, + lastUsed: now, + } as never, + ], + }); + + vi.mocked(setCodexCliActiveSelection).mockResolvedValueOnce(false); + + await expect(manager.syncCodexCliActiveSelectionForIndex(0)).resolves.toBe(false); + }); + it("getNextForFamily skips disabled/rate-limited/cooldown accounts", () => { const now = Date.now(); const manager = new AccountManager(undefined, { diff --git a/test/index.test.ts b/test/index.test.ts index 7810c942..c563f03b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -93,6 +93,7 @@ vi.mock("../lib/config.js", () => ({ getFetchTimeoutMs: () => 60000, getStreamStallTimeoutMs: () => 45000, getLiveAccountSync: vi.fn(() => false), + getCodexCliDirectInjection: vi.fn(() => true), getLiveAccountSyncDebounceMs: () => 250, getLiveAccountSyncPollMs: () => 2000, getSessionAffinity: () => false, @@ -105,7 +106,7 @@ vi.mock("../lib/config.js", () => ({ getServerErrorCooldownMs: () => 0, getStorageBackupEnabled: () => true, getPreemptiveQuotaEnabled: () => true, - getPreemptiveQuotaRemainingPercent5h: () => 5, + getPreemptiveQuotaRemainingPercent5h: () => 10, getPreemptiveQuotaRemainingPercent7d: () => 5, getPreemptiveQuotaMaxDeferralMs: () => 2 * 60 * 60_000, getCodexTuiV2: () => false, @@ -204,7 +205,7 @@ vi.mock("../lib/request/rate-limit-backoff.js", () => ({ updatedInit: init, body: { model: "gpt-5.1" }, })), - shouldRefreshToken: () => false, + shouldRefreshToken: vi.fn(() => false), refreshAndUpdateToken: vi.fn(async (auth: unknown) => auth), createCodexHeaders: vi.fn(() => new Headers()), handleErrorResponse: vi.fn(async (response: Response) => ({ response })), @@ -292,7 +293,68 @@ const withAccountStorageTransactionMock = vi.fn( }, ); -const syncCodexCliSelectionMock = vi.fn(async (_index: number) => {}); +const syncCodexCliSelectionMock = vi.fn(async (_index: number) => true); + +type TestAccount = { + index: number; + accountId: string; + email: string; + refreshToken: string; + access: string; + expires: number; + addedAt: number; +}; + +function buildCustomManager( + accountOne: TestAccount, + accountTwo: TestAccount, + overrides: Record = {}, +) { + return { + accounts: [accountOne, accountTwo], + getCurrentOrNextForFamilyHybrid: () => accountOne, + getAccountCount: () => 2, + getAccountsSnapshot: () => [accountOne, accountTwo], + isAccountAvailableForFamily: () => true, + getAccountByIndex: (index: number) => + index === 0 ? accountOne : index === 1 ? accountTwo : null, + toAuthDetails: (account: TestAccount) => ({ + type: "oauth" as const, + access: account.access, + refresh: account.refreshToken, + expires: account.expires, + multiAccount: true, + }), + consumeToken: vi.fn(() => true), + updateFromAuth: vi.fn(), + clearAuthFailures: vi.fn(), + saveToDiskDebounced: vi.fn(), + saveToDisk: vi.fn(), + hasRefreshToken: vi.fn(() => true), + syncCodexCliActiveSelectionForIndex: (index: number) => + syncCodexCliSelectionMock(index), + recordFailure: vi.fn(), + recordSuccess: vi.fn(), + recordRateLimit: vi.fn(), + refundToken: vi.fn(), + markRateLimitedWithReason: vi.fn(), + markSwitched: vi.fn(), + shouldShowAccountToast: () => false, + markToastShown: vi.fn(), + getMinWaitTimeForFamily: () => 0, + getCurrentAccountForFamily: () => accountOne, + ...overrides, + }; +} + +function getFetchAccessTokenSequence(): Array { + const fetchMock = globalThis.fetch as unknown as { + mock: { calls: Array<[unknown, RequestInit | undefined]> }; + }; + return fetchMock.mock.calls.map(([, init]) => + new Headers(init?.headers).get("x-test-access-token"), + ); +} vi.mock("../lib/storage.js", async () => { const actual = await vi.importActual("../lib/storage.js"); @@ -332,6 +394,7 @@ vi.mock("../lib/accounts.js", () => { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1", + addedAt: 1, }, ]; @@ -793,7 +856,7 @@ describe("OpenAIOAuthPlugin", () => { loadFromDiskSpy.mockClear(); await plugin.tool["codex-switch"].execute({ index: 2 }); - expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(loadFromDiskSpy).toHaveBeenCalledTimes(2); }); }); @@ -1058,87 +1121,1149 @@ describe("OpenAIOAuthPlugin edge cases", () => { const mockClient = createMockClient(); - const { OpenAIOAuthPlugin } = await import("../index.js"); - const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + + await plugin.tool["codex-remove"].execute({ index: 1 }); + // After removing account at 0-based index 0, length is 2. + // activeIndex (2) >= length (2), so it resets to 0 + expect(mockStorage.activeIndex).toBe(0); + expect(mockStorage.activeIndexByFamily.codex).toBe(0); + }); + + it("resets activeIndex when removing active account at end", async () => { + mockStorage.accounts = [ + { refreshToken: "r1", email: "user1@example.com" }, + { refreshToken: "r2", email: "user2@example.com" }, + ]; + mockStorage.activeIndex = 1; + mockStorage.activeIndexByFamily = { codex: 1 }; + + const mockClient = createMockClient(); + + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + + await plugin.tool["codex-remove"].execute({ index: 2 }); + expect(mockStorage.activeIndex).toBe(0); + }); +}); + +describe("OpenAIOAuthPlugin fetch handler", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + syncCodexCliSelectionMock.mockClear(); + mockStorage.accounts = [ + { + accountId: "acc-1", + email: "user@example.com", + refreshToken: "refresh-1", + }, + ]; + mockStorage.activeIndex = 0; + mockStorage.activeIndexByFamily = {}; + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + const setupPlugin = async () => { + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + + const getAuth = async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + + const sdk = await plugin.auth.loader(getAuth, { options: {}, models: {} }); + return { plugin, sdk, mockClient }; + }; + + it("returns success response for successful fetch", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "test" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(0); + }); + + it("injects only the account that clears token-bucket admission", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne: TestAccount = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const accountTwo: TestAccount = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + const consumeSpy = vi + .fn() + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + const customManager = buildCustomManager(accountOne, accountTwo, { + getCurrentOrNextForFamilyHybrid: vi + .fn() + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null), + consumeToken: consumeSpy, + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(200); + expect(consumeSpy.mock.invocationCallOrder[1]).toBeLessThan( + syncCodexCliSelectionMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([1]); + }); + + it("serializes concurrent CLI injection for the same account", async () => { + let resolveSync: (() => void) | null = null; + let notifySyncStarted: (() => void) | null = null; + const syncStarted = new Promise((resolve) => { + notifySyncStarted = resolve; + }); + syncCodexCliSelectionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + notifySyncStarted?.(); + resolveSync = () => resolve(true); + }), + ); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const firstFetch = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const secondFetch = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + await syncStarted; + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + resolveSync?.(); + const [firstResponse, secondResponse] = await Promise.all([firstFetch, secondFetch]); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); + }); + + it("queues manual account selection behind an in-flight CLI sync", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + let resolveSync: (() => void) | null = null; + let notifySyncStarted: (() => void) | null = null; + const syncStarted = new Promise((resolve) => { + notifySyncStarted = resolve; + }); + syncCodexCliSelectionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + notifySyncStarted?.(); + resolveSync = () => resolve(true); + }), + ); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { plugin, sdk } = await setupPlugin(); + const requestPromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + await syncStarted; + let eventResolved = false; + const eventPromise = plugin + .event({ + event: { type: "account.select", properties: { index: 1 } }, + }) + .then(() => { + eventResolved = true; + }); + + await vi.waitFor(() => expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setImmediate(resolve)); + expect(eventResolved).toBe(false); + expect(globalThis.fetch).not.toHaveBeenCalled(); + resolveSync?.(); + + const response = await requestPromise; + await eventPromise; + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(2); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + }); + + it("skips a stale manual account selection sync after a newer selection wins", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + let resolveSync: (() => void) | null = null; + let notifySyncStarted: (() => void) | null = null; + const syncStarted = new Promise((resolve) => { + notifySyncStarted = resolve; + }); + syncCodexCliSelectionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + notifySyncStarted?.(); + resolveSync = () => resolve(true); + }), + ); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { plugin, sdk } = await setupPlugin(); + const requestPromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" } as const), + }); + + await syncStarted; + const firstSelectionPromise = plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + const secondSelectionPromise = plugin.event({ + event: { type: "account.select", properties: { index: 0 } }, + }); + + resolveSync?.(); + const response = await requestPromise; + await Promise.all([firstSelectionPromise, secondSelectionPromise]); + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(2); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([ + 0, + 0, + ]); + }); + + it("does not let an in-flight request restore an older CLI selection after a manual switch", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + let resolveFetch: (() => void) | null = null; + globalThis.fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => + resolve(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + }), + ); + + const { plugin, sdk } = await setupPlugin(); + const requestPromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + await vi.waitFor(() => expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1)); + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + + resolveFetch?.(); + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + }); + + it("does not let an in-flight request restore an older CLI selection after a tool switch", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + let resolveFetch: (() => void) | null = null; + globalThis.fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => + resolve(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + }), + ); + + const { plugin, sdk } = await setupPlugin(); + const requestPromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + await vi.waitFor(() => expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1)); + const switchResult = await plugin.tool["codex-switch"].execute({ index: 2 }); + + expect(switchResult).toContain("Switched to account"); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + + resolveFetch?.(); + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + }); + + it("times out a stalled CLI sync so later requests can continue", async () => { + vi.useFakeTimers(); + try { + const loggerModule = await import("../lib/logger.js"); + vi.mocked(loggerModule.logWarn).mockClear(); + syncCodexCliSelectionMock + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce(true); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const firstFetch = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const secondFetch = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + await vi.advanceTimersByTimeAsync(5_000); + const [firstResponse, secondResponse] = await Promise.all([firstFetch, secondFetch]); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(2); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Codex CLI selection sync timed out"), + expect.objectContaining({ + timeoutMs: 5_000, + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("ignores a timed-out stale sync completion after a newer account sync wins", async () => { + vi.useFakeTimers(); + try { + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + let resolveStaleSync: (() => void) | null = null; + const currentAccount = vi + .fn() + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null); + const customManager = buildCustomManager(accountOne, accountTwo, { + getCurrentOrNextForFamilyHybrid: currentAccount, + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + syncCodexCliSelectionMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStaleSync = () => resolve(true); + }), + ) + .mockResolvedValueOnce(true); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const firstResponsePromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + await vi.advanceTimersByTimeAsync(5_000); + const secondResponsePromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const firstResponse = await firstResponsePromise; + const secondResponse = await secondResponsePromise; + resolveStaleSync?.(); + await vi.runAllTimersAsync(); + + const thirdResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(thirdResponse.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([ + 0, + 0, + 1, + ]); + expect(getFetchAccessTokenSequence()).toEqual([ + "access-account-1", + "access-account-2", + "access-account-2", + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("forces CLI reinjection after a token refresh keeps the same signature fields", async () => { + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.shouldRefreshToken) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + vi.mocked(fetchHelpers.refreshAndUpdateToken).mockResolvedValueOnce({ + type: "oauth", + access: "refreshed-access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const firstResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const secondResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); + const thirdResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(thirdResponse.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 0]); + expect(getFetchAccessTokenSequence()).toEqual([ + "access-token", + "access-token", + "refreshed-access-token", + ]); + }); + + it("retries CLI injection on the next request after a forced refresh write fails", async () => { + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.shouldRefreshToken) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + vi.mocked(fetchHelpers.refreshAndUpdateToken).mockResolvedValueOnce({ + type: "oauth", + access: "refreshed-access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + syncCodexCliSelectionMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const firstResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const secondResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const thirdResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(thirdResponse.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([ + 0, + 0, + 0, + ]); + }); + + it("re-resolves the live CLI sync index when the selected account is reindexed", async () => { + const selectedAccount = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const displacedAccount = { + index: 0, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + const movedSelectedAccount = { + ...selectedAccount, + index: 1, + }; + const customManager = buildCustomManager(selectedAccount, displacedAccount, { + getCurrentOrNextForFamilyHybrid: () => selectedAccount, + getAccountsSnapshot: () => [displacedAccount, movedSelectedAccount], + getAccountByIndex: (index: number) => + index === 0 ? displacedAccount : index === 1 ? movedSelectedAccount : null, + }); + const { AccountManager } = await import("../lib/accounts.js"); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); + expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(1); + }); + + it("forces CLI reinjection when stream failover switches to another account", async () => { + vi.doMock("../lib/request/stream-failover.js", () => ({ + withStreamingFailover: async ( + response: Response, + failover: (attempt: number, emittedBytes: number) => Promise, + ) => (await failover(1, 128)) ?? response, + })); + try { + vi.resetModules(); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + const customManager = buildCustomManager(accountOne, accountTwo); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(new Response("streaming", { status: 200 })) + .mockResolvedValueOnce(new Response("retry later", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "fallback-ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", stream: true }), + }); + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + expect(getFetchAccessTokenSequence()).toEqual([ + "access-account-1", + "access-account-1", + "access-account-2", + ]); + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); + + it("forces failover CLI reinjection even when the fallback account was previously injected", async () => { + vi.doMock("../lib/request/stream-failover.js", () => ({ + withStreamingFailover: async ( + response: Response, + failover: (attempt: number, emittedBytes: number) => Promise, + ) => (await failover(1, 128)) ?? response, + })); + try { + vi.resetModules(); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + const currentAccount = vi + .fn() + .mockReturnValueOnce(accountTwo) + .mockReturnValueOnce(accountOne) + .mockImplementation(() => null); + const customManager = buildCustomManager(accountOne, accountTwo, { + getCurrentOrNextForFamilyHybrid: currentAccount, + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + syncCodexCliSelectionMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "seed" }), { status: 200 }), + ) + .mockResolvedValueOnce(new Response("streaming", { status: 200 })) + .mockResolvedValueOnce(new Response("retry later", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "fallback-ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const seedResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", stream: true }), + }); + + expect(seedResponse.status).toBe(200); + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([1, 0, 1]); + expect(getFetchAccessTokenSequence()).toEqual([ + "access-account-2", + "access-account-1", + "access-account-1", + "access-account-2", + ]); + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); + + it("forces failover CLI reinjection when refreshed fallback auth stays on the same account", async () => { + vi.doMock("../lib/request/stream-failover.js", () => ({ + withStreamingFailover: async ( + response: Response, + failover: (attempt: number, emittedBytes: number) => Promise, + ) => (await failover(1, 128)) ?? response, + })); + try { + vi.resetModules(); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.shouldRefreshToken) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + vi.mocked(fetchHelpers.refreshAndUpdateToken).mockResolvedValueOnce({ + type: "oauth", + access: "refreshed-fallback-access", + refresh: "refresh-1", + expires: Date.now() + 60_000, + multiAccount: true, + }); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const customManager = buildCustomManager(accountOne, accountOne, { + accounts: [accountOne], + getAccountCount: () => 1, + getAccountsSnapshot: () => [accountOne], + getAccountByIndex: (index: number) => (index === 0 ? accountOne : null), + toAuthDetails: () => ({ + type: "oauth" as const, + access: accountOne.access, + refresh: accountOne.refreshToken, + expires: accountOne.expires, + multiAccount: true, + }), + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(new Response("streaming", { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "fallback-ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", stream: true }), + }); + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 0]); + expect(getFetchAccessTokenSequence()).toEqual([ + "access-account-1", + "refreshed-fallback-access", + ]); + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); + + it("skips manual account selection injection when direct injection is disabled", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); + try { + const { plugin } = await setupPlugin(); + syncCodexCliSelectionMock.mockClear(); + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + expect(mockStorage.activeIndex).toBe(1); + expect(mockStorage.activeIndexByFamily["gpt-5.1"]).toBe(1); + } finally { + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + } + }); + + it("keeps a recovered failover response when forced CLI reinjection hits transient windows write errors", async () => { + vi.doMock("../lib/request/stream-failover.js", () => ({ + withStreamingFailover: async ( + response: Response, + failover: (attempt: number, emittedBytes: number) => Promise, + ) => (await failover(1, 128)) ?? response, + })); + try { + for (const code of ["EACCES", "EBUSY"] as const) { + vi.resetModules(); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + const loggerModule = await import("../lib/logger.js"); + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + const customManager = buildCustomManager(accountOne, accountTwo, { + getCurrentOrNextForFamilyHybrid: vi + .fn() + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null), + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + syncCodexCliSelectionMock.mockClear(); + syncCodexCliSelectionMock + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(Object.assign(new Error(`${code} fail`), { code })); + vi.mocked(loggerModule.logWarn).mockClear(); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(new Response("streaming", { status: 200 })) + .mockResolvedValueOnce(new Response("retry later", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: `${code}-ok` }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", stream: true }), + }); + + expect(response.status).toBe(200); + expect(await response.text()).toContain(`${code}-ok`); + expect(vi.mocked(loggerModule.logWarn)).toHaveBeenCalledWith( + expect.stringContaining("Codex CLI selection sync failed"), + expect.objectContaining({ + accountIndex: 1, + code, + }), + ); + } + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); + + it("retries a warm cached fallback selection after a forced reinjection write fails", async () => { + vi.doMock("../lib/request/stream-failover.js", () => ({ + withStreamingFailover: async ( + response: Response, + failover: (attempt: number, emittedBytes: number) => Promise, + ) => (await failover(1, 128)) ?? response, + })); + try { + vi.resetModules(); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + const currentAccount = vi + .fn() + .mockReturnValueOnce(accountTwo) + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null); + const customManager = buildCustomManager(accountOne, accountTwo, { + getCurrentOrNextForFamilyHybrid: currentAccount, + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + syncCodexCliSelectionMock.mockClear(); + syncCodexCliSelectionMock + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockRejectedValueOnce( + Object.assign(new Error("EACCES fail"), { code: "EACCES" }), + ) + .mockResolvedValueOnce(true); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "seed" }), { status: 200 }), + ) + .mockResolvedValueOnce(new Response("streaming", { status: 200 })) + .mockResolvedValueOnce(new Response("retry later", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "fallback-ok" }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "steady-ok" }), { status: 200 }), + ); + + const { sdk } = await setupPlugin(); + const seedResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + const failoverResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", stream: true }), + }); + const steadyStateResponse = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); - await plugin.tool["codex-remove"].execute({ index: 1 }); - // After removing account at 0-based index 0, length is 2. - // activeIndex (2) >= length (2), so it resets to 0 - expect(mockStorage.activeIndex).toBe(0); - expect(mockStorage.activeIndexByFamily.codex).toBe(0); + expect(seedResponse.status).toBe(200); + expect(failoverResponse.status).toBe(200); + expect(steadyStateResponse.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([ + 1, + 0, + 1, + 1, + ]); + expect(getFetchAccessTokenSequence()).toEqual([ + "access-account-2", + "access-account-1", + "access-account-1", + "access-account-2", + "access-account-2", + ]); + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } }); - it("resets activeIndex when removing active account at end", async () => { - mockStorage.accounts = [ - { refreshToken: "r1", email: "user1@example.com" }, - { refreshToken: "r2", email: "user2@example.com" }, - ]; - mockStorage.activeIndex = 1; - mockStorage.activeIndexByFamily = { codex: 1 }; - - const mockClient = createMockClient(); + it("skips automatic CLI injection when direct injection is disabled", async () => { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); + try { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "test" }), { status: 200 }), + ); - const { OpenAIOAuthPlugin } = await import("../index.js"); - const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); - await plugin.tool["codex-remove"].execute({ index: 2 }); - expect(mockStorage.activeIndex).toBe(0); + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + } finally { + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + } }); -}); -describe("OpenAIOAuthPlugin fetch handler", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - vi.clearAllMocks(); - syncCodexCliSelectionMock.mockClear(); - mockStorage.accounts = [ - { + it("skips failover CLI injection when direct injection is disabled", async () => { + vi.doMock("../lib/request/stream-failover.js", () => ({ + withStreamingFailover: async ( + response: Response, + failover: (attempt: number, emittedBytes: number) => Promise, + ) => (await failover(1, 128)) ?? response, + })); + try { + vi.resetModules(); + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( + (_init, _accountId, accessToken) => + new Headers({ "x-test-access-token": String(accessToken) }), + ); + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, accountId: "acc-1", - email: "user@example.com", + email: "user1@example.com", refreshToken: "refresh-1", - }, - ]; - mockStorage.activeIndex = 0; - mockStorage.activeIndexByFamily = {}; - originalFetch = globalThis.fetch; - }); + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, + }; + const customManager = buildCustomManager(accountOne, accountTwo, { + getCurrentOrNextForFamilyHybrid: vi + .fn() + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null), + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce(new Response("streaming", { status: 200 })) + .mockResolvedValueOnce(new Response("retry later", { status: 429 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ content: "fallback-ok" }), { status: 200 }), + ); - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1", stream: true }), + }); - const setupPlugin = async () => { - const mockClient = createMockClient(); - const { OpenAIOAuthPlugin } = await import("../index.js"); - const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + } finally { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); - const getAuth = async () => ({ - type: "oauth" as const, - access: "access-token", + it("skips refresh-driven CLI injection when direct injection is disabled", async () => { + const configModule = await import("../lib/config.js"); + const fetchHelpers = await import("../lib/request/fetch-helpers.js"); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); + vi.mocked(fetchHelpers.shouldRefreshToken).mockReturnValueOnce(true); + vi.mocked(fetchHelpers.refreshAndUpdateToken).mockResolvedValueOnce({ + type: "oauth", + access: "refreshed-disabled-access", refresh: "refresh-token", expires: Date.now() + 60_000, multiAccount: true, }); + try { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "test" }), { status: 200 }), + ); - const sdk = await plugin.auth.loader(getAuth, { options: {}, models: {} }); - return { plugin, sdk, mockClient }; - }; - - it("returns success response for successful fetch", async () => { - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ content: "test" }), { status: 200 }), - ); - - const { sdk } = await setupPlugin(); - const response = await sdk.fetch!("https://api.openai.com/v1/chat", { - method: "POST", - body: JSON.stringify({ model: "gpt-5.1" }), - }); + const { sdk } = await setupPlugin(); + const response = await sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); - expect(response.status).toBe(200); - expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(0); + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + } finally { + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + } }); it("uses the refreshed token email when checking entitlement blocks", async () => { @@ -1182,7 +2307,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(response.status).toBe(503); expect(await response.text()).toContain("server errors or auth issues"); - expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(0); }); it("does not penalize account health when fetch is aborted by user", async () => { @@ -1232,30 +2357,37 @@ describe("OpenAIOAuthPlugin fetch handler", () => { it("continues to next account when local token bucket is depleted", async () => { const { AccountManager } = await import("../lib/accounts.js"); - const accountOne = { + const accountOne: TestAccount = { index: 0, accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 1, }; - const accountTwo = { + const accountTwo: TestAccount = { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 2, }; - const countSpy = vi - .spyOn(AccountManager.prototype, "getAccountCount") - .mockReturnValue(2); - const selectionSpy = vi - .spyOn(AccountManager.prototype, "getCurrentOrNextForFamilyHybrid") - .mockImplementationOnce(() => accountOne) - .mockImplementationOnce(() => accountTwo) - .mockImplementation(() => null); const consumeSpy = vi - .spyOn(AccountManager.prototype, "consumeToken") + .fn() .mockReturnValueOnce(false) .mockReturnValueOnce(true); + const customManager = buildCustomManager(accountOne, accountTwo, { + getCurrentOrNextForFamilyHybrid: vi + .fn() + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null), + consumeToken: consumeSpy, + }); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); globalThis.fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ content: "from-second-account" }), { status: 200 }), ); @@ -1269,9 +2401,6 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(response.status).toBe(200); expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(consumeSpy).toHaveBeenCalledTimes(2); - countSpy.mockRestore(); - selectionSpy.mockRestore(); - consumeSpy.mockRestore(); }); it("treats timeout-triggered abort as network failure", async () => { @@ -1626,8 +2755,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { let legacySelection = 0; let fallbackSelection = 0; - const customManager = { - getAccountCount: () => 2, + const customManager = buildCustomManager(accountOne, accountTwo, { getCurrentOrNextForFamilyHybrid: (_family: string, currentModel?: string) => { if (currentModel === "gpt-5-codex") { if (fallbackSelection === 0) { @@ -1666,7 +2794,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { recordRateLimit: () => {}, consumeToken: () => true, refundToken: () => {}, - syncCodexCliActiveSelectionForIndex: async () => {}, + syncCodexCliActiveSelectionForIndex: async () => true, markSwitched: () => {}, removeAccount: () => {}, recordFailure: () => {}, @@ -1676,7 +2804,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { markToastShown: () => {}, setActiveIndex: () => accountOne, getAccountsSnapshot: () => [accountOne, accountTwo], - }; + }); vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValueOnce(customManager as never); vi.mocked(configModule.getFallbackOnUnsupportedCodexModel).mockReturnValueOnce(true); @@ -2408,7 +3536,188 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { event: { type: "account.select", properties: { index: 1 } }, }); - expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(loadFromDiskSpy).toHaveBeenCalledTimes(2); + expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(1); + expect(loadFromDiskSpy.mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY).toBeLessThan( + syncCodexCliSelectionMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it("uses the latest reloaded manager for a queued account.select sync", async () => { + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; + + let resolveSync: (() => void) | null = null; + let notifySyncStarted: (() => void) | null = null; + const syncStarted = new Promise((resolve) => { + notifySyncStarted = resolve; + }); + syncCodexCliSelectionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + notifySyncStarted?.(); + resolveSync = () => resolve(true); + }), + ); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ content: "ok" }), { status: 200 }), + ); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const { AccountManager } = await import("../lib/accounts.js"); + const getAuth = async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + const sdk = await plugin.auth.loader(getAuth, { options: {}, models: {} }); + const loadFromDiskSpy = vi.spyOn(AccountManager, "loadFromDisk"); + loadFromDiskSpy.mockClear(); + const staleSync = vi.fn(async () => true); + const freshSync = vi.fn(async () => true); + loadFromDiskSpy + .mockResolvedValueOnce({ + syncCodexCliActiveSelectionForIndex: staleSync, + } as never) + .mockResolvedValueOnce({ + syncCodexCliActiveSelectionForIndex: freshSync, + } as never); + + const requestPromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + await syncStarted; + + const eventPromise = plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + await vi.waitFor(() => expect(loadFromDiskSpy).toHaveBeenCalledTimes(1)); + resolveSync?.(); + await requestPromise; + await eventPromise; + + expect(loadFromDiskSpy).toHaveBeenCalledTimes(2); + expect(staleSync).not.toHaveBeenCalled(); + expect(freshSync).toHaveBeenCalledTimes(1); + expect(freshSync).toHaveBeenCalledWith(1); + }); + + it("re-resolves the live account by unique identity after a same-timestamp reindex", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const originalAccountAtIndexZero = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + access: "access-account-1", + expires: Date.now() + 60_000, + addedAt: 7, + }; + const siblingAccountAtIndexOne = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + addedAt: 7, + }; + const siblingAccountAtIndexZero = { + ...siblingAccountAtIndexOne, + index: 0, + }; + const originalAccountAtIndexOne = { + ...originalAccountAtIndexZero, + index: 1, + }; + let currentAccounts = [originalAccountAtIndexZero, siblingAccountAtIndexOne]; + const customManager = buildCustomManager( + originalAccountAtIndexZero, + siblingAccountAtIndexOne, + { + getCurrentOrNextForFamilyHybrid: vi + .fn() + .mockReturnValueOnce(originalAccountAtIndexZero) + .mockImplementation(() => null), + getAccountsSnapshot: () => currentAccounts, + getAccountByIndex: (index: number) => currentAccounts[index] ?? null, + }, + ); + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + + let resolveFetch: (() => void) | null = null; + globalThis.fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => + resolve(new Response(JSON.stringify({ content: "ok" }), { status: 200 })); + }), + ); + + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const getAuth = async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + const sdk = await plugin.auth.loader(getAuth, { options: {}, models: {} }); + const responsePromise = sdk.fetch!("https://api.openai.com/v1/chat", { + method: "POST", + body: JSON.stringify({ model: "gpt-5.1" }), + }); + + await vi.waitFor(() => expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1)); + currentAccounts = [siblingAccountAtIndexZero, originalAccountAtIndexOne]; + resolveFetch?.(); + + const response = await responsePromise; + + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + }); + + it("reloads account manager from disk when handling account.select even if direct injection is disabled", async () => { + const mockClient = createMockClient(); + const { OpenAIOAuthPlugin } = await import("../index.js"); + const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const { AccountManager } = await import("../lib/accounts.js"); + const loadFromDiskSpy = vi.spyOn(AccountManager, "loadFromDisk"); + const configModule = await import("../lib/config.js"); + + const getAuth = async () => ({ + type: "oauth" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: true, + }); + + await plugin.auth.loader(getAuth, { options: {}, models: {} }); + loadFromDiskSpy.mockClear(); + syncCodexCliSelectionMock.mockClear(); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); + try { + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + } finally { + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + } }); it("handles openai.account.select with openai provider", async () => { @@ -2434,14 +3743,26 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { }); }); - it("ignores account.select when cachedAccountManager is null", async () => { + it("persists account.select when the cache is empty", async () => { + vi.resetModules(); const mockClient = createMockClient(); const { OpenAIOAuthPlugin } = await import("../index.js"); const plugin = await OpenAIOAuthPlugin({ client: mockClient } as never) as unknown as PluginType; + const { AccountManager } = await import("../lib/accounts.js"); + const loadFromDiskSpy = vi.spyOn(AccountManager, "loadFromDisk"); + + mockStorage.accounts = [ + { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1" }, + { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, + ]; await plugin.event({ - event: { type: "account.select", properties: { index: 0 } }, + event: { type: "account.select", properties: { index: 1 } }, }); + + expect(loadFromDiskSpy).toHaveBeenCalledTimes(2); + expect(mockStorage.activeIndex).toBe(1); + expect(mockStorage.activeIndexByFamily["gpt-5.1"]).toBe(1); }); it("handles non-numeric index gracefully", async () => { diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 9caebf96..5cbd587e 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -17,6 +17,7 @@ import { getUnsupportedCodexFallbackChain, getFetchTimeoutMs, getStreamStallTimeoutMs, + getCodexCliDirectInjection, getPreemptiveQuotaEnabled, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, @@ -67,8 +68,55 @@ describe('Plugin Configuration', () => { 'CODEX_AUTH_PREEMPTIVE_QUOTA_5H_REMAINING_PCT', 'CODEX_AUTH_PREEMPTIVE_QUOTA_7D_REMAINING_PCT', 'CODEX_AUTH_PREEMPTIVE_QUOTA_MAX_DEFERRAL_MS', + 'CODEX_AUTH_DIRECT_CLI_INJECTION', ] as const; const originalEnv: Partial> = {}; + const expectedDefaultConfig = (): PluginConfig => ({ + codexMode: true, + codexTuiV2: true, + codexTuiColorProfile: 'truecolor', + codexTuiGlyphMode: 'ascii', + fastSession: false, + fastSessionStrategy: 'hybrid', + fastSessionMaxInputItems: 30, + retryAllAccountsRateLimited: true, + retryAllAccountsMaxWaitMs: 0, + retryAllAccountsMaxRetries: Infinity, + unsupportedCodexPolicy: 'strict', + fallbackOnUnsupportedCodexModel: false, + fallbackToGpt52OnUnsupportedGpt53: true, + unsupportedCodexFallbackChain: {}, + tokenRefreshSkewMs: 60_000, + rateLimitToastDebounceMs: 60_000, + toastDurationMs: 5_000, + perProjectAccounts: true, + sessionRecovery: true, + autoResume: true, + parallelProbing: false, + parallelProbingMaxConcurrency: 2, + emptyResponseMaxRetries: 2, + emptyResponseRetryDelayMs: 1_000, + pidOffsetEnabled: false, + fetchTimeoutMs: 60_000, + streamStallTimeoutMs: 45_000, + liveAccountSync: true, + codexCliDirectInjection: true, + liveAccountSyncDebounceMs: 250, + liveAccountSyncPollMs: 2_000, + sessionAffinity: true, + sessionAffinityTtlMs: 20 * 60_000, + sessionAffinityMaxEntries: 512, + proactiveRefreshGuardian: true, + proactiveRefreshIntervalMs: 60_000, + proactiveRefreshBufferMs: 5 * 60_000, + networkErrorCooldownMs: 6_000, + serverErrorCooldownMs: 4_000, + storageBackupEnabled: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 10, + preemptiveQuotaRemainingPercent7d: 5, + preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, + }); beforeEach(() => { for (const key of envKeys) { @@ -95,51 +143,7 @@ describe('Plugin Configuration', () => { const config = loadPluginConfig(); - expect(config).toEqual({ - codexMode: true, - codexTuiV2: true, - codexTuiColorProfile: 'truecolor', - codexTuiGlyphMode: 'ascii', - fastSession: false, - fastSessionStrategy: 'hybrid', - fastSessionMaxInputItems: 30, - retryAllAccountsRateLimited: true, - retryAllAccountsMaxWaitMs: 0, - retryAllAccountsMaxRetries: Infinity, - unsupportedCodexPolicy: 'strict', - fallbackOnUnsupportedCodexModel: false, - fallbackToGpt52OnUnsupportedGpt53: true, - unsupportedCodexFallbackChain: {}, - tokenRefreshSkewMs: 60_000, - rateLimitToastDebounceMs: 60_000, - toastDurationMs: 5_000, - perProjectAccounts: true, - sessionRecovery: true, - autoResume: true, - parallelProbing: false, - parallelProbingMaxConcurrency: 2, - emptyResponseMaxRetries: 2, - emptyResponseRetryDelayMs: 1_000, - pidOffsetEnabled: false, - fetchTimeoutMs: 60_000, - streamStallTimeoutMs: 45_000, - liveAccountSync: true, - liveAccountSyncDebounceMs: 250, - liveAccountSyncPollMs: 2_000, - sessionAffinity: true, - sessionAffinityTtlMs: 20 * 60_000, - sessionAffinityMaxEntries: 512, - proactiveRefreshGuardian: true, - proactiveRefreshIntervalMs: 60_000, - proactiveRefreshBufferMs: 5 * 60_000, - networkErrorCooldownMs: 6_000, - serverErrorCooldownMs: 4_000, - storageBackupEnabled: true, - preemptiveQuotaEnabled: true, - preemptiveQuotaRemainingPercent5h: 5, - preemptiveQuotaRemainingPercent7d: 5, - preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, - }); + expect(config).toEqual(expectedDefaultConfig()); // existsSync is called with multiple candidate config paths (primary + legacy fallbacks) expect(mockExistsSync).toHaveBeenCalled(); expect(mockExistsSync.mock.calls.some(([p]) => @@ -154,49 +158,8 @@ describe('Plugin Configuration', () => { const config = loadPluginConfig(); expect(config).toEqual({ + ...expectedDefaultConfig(), codexMode: false, - codexTuiV2: true, - codexTuiColorProfile: 'truecolor', - codexTuiGlyphMode: 'ascii', - fastSession: false, - fastSessionStrategy: 'hybrid', - fastSessionMaxInputItems: 30, - retryAllAccountsRateLimited: true, - retryAllAccountsMaxWaitMs: 0, - retryAllAccountsMaxRetries: Infinity, - unsupportedCodexPolicy: 'strict', - fallbackOnUnsupportedCodexModel: false, - fallbackToGpt52OnUnsupportedGpt53: true, - unsupportedCodexFallbackChain: {}, - tokenRefreshSkewMs: 60_000, - rateLimitToastDebounceMs: 60_000, - toastDurationMs: 5_000, - perProjectAccounts: true, - sessionRecovery: true, - autoResume: true, - parallelProbing: false, - parallelProbingMaxConcurrency: 2, - emptyResponseMaxRetries: 2, - emptyResponseRetryDelayMs: 1_000, - pidOffsetEnabled: false, - fetchTimeoutMs: 60_000, - streamStallTimeoutMs: 45_000, - liveAccountSync: true, - liveAccountSyncDebounceMs: 250, - liveAccountSyncPollMs: 2_000, - sessionAffinity: true, - sessionAffinityTtlMs: 20 * 60_000, - sessionAffinityMaxEntries: 512, - proactiveRefreshGuardian: true, - proactiveRefreshIntervalMs: 60_000, - proactiveRefreshBufferMs: 5 * 60_000, - networkErrorCooldownMs: 6_000, - serverErrorCooldownMs: 4_000, - storageBackupEnabled: true, - preemptiveQuotaEnabled: true, - preemptiveQuotaRemainingPercent5h: 5, - preemptiveQuotaRemainingPercent7d: 5, - preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, }); }); @@ -408,51 +371,7 @@ describe('Plugin Configuration', () => { const config = loadPluginConfig(); - expect(config).toEqual({ - codexMode: true, - codexTuiV2: true, - codexTuiColorProfile: 'truecolor', - codexTuiGlyphMode: 'ascii', - fastSession: false, - fastSessionStrategy: 'hybrid', - fastSessionMaxInputItems: 30, - retryAllAccountsRateLimited: true, - retryAllAccountsMaxWaitMs: 0, - retryAllAccountsMaxRetries: Infinity, - unsupportedCodexPolicy: 'strict', - fallbackOnUnsupportedCodexModel: false, - fallbackToGpt52OnUnsupportedGpt53: true, - unsupportedCodexFallbackChain: {}, - tokenRefreshSkewMs: 60_000, - rateLimitToastDebounceMs: 60_000, - toastDurationMs: 5_000, - perProjectAccounts: true, - sessionRecovery: true, - autoResume: true, - parallelProbing: false, - parallelProbingMaxConcurrency: 2, - emptyResponseMaxRetries: 2, - emptyResponseRetryDelayMs: 1_000, - pidOffsetEnabled: false, - fetchTimeoutMs: 60_000, - streamStallTimeoutMs: 45_000, - liveAccountSync: true, - liveAccountSyncDebounceMs: 250, - liveAccountSyncPollMs: 2_000, - sessionAffinity: true, - sessionAffinityTtlMs: 20 * 60_000, - sessionAffinityMaxEntries: 512, - proactiveRefreshGuardian: true, - proactiveRefreshIntervalMs: 60_000, - proactiveRefreshBufferMs: 5 * 60_000, - networkErrorCooldownMs: 6_000, - serverErrorCooldownMs: 4_000, - storageBackupEnabled: true, - preemptiveQuotaEnabled: true, - preemptiveQuotaRemainingPercent5h: 5, - preemptiveQuotaRemainingPercent7d: 5, - preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, - }); + expect(config).toEqual(expectedDefaultConfig()); }); it('should parse UTF-8 BOM-prefixed config files', () => { @@ -472,51 +391,7 @@ describe('Plugin Configuration', () => { mockLogWarn.mockClear(); const config = loadPluginConfig(); - expect(config).toEqual({ - codexMode: true, - codexTuiV2: true, - codexTuiColorProfile: 'truecolor', - codexTuiGlyphMode: 'ascii', - fastSession: false, - fastSessionStrategy: 'hybrid', - fastSessionMaxInputItems: 30, - retryAllAccountsRateLimited: true, - retryAllAccountsMaxWaitMs: 0, - retryAllAccountsMaxRetries: Infinity, - unsupportedCodexPolicy: 'strict', - fallbackOnUnsupportedCodexModel: false, - fallbackToGpt52OnUnsupportedGpt53: true, - unsupportedCodexFallbackChain: {}, - tokenRefreshSkewMs: 60_000, - rateLimitToastDebounceMs: 60_000, - toastDurationMs: 5_000, - perProjectAccounts: true, - sessionRecovery: true, - autoResume: true, - parallelProbing: false, - parallelProbingMaxConcurrency: 2, - emptyResponseMaxRetries: 2, - emptyResponseRetryDelayMs: 1_000, - pidOffsetEnabled: false, - fetchTimeoutMs: 60_000, - streamStallTimeoutMs: 45_000, - liveAccountSync: true, - liveAccountSyncDebounceMs: 250, - liveAccountSyncPollMs: 2_000, - sessionAffinity: true, - sessionAffinityTtlMs: 20 * 60_000, - sessionAffinityMaxEntries: 512, - proactiveRefreshGuardian: true, - proactiveRefreshIntervalMs: 60_000, - proactiveRefreshBufferMs: 5 * 60_000, - networkErrorCooldownMs: 6_000, - serverErrorCooldownMs: 4_000, - storageBackupEnabled: true, - preemptiveQuotaEnabled: true, - preemptiveQuotaRemainingPercent5h: 5, - preemptiveQuotaRemainingPercent7d: 5, - preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, - }); + expect(config).toEqual(expectedDefaultConfig()); expect(mockLogWarn).toHaveBeenCalled(); }); @@ -530,51 +405,7 @@ describe('Plugin Configuration', () => { mockLogWarn.mockClear(); const config = loadPluginConfig(); - expect(config).toEqual({ - codexMode: true, - codexTuiV2: true, - codexTuiColorProfile: 'truecolor', - codexTuiGlyphMode: 'ascii', - fastSession: false, - fastSessionStrategy: 'hybrid', - fastSessionMaxInputItems: 30, - retryAllAccountsRateLimited: true, - retryAllAccountsMaxWaitMs: 0, - retryAllAccountsMaxRetries: Infinity, - unsupportedCodexPolicy: 'strict', - fallbackOnUnsupportedCodexModel: false, - fallbackToGpt52OnUnsupportedGpt53: true, - unsupportedCodexFallbackChain: {}, - tokenRefreshSkewMs: 60_000, - rateLimitToastDebounceMs: 60_000, - toastDurationMs: 5_000, - perProjectAccounts: true, - sessionRecovery: true, - autoResume: true, - parallelProbing: false, - parallelProbingMaxConcurrency: 2, - emptyResponseMaxRetries: 2, - emptyResponseRetryDelayMs: 1_000, - pidOffsetEnabled: false, - fetchTimeoutMs: 60_000, - streamStallTimeoutMs: 45_000, - liveAccountSync: true, - liveAccountSyncDebounceMs: 250, - liveAccountSyncPollMs: 2_000, - sessionAffinity: true, - sessionAffinityTtlMs: 20 * 60_000, - sessionAffinityMaxEntries: 512, - proactiveRefreshGuardian: true, - proactiveRefreshIntervalMs: 60_000, - proactiveRefreshBufferMs: 5 * 60_000, - networkErrorCooldownMs: 6_000, - serverErrorCooldownMs: 4_000, - storageBackupEnabled: true, - preemptiveQuotaEnabled: true, - preemptiveQuotaRemainingPercent5h: 5, - preemptiveQuotaRemainingPercent7d: 5, - preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, - }); + expect(config).toEqual(expectedDefaultConfig()); expect(mockLogWarn).toHaveBeenCalled(); }); @@ -964,7 +795,7 @@ describe('Plugin Configuration', () => { describe('preemptive quota settings', () => { it('should use default thresholds', () => { expect(getPreemptiveQuotaEnabled({})).toBe(true); - expect(getPreemptiveQuotaRemainingPercent5h({})).toBe(5); + expect(getPreemptiveQuotaRemainingPercent5h({})).toBe(10); expect(getPreemptiveQuotaRemainingPercent7d({})).toBe(5); expect(getPreemptiveQuotaMaxDeferralMs({})).toBe(2 * 60 * 60_000); }); @@ -980,5 +811,28 @@ describe('Plugin Configuration', () => { expect(getPreemptiveQuotaMaxDeferralMs({ preemptiveQuotaMaxDeferralMs: 2_000 })).toBe(123000); }); }); + + describe('direct cli injection setting', () => { + it('defaults to enabled', () => { + expect(getCodexCliDirectInjection({})).toBe(true); + }); + + it('prioritizes environment override', () => { + process.env.CODEX_AUTH_DIRECT_CLI_INJECTION = '0'; + expect(getCodexCliDirectInjection({ codexCliDirectInjection: true })).toBe(false); + delete process.env.CODEX_AUTH_DIRECT_CLI_INJECTION; + }); + + it('honors env true over config false', () => { + process.env.CODEX_AUTH_DIRECT_CLI_INJECTION = '1'; + expect(getCodexCliDirectInjection({ codexCliDirectInjection: false })).toBe(true); + delete process.env.CODEX_AUTH_DIRECT_CLI_INJECTION; + }); + + it('honors config false when no env override is present', () => { + delete process.env.CODEX_AUTH_DIRECT_CLI_INJECTION; + expect(getCodexCliDirectInjection({ codexCliDirectInjection: false })).toBe(false); + }); + }); }); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 2c56244b..089b9810 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -427,16 +427,19 @@ describe("settings-hub utility coverage", () => { const api = await loadSettingsHubTestApi(); const configModule = await import("../lib/config.js"); const selected = configModule.getDefaultPluginConfig(); + selected.codexCliDirectInjection = false; selected.fetchTimeoutMs = 12_345; selected.streamStallTimeoutMs = 23_456; const saved = await api.persistBackendConfigSelection(selected, "backend"); + expect(saved.codexCliDirectInjection).toBe(false); expect(saved.fetchTimeoutMs).toBe(12_345); expect(saved.streamStallTimeoutMs).toBe(23_456); vi.resetModules(); const freshConfigModule = await import("../lib/config.js"); const reloaded = freshConfigModule.loadPluginConfig(); + expect(reloaded.codexCliDirectInjection).toBe(false); expect(reloaded.fetchTimeoutMs).toBe(12_345); expect(reloaded.streamStallTimeoutMs).toBe(23_456); }); @@ -687,5 +690,175 @@ describe("settings-hub utility coverage", () => { }); expect(selected?.proactiveRefreshIntervalMs).toBe(60_000); }); + + it("toggles direct injection and opens rotation quota controls from experimental settings", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + { type: "toggle-direct-cli-injection" }, + { type: "toggle-session-affinity" }, + { type: "toggle-pool-retry" }, + { type: "open-rotation-quota" }, + { type: "toggle", key: "preemptiveQuotaEnabled" }, + { + type: "bump", + key: "preemptiveQuotaRemainingPercent5h", + direction: 1, + }, + { type: "back" }, + { type: "save" }, + ); + const selected = await api.promptExperimentalSettings({ + codexCliDirectInjection: true, + sessionAffinity: true, + retryAllAccountsRateLimited: true, + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 10, + }); + expect(selected).toEqual( + expect.objectContaining({ + codexCliDirectInjection: false, + sessionAffinity: false, + retryAllAccountsRateLimited: false, + preemptiveQuotaEnabled: false, + preemptiveQuotaRemainingPercent5h: 11, + }), + ); + }); + + it("treats missing direct injection config as enabled before toggling it off", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + { type: "toggle-direct-cli-injection" }, + { type: "save" }, + ); + const selected = await api.promptExperimentalSettings({}); + expect(selected?.codexCliDirectInjection).toBe(false); + }); + + it("treats missing session affinity config as enabled before toggling it off", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + { type: "toggle-session-affinity" }, + { type: "save" }, + ); + + const selected = await api.promptExperimentalSettings({}); + + expect(selected?.sessionAffinity).toBe(false); + }); + + it("treats missing preemptive quota config as enabled before toggling it off", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + { type: "toggle-preemptive-quota" }, + { type: "save" }, + ); + + const selected = await api.promptExperimentalSettings({}); + + expect(selected?.preemptiveQuotaEnabled).toBe(false); + }); + + it("treats missing pool retry config as enabled before toggling it off", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + { type: "toggle-pool-retry" }, + { type: "save" }, + ); + + const selected = await api.promptExperimentalSettings({}); + + expect(selected?.retryAllAccountsRateLimited).toBe(false); + }); + + it("shows default-true experimental toggles as enabled when unset", async () => { + const api = await loadSettingsHubTestApi(); + let observedLabels: string[] = []; + selectHandler = async (items) => { + observedLabels = items.map((item) => item.label); + return { type: "save" }; + }; + + const selected = await api.promptExperimentalSettings({}); + + expect(selected).not.toBeNull(); + expect(observedLabels).toEqual( + expect.arrayContaining([ + "[x] Direct Codex CLI Injection", + "[x] Manual Session Lock", + "[x] Retry Whole Pool on Rate Limit", + "[x] Auto-Rotate on Quota Pressure", + ]), + ); + }); + + it("preserves experimental toggles when backend settings reset is used", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + { type: "reset" }, + { type: "save" }, + ); + const selected = await api.promptBackendSettings({ + codexCliDirectInjection: false, + proactiveRefreshGuardian: false, + proactiveRefreshIntervalMs: 120_000, + sessionAffinity: false, + retryAllAccountsRateLimited: false, + preemptiveQuotaEnabled: false, + preemptiveQuotaRemainingPercent5h: 25, + preemptiveQuotaRemainingPercent7d: 15, + preemptiveQuotaMaxDeferralMs: 180_000, + }); + expect(selected).toEqual( + expect.objectContaining({ + codexCliDirectInjection: false, + proactiveRefreshGuardian: false, + proactiveRefreshIntervalMs: 120_000, + sessionAffinity: false, + retryAllAccountsRateLimited: false, + preemptiveQuotaEnabled: false, + preemptiveQuotaRemainingPercent5h: 25, + preemptiveQuotaRemainingPercent7d: 15, + preemptiveQuotaMaxDeferralMs: 180_000, + }), + ); + }); + + it("keeps the experimental rotation quota o shortcut scoped to the outer prompt", async () => { + const api = await loadSettingsHubTestApi(); + let observedShortcutResult: unknown; + let observedInnerShortcutResult: unknown; + let callCount = 0; + selectHandler = async (_items, options) => { + callCount += 1; + if (callCount === 1) { + observedShortcutResult = (options as { + onInput?: (raw: string) => unknown; + }).onInput?.("o"); + return observedShortcutResult; + } + if (callCount === 2) { + observedInnerShortcutResult = (options as { + onInput?: (raw: string) => unknown; + }).onInput?.("o"); + return { type: "back" }; + } + return { type: "save" }; + }; + + const selected = await api.promptExperimentalSettings({ + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 10, + }); + + expect(observedShortcutResult).toEqual({ type: "open-rotation-quota" }); + expect(observedInnerShortcutResult).toBeUndefined(); + expect(selected).toEqual( + expect.objectContaining({ + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 10, + }), + ); + }); }); });