From 7d9b3a83385b327c44b801d6bcab7ad2285c9c3b Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 21:28:25 +0800 Subject: [PATCH 01/17] feat: inject codex cli account selection during live rotation --- index.ts | 54 ++++- lib/accounts.ts | 10 +- lib/codex-manager/settings-hub.ts | 75 ++++++- lib/config.ts | 13 +- lib/schemas.ts | 1 + lib/ui/copy.ts | 9 +- test/accounts-load-from-disk.test.ts | 6 +- test/index.test.ts | 60 +++++- test/plugin-config.test.ts | 289 ++++++--------------------- test/settings-hub-utils.test.ts | 34 ++++ 10 files changed, 308 insertions(+), 243 deletions(-) diff --git a/index.ts b/index.ts index 147959c4..54427fe2 100644 --- a/index.ts +++ b/index.ts @@ -63,6 +63,7 @@ import { getCodexTuiColorProfile, getCodexTuiGlyphMode, getLiveAccountSync, + getCodexCliDirectInjection, getLiveAccountSyncDebounceMs, getLiveAccountSyncPollMs, getSessionAffinity, @@ -227,6 +228,7 @@ 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 perProjectStorageWarningShown = false; let liveAccountSync: LiveAccountSync | null = null; let liveAccountSyncPath: string | null = null; @@ -964,6 +966,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); } lastCodexCliActiveSyncIndex = index; + lastCodexCliActiveSyncSignature = null; // Reload manager from disk so we don't overwrite newer rotated // refresh tokens with stale in-memory state. @@ -1107,6 +1110,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 +1158,41 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); }); + const buildCodexCliSelectionSignature = ( + account: { index: number; accountId?: string; email?: string; expires?: number }, + ): string => + [ + account.index, + account.accountId?.trim() ?? "", + account.email?.trim().toLowerCase() ?? "", + typeof account.expires === "number" ? account.expires : "", + ].join("|"); + + const syncCodexCliSelectionNow = async ( + account: { index: number; accountId?: string; email?: string; expires?: number }, + options?: { force?: boolean }, + ): Promise => { + if (!codexCliDirectInjectionEnabled) { + return false; + } + const signature = buildCodexCliSelectionSignature(account); + if ( + !options?.force && + lastCodexCliActiveSyncIndex === account.index && + lastCodexCliActiveSyncSignature === signature + ) { + return true; + } + const synced = await accountManager.syncCodexCliActiveSelectionForIndex( + account.index, + ); + if (synced) { + lastCodexCliActiveSyncIndex = account.index; + lastCodexCliActiveSyncSignature = signature; + } + return synced; + }; + // Return SDK configuration return { @@ -1442,6 +1482,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 +1492,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)}`); @@ -1537,6 +1579,10 @@ while (attempted.size < Math.max(1, accountCount)) { continue; } + await syncCodexCliSelectionNow(account, { + force: accountAuthRefreshed, + }); + if ( accountCount > 1 && accountManager.shouldShowAccountToast( @@ -2195,6 +2241,9 @@ while (attempted.size < Math.max(1, accountCount)) { sessionAffinityKey, fallbackAccount.index, ); + await syncCodexCliSelectionNow(fallbackAccount, { + force: true, + }); } logInfo( @@ -2312,10 +2361,7 @@ 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); return successResponse; } if (retryNextAccountBeforeFallback) { diff --git a/lib/accounts.ts b/lib/accounts.ts index f870b6b3..58b99162 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -389,12 +389,12 @@ export class AccountManager { 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; + 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..f90ec2b3 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" } @@ -2540,6 +2545,31 @@ async function promptExperimentalSettings( value: { type: "backup" }, color: "green", }, + { + label: `${formatDashboardSettingState(draft.codexCliDirectInjection ?? true)} ${UI_COPY.settings.experimentalDirectCliInjection}`, + value: { type: "toggle-direct-cli-injection" }, + color: "green", + }, + { + label: `${formatDashboardSettingState(draft.sessionAffinity ?? true)} ${UI_COPY.settings.experimentalManualSessionLock}`, + value: { type: "toggle-session-affinity" }, + color: "yellow", + }, + { + label: `${formatDashboardSettingState(draft.retryAllAccountsRateLimited ?? true)} ${UI_COPY.settings.experimentalPoolFallback}`, + value: { type: "toggle-pool-retry" }, + color: "green", + }, + { + label: `${formatDashboardSettingState(draft.preemptiveQuotaEnabled ?? true)} ${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 +2611,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 ?? true), + }; + continue; + } + if (action.type === "toggle-session-affinity") { + draft = { + ...draft, + sessionAffinity: !(draft.sessionAffinity ?? true), + }; + continue; + } + if (action.type === "toggle-preemptive-quota") { + draft = { + ...draft, + preemptiveQuotaEnabled: !(draft.preemptiveQuotaEnabled ?? true), + }; + continue; + } + if (action.type === "toggle-pool-retry") { + draft = { + ...draft, + retryAllAccountsRateLimited: !(draft.retryAllAccountsRateLimited ?? true), + }; + 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..3c91e899 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 () => { @@ -181,7 +181,7 @@ describe("AccountManager loadFromDisk", () => { await manager.syncCodexCliActiveSelectionForIndex(9); 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({ diff --git a/test/index.test.ts b/test/index.test.ts index 7810c942..865250bd 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, @@ -292,7 +293,7 @@ const withAccountStorageTransactionMock = vi.fn( }, ); -const syncCodexCliSelectionMock = vi.fn(async (_index: number) => {}); +const syncCodexCliSelectionMock = vi.fn(async (_index: number) => true); vi.mock("../lib/storage.js", async () => { const actual = await vi.importActual("../lib/storage.js"); @@ -1141,6 +1142,59 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(0); }); + it("injects the next account before a skipped account path settles", async () => { + const { AccountManager } = await import("../lib/accounts.js"); + const accountOne = { + index: 0, + accountId: "acc-1", + email: "user1@example.com", + refreshToken: "refresh-1", + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + }; + vi.spyOn(AccountManager.prototype, "getAccountCount").mockReturnValue(2); + vi.spyOn(AccountManager.prototype, "getCurrentOrNextForFamilyHybrid") + .mockImplementationOnce(() => accountOne) + .mockImplementationOnce(() => accountTwo) + .mockImplementation(() => null); + vi.spyOn(AccountManager.prototype, "consumeToken") + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + 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.mock.calls.map((call) => call[0])).toEqual([0, 1]); + }); + + it("skips automatic CLI injection when direct injection is disabled", async () => { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValueOnce(false); + 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).not.toHaveBeenCalled(); + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { mockStorage.accounts = [ { @@ -1182,7 +1236,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 () => { diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 9caebf96..2e90560c 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,17 @@ 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; + }); + }); }); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 2c56244b..ca1b7866 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -687,5 +687,39 @@ 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, + }), + ); + }); }); }); From bff00952bc13e5497193cabb04cb6f371b244228 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 21:42:59 +0800 Subject: [PATCH 02/17] fix: serialize live cli sync writes --- index.ts | 49 +++++++++++++++++++++++++++++----------------- test/index.test.ts | 39 ++++++++++++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/index.ts b/index.ts index 54427fe2..e46dc8a7 100644 --- a/index.ts +++ b/index.ts @@ -229,6 +229,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { 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; @@ -1176,21 +1177,34 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return false; } const signature = buildCodexCliSelectionSignature(account); - if ( - !options?.force && - lastCodexCliActiveSyncIndex === account.index && - lastCodexCliActiveSyncSignature === signature - ) { - return true; - } - const synced = await accountManager.syncCodexCliActiveSelectionForIndex( - account.index, - ); - if (synced) { - lastCodexCliActiveSyncIndex = account.index; - lastCodexCliActiveSyncSignature = signature; + const runSync = async (): Promise => { + if ( + !options?.force && + lastCodexCliActiveSyncIndex === account.index && + lastCodexCliActiveSyncSignature === signature + ) { + return true; + } + const synced = await accountManager.syncCodexCliActiveSelectionForIndex( + account.index, + ); + if (synced) { + lastCodexCliActiveSyncIndex = account.index; + lastCodexCliActiveSyncSignature = signature; + } + return synced; + }; + const priorSync = codexCliSelectionSyncQueue; + let releaseSyncQueue!: () => void; + codexCliSelectionSyncQueue = new Promise((resolve) => { + releaseSyncQueue = resolve; + }); + await priorSync.catch(() => undefined); + try { + return await runSync(); + } finally { + releaseSyncQueue(); } - return synced; }; @@ -1579,10 +1593,6 @@ while (attempted.size < Math.max(1, accountCount)) { continue; } - await syncCodexCliSelectionNow(account, { - force: accountAuthRefreshed, - }); - if ( accountCount > 1 && accountManager.shouldShowAccountToast( @@ -1637,6 +1647,9 @@ while (attempted.size < Math.max(1, accountCount)) { ); continue; } + await syncCodexCliSelectionNow(account, { + force: accountAuthRefreshed, + }); let sameAccountRetryCount = 0; let successAccountForResponse = account; diff --git a/test/index.test.ts b/test/index.test.ts index 865250bd..ed092c69 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1142,7 +1142,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(0); }); - it("injects the next account before a skipped account path settles", async () => { + it("injects only the account that clears token-bucket admission", async () => { const { AccountManager } = await import("../lib/accounts.js"); const accountOne = { index: 0, @@ -1175,7 +1175,42 @@ describe("OpenAIOAuthPlugin fetch handler", () => { }); expect(response.status).toBe(200); - expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 1]); + 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; + syncCodexCliSelectionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + 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" }), + }); + + for (let attempt = 0; attempt < 20 && syncCodexCliSelectionMock.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); + + resolveSync?.(); + const [firstResponse, secondResponse] = await Promise.all([firstFetch, secondFetch]); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); }); it("skips automatic CLI injection when direct injection is disabled", async () => { From 761073a0fcad9400070355982341b444a341e58f Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 22:08:48 +0800 Subject: [PATCH 03/17] fix: tighten cli injection review follow-ups --- index.ts | 37 +++++--- lib/accounts.ts | 1 - lib/codex-manager/settings-hub.ts | 7 ++ test/accounts-load-from-disk.test.ts | 23 +++++ test/index.test.ts | 134 ++++++++++++++++++++++++++- test/settings-hub-utils.test.ts | 3 + 6 files changed, 188 insertions(+), 17 deletions(-) diff --git a/index.ts b/index.ts index e46dc8a7..9fdccd51 100644 --- a/index.ts +++ b/index.ts @@ -964,15 +964,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await saveAccounts(storage); if (cachedAccountManager) { - await cachedAccountManager.syncCodexCliActiveSelectionForIndex(index); - } - lastCodexCliActiveSyncIndex = index; - lastCodexCliActiveSyncSignature = null; - - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state. - if (cachedAccountManager) { - await reloadAccountManagerFromDisk(); + const freshManager = await reloadAccountManagerFromDisk(); + const synced = await freshManager.syncCodexCliActiveSelectionForIndex(index); + if (synced) { + lastCodexCliActiveSyncIndex = index; + lastCodexCliActiveSyncSignature = null; + } } await showToast(`Switched to account ${index + 1}`, "info"); @@ -1160,17 +1157,33 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); const buildCodexCliSelectionSignature = ( - account: { index: number; accountId?: string; email?: string; expires?: number }, + account: { + index: number; + accountId?: string; + email?: string; + expires?: number; + expiresAt?: number; + }, ): string => [ account.index, account.accountId?.trim() ?? "", account.email?.trim().toLowerCase() ?? "", - typeof account.expires === "number" ? account.expires : "", + typeof account.expires === "number" + ? account.expires + : typeof account.expiresAt === "number" + ? account.expiresAt + : "", ].join("|"); const syncCodexCliSelectionNow = async ( - account: { index: number; accountId?: string; email?: string; expires?: number }, + account: { + index: number; + accountId?: string; + email?: string; + expires?: number; + expiresAt?: number; + }, options?: { force?: boolean }, ): Promise => { if (!codexCliDirectInjectionEnabled) { diff --git a/lib/accounts.ts b/lib/accounts.ts index 58b99162..ba31c5ce 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -385,7 +385,6 @@ export class AccountManager { account.lastUsed = nowMs(); account.lastSwitchReason = "rotation"; - void this.syncCodexCliActiveSelectionForIndex(account.index); return account; } diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index f90ec2b3..5e7da9b3 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1076,6 +1076,10 @@ function backendSettingsSnapshot( config: PluginConfig, ): Record { const snapshot: Record = {}; + snapshot.codexCliDirectInjection = + config.codexCliDirectInjection ?? + BACKEND_DEFAULTS.codexCliDirectInjection ?? + true; for (const option of BACKEND_TOGGLE_OPTIONS) { snapshot[option.key] = config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; @@ -1176,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") { diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts index 3c91e899..9d8745a8 100644 --- a/test/accounts-load-from-disk.test.ts +++ b/test/accounts-load-from-disk.test.ts @@ -192,6 +192,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 ed092c69..2b0e4a90 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -205,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 })), @@ -1180,9 +1180,14 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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); }), ); @@ -1200,9 +1205,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { body: JSON.stringify({ model: "gpt-5.1" }), }); - for (let attempt = 0; attempt < 20 && syncCodexCliSelectionMock.mock.calls.length === 0; attempt++) { - await Promise.resolve(); - } + await syncStarted; expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); resolveSync?.(); @@ -1213,6 +1216,125 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); }); + 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(true); + vi.mocked(fetchHelpers.refreshAndUpdateToken).mockResolvedValueOnce({ + type: "oauth", + access: "refreshed-access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + multiAccount: 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" }), + }); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 0]); + }); + + 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, + }; + const accountTwo = { + index: 1, + accountId: "acc-2", + email: "user2@example.com", + refreshToken: "refresh-2", + access: "access-account-2", + expires: Date.now() + 60_000, + }; + const customManager = { + accounts: [accountOne, accountTwo], + getCurrentOrNextForFamilyHybrid: () => accountOne, + getAccountCount: () => 2, + getAccountsSnapshot: () => [accountOne, accountTwo], + isAccountAvailableForFamily: () => true, + getAccountByIndex: (index: number) => + index === 0 ? accountOne : index === 1 ? accountTwo : null, + toAuthDetails: (account: typeof accountOne) => ({ + 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, + }; + 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]); + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); + it("skips automatic CLI injection when direct injection is disabled", async () => { const configModule = await import("../lib/config.js"); vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValueOnce(false); @@ -2498,6 +2620,10 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { }); expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(syncCodexCliSelectionMock).toHaveBeenCalledWith(1); + expect(loadFromDiskSpy.mock.invocationCallOrder[0]).toBeLessThan( + syncCodexCliSelectionMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); }); it("handles openai.account.select with openai provider", async () => { diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index ca1b7866..9665e0ab 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); }); From f3a8145074f69508d9715585abe56221b61cd47d Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 22:30:35 +0800 Subject: [PATCH 04/17] fix: harden live cli sync follow-ups --- index.ts | 100 ++++++---- lib/codex-manager/settings-hub.ts | 16 +- test/index.test.ts | 291 ++++++++++++++++++++++++++++++ 3 files changed, 365 insertions(+), 42 deletions(-) diff --git a/index.ts b/index.ts index 9fdccd51..335474ec 100644 --- a/index.ts +++ b/index.ts @@ -927,6 +927,22 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; + const queueCodexCliSelectionSync = async ( + runSync: () => Promise, + ): Promise => { + const priorSync = codexCliSelectionSyncQueue; + let releaseSyncQueue!: () => void; + codexCliSelectionSyncQueue = new Promise((resolve) => { + releaseSyncQueue = resolve; + }); + await priorSync.catch(() => undefined); + try { + return await runSync(); + } finally { + releaseSyncQueue(); + } + }; + // Event handler for session recovery and account selection const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { try { @@ -964,8 +980,28 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { await saveAccounts(storage); if (cachedAccountManager) { - const freshManager = await reloadAccountManagerFromDisk(); - const synced = await freshManager.syncCodexCliActiveSelectionForIndex(index); + const synced = await queueCodexCliSelectionSync(async () => { + const freshManager = await reloadAccountManagerFromDisk(); + try { + return await freshManager.syncCodexCliActiveSelectionForIndex(index); + } catch (error) { + logWarn( + `[${PLUGIN_NAME}] Codex CLI selection sync failed for manual account selection`, + { + 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) { lastCodexCliActiveSyncIndex = index; lastCodexCliActiveSyncSignature = null; @@ -1159,30 +1195,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const buildCodexCliSelectionSignature = ( account: { index: number; - accountId?: string; - email?: string; - expires?: number; - expiresAt?: number; + addedAt?: number; }, ): string => - [ - account.index, - account.accountId?.trim() ?? "", - account.email?.trim().toLowerCase() ?? "", - typeof account.expires === "number" - ? account.expires - : typeof account.expiresAt === "number" - ? account.expiresAt - : "", - ].join("|"); + [account.index, String(account.addedAt ?? "")].join("|"); const syncCodexCliSelectionNow = async ( account: { index: number; - accountId?: string; - email?: string; - expires?: number; - expiresAt?: number; + addedAt?: number; }, options?: { force?: boolean }, ): Promise => { @@ -1198,26 +1219,37 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ) { return true; } - const synced = await accountManager.syncCodexCliActiveSelectionForIndex( - account.index, - ); + let synced = false; + try { + synced = + await accountManager.syncCodexCliActiveSelectionForIndex( + account.index, + ); + } catch (error) { + logWarn( + `[${PLUGIN_NAME}] Codex CLI selection sync failed`, + { + accountIndex: account.index, + signature, + 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) { lastCodexCliActiveSyncIndex = account.index; lastCodexCliActiveSyncSignature = signature; } return synced; }; - const priorSync = codexCliSelectionSyncQueue; - let releaseSyncQueue!: () => void; - codexCliSelectionSyncQueue = new Promise((resolve) => { - releaseSyncQueue = resolve; - }); - await priorSync.catch(() => undefined); - try { - return await runSync(); - } finally { - releaseSyncQueue(); - } + return await queueCodexCliSelectionSync(runSync); }; diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 5e7da9b3..363fec7b 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -2553,22 +2553,22 @@ async function promptExperimentalSettings( color: "green", }, { - label: `${formatDashboardSettingState(draft.codexCliDirectInjection ?? true)} ${UI_COPY.settings.experimentalDirectCliInjection}`, + label: `${formatDashboardSettingState(draft.codexCliDirectInjection === true)} ${UI_COPY.settings.experimentalDirectCliInjection}`, value: { type: "toggle-direct-cli-injection" }, color: "green", }, { - label: `${formatDashboardSettingState(draft.sessionAffinity ?? true)} ${UI_COPY.settings.experimentalManualSessionLock}`, + label: `${formatDashboardSettingState(draft.sessionAffinity === true)} ${UI_COPY.settings.experimentalManualSessionLock}`, value: { type: "toggle-session-affinity" }, color: "yellow", }, { - label: `${formatDashboardSettingState(draft.retryAllAccountsRateLimited ?? true)} ${UI_COPY.settings.experimentalPoolFallback}`, + label: `${formatDashboardSettingState(draft.retryAllAccountsRateLimited === true)} ${UI_COPY.settings.experimentalPoolFallback}`, value: { type: "toggle-pool-retry" }, color: "green", }, { - label: `${formatDashboardSettingState(draft.preemptiveQuotaEnabled ?? true)} ${UI_COPY.settings.experimentalQuotaRotation}`, + label: `${formatDashboardSettingState(draft.preemptiveQuotaEnabled === true)} ${UI_COPY.settings.experimentalQuotaRotation}`, value: { type: "toggle-preemptive-quota" }, color: "yellow", }, @@ -2641,28 +2641,28 @@ async function promptExperimentalSettings( if (action.type === "toggle-direct-cli-injection") { draft = { ...draft, - codexCliDirectInjection: !(draft.codexCliDirectInjection ?? true), + codexCliDirectInjection: !draft.codexCliDirectInjection, }; continue; } if (action.type === "toggle-session-affinity") { draft = { ...draft, - sessionAffinity: !(draft.sessionAffinity ?? true), + sessionAffinity: !draft.sessionAffinity, }; continue; } if (action.type === "toggle-preemptive-quota") { draft = { ...draft, - preemptiveQuotaEnabled: !(draft.preemptiveQuotaEnabled ?? true), + preemptiveQuotaEnabled: !draft.preemptiveQuotaEnabled, }; continue; } if (action.type === "toggle-pool-retry") { draft = { ...draft, - retryAllAccountsRateLimited: !(draft.retryAllAccountsRateLimited ?? true), + retryAllAccountsRateLimited: !draft.retryAllAccountsRateLimited, }; continue; } diff --git a/test/index.test.ts b/test/index.test.ts index 2b0e4a90..689440d8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -333,6 +333,7 @@ vi.mock("../lib/accounts.js", () => { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1", + addedAt: 1, }, ]; @@ -1149,12 +1150,14 @@ describe("OpenAIOAuthPlugin fetch handler", () => { accountId: "acc-1", email: "user1@example.com", refreshToken: "refresh-1", + addedAt: 1, }; const accountTwo = { index: 1, accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2", + addedAt: 2, }; vi.spyOn(AccountManager.prototype, "getAccountCount").mockReturnValue(2); vi.spyOn(AccountManager.prototype, "getCurrentOrNextForFamilyHybrid") @@ -1216,6 +1219,53 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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; + const eventPromise = plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + await vi.waitFor(() => expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1)); + resolveSync?.(); + + const response = await requestPromise; + await eventPromise; + + expect(response.status).toBe(200); + expect( + syncCodexCliSelectionMock.mock.calls + .map((call) => call[0]) + .slice(0, 2), + ).toEqual([0, 1]); + }); + 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) @@ -1247,6 +1297,29 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 0]); }); + it("retries CLI injection on the next request after a failed write", async () => { + syncCodexCliSelectionMock + .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" }), + }); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 0]); + }); + it("forces CLI reinjection when stream failover switches to another account", async () => { vi.doMock("../lib/request/stream-failover.js", () => ({ withStreamingFailover: async ( @@ -1269,6 +1342,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { refreshToken: "refresh-1", access: "access-account-1", expires: Date.now() + 60_000, + addedAt: 1, }; const accountTwo = { index: 1, @@ -1277,6 +1351,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { refreshToken: "refresh-2", access: "access-account-2", expires: Date.now() + 60_000, + addedAt: 2, }; const customManager = { accounts: [accountOne, accountTwo], @@ -1335,6 +1410,222 @@ describe("OpenAIOAuthPlugin fetch handler", () => { } }); + 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 = { + accounts: [accountOne, accountTwo], + getCurrentOrNextForFamilyHybrid: currentAccount, + getAccountCount: () => 2, + getAccountsSnapshot: () => [accountOne, accountTwo], + isAccountAvailableForFamily: () => true, + getAccountByIndex: (index: number) => + index === 0 ? accountOne : index === 1 ? accountTwo : null, + toAuthDetails: (account: typeof accountOne) => ({ + 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, + }; + 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]); + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); + + 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 = { + accounts: [accountOne, accountTwo], + getCurrentOrNextForFamilyHybrid: vi + .fn() + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null), + getAccountCount: () => 2, + getAccountsSnapshot: () => [accountOne, accountTwo], + isAccountAvailableForFamily: () => true, + getAccountByIndex: (index: number) => + index === 0 ? accountOne : index === 1 ? accountTwo : null, + toAuthDetails: (account: typeof accountOne) => ({ + 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, + }; + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + syncCodexCliSelectionMock.mockReset(); + 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("skips automatic CLI injection when direct injection is disabled", async () => { const configModule = await import("../lib/config.js"); vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValueOnce(false); From d27d90d0dd0f3f3b306a1997913c3cce8aaf8a86 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 22:51:13 +0800 Subject: [PATCH 05/17] fix: bound queued cli sync follow-ups --- index.ts | 24 +++++- lib/codex-manager/settings-hub.ts | 2 +- test/index.test.ts | 128 ++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index 335474ec..d9fa460d 100644 --- a/index.ts +++ b/index.ts @@ -927,6 +927,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); }; + const CODEX_CLI_SELECTION_SYNC_TIMEOUT_MS = 5_000; + const queueCodexCliSelectionSync = async ( runSync: () => Promise, ): Promise => { @@ -937,7 +939,27 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); await priorSync.catch(() => undefined); try { - return await runSync(); + let timeoutHandle: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + runSync(), + new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + 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(); } diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 363fec7b..675e1e3d 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1139,7 +1139,7 @@ function buildBackendSettingsPreview( const threshold5h = config.preemptiveQuotaRemainingPercent5h ?? BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent5h ?? - 5; + 10; const threshold7d = config.preemptiveQuotaRemainingPercent7d ?? BACKEND_DEFAULTS.preemptiveQuotaRemainingPercent7d ?? diff --git a/test/index.test.ts b/test/index.test.ts index 689440d8..8bb0d8de 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1266,6 +1266,38 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).toEqual([0, 1]); }); + it("times out a stalled CLI sync so later requests can continue", async () => { + vi.useFakeTimers(); + try { + 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); + } 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) @@ -1643,6 +1675,102 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); }); + 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).mockReturnValueOnce(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: "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 = { + accounts: [accountOne, accountTwo], + getCurrentOrNextForFamilyHybrid: vi + .fn() + .mockReturnValueOnce(accountOne) + .mockReturnValueOnce(accountTwo) + .mockImplementation(() => null), + getAccountCount: () => 2, + getAccountsSnapshot: () => [accountOne, accountTwo], + isAccountAvailableForFamily: () => true, + getAccountByIndex: (index: number) => + index === 0 ? accountOne : index === 1 ? accountTwo : null, + toAuthDetails: (account: typeof accountOne) => ({ + 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, + }; + 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).not.toHaveBeenCalled(); + } finally { + vi.doUnmock("../lib/request/stream-failover.js"); + vi.resetModules(); + } + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { mockStorage.accounts = [ { From eab19c0cdf402daf637ccdef5ab745214ed0837f Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 23:05:01 +0800 Subject: [PATCH 06/17] fix: resolve remaining cli injection review gaps --- index.ts | 2 ++ lib/codex-manager/settings-hub.ts | 4 ++-- test/index.test.ts | 1 + test/settings-hub-utils.test.ts | 10 ++++++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index d9fa460d..96714a9c 100644 --- a/index.ts +++ b/index.ts @@ -945,6 +945,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { runSync(), new Promise((resolve) => { timeoutHandle = setTimeout(() => { + // 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`, { diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 675e1e3d..1ba6612f 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -2553,7 +2553,7 @@ async function promptExperimentalSettings( color: "green", }, { - label: `${formatDashboardSettingState(draft.codexCliDirectInjection === true)} ${UI_COPY.settings.experimentalDirectCliInjection}`, + label: `${formatDashboardSettingState(draft.codexCliDirectInjection !== false)} ${UI_COPY.settings.experimentalDirectCliInjection}`, value: { type: "toggle-direct-cli-injection" }, color: "green", }, @@ -2641,7 +2641,7 @@ async function promptExperimentalSettings( if (action.type === "toggle-direct-cli-injection") { draft = { ...draft, - codexCliDirectInjection: !draft.codexCliDirectInjection, + codexCliDirectInjection: !(draft.codexCliDirectInjection !== false), }; continue; } diff --git a/test/index.test.ts b/test/index.test.ts index 8bb0d8de..557d84a8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1216,6 +1216,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expect(firstResponse.status).toBe(200); expect(secondResponse.status).toBe(200); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); }); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 9665e0ab..0a2a9736 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -724,5 +724,15 @@ describe("settings-hub utility coverage", () => { }), ); }); + + 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); + }); }); }); From 7ca839501a937f5bb12d2d0256f6caa3e7993bfd Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 23:07:23 +0800 Subject: [PATCH 07/17] fix: tighten remaining review follow-ups --- lib/codex-manager/settings-hub.ts | 5 ++++- test/index.test.ts | 2 +- test/settings-hub-utils.test.ts | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 1ba6612f..c781366e 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -2464,7 +2464,10 @@ 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, + }); for (const category of BACKEND_CATEGORY_OPTIONS) { focusByCategory[category.key] = getBackendCategoryInitialFocus(category); diff --git a/test/index.test.ts b/test/index.test.ts index 557d84a8..f8ed8a43 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1624,7 +1624,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { getCurrentAccountForFamily: () => accountOne, }; vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); - syncCodexCliSelectionMock.mockReset(); + syncCodexCliSelectionMock.mockClear(); syncCodexCliSelectionMock .mockResolvedValueOnce(true) .mockRejectedValueOnce(Object.assign(new Error(`${code} fail`), { code })); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 0a2a9736..68ef59cb 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -734,5 +734,25 @@ describe("settings-hub utility coverage", () => { const selected = await api.promptExperimentalSettings({}); expect(selected?.codexCliDirectInjection).toBe(false); }); + + it("preserves direct injection when backend settings reset is used", async () => { + const api = await loadSettingsHubTestApi(); + queueSelectResults( + { type: "reset" }, + { type: "save" }, + ); + const selected = await api.promptBackendSettings({ + codexCliDirectInjection: false, + sessionAffinity: false, + preemptiveQuotaEnabled: false, + }); + expect(selected).toEqual( + expect.objectContaining({ + codexCliDirectInjection: false, + sessionAffinity: true, + preemptiveQuotaEnabled: true, + }), + ); + }); }); }); From 167ff0216fd461b3f05e3a520a0fcee01494563e Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 23:31:29 +0800 Subject: [PATCH 08/17] fix: address latest review follow-ups --- index.ts | 25 +++- test/index.test.ts | 228 ++++++++++++++++++++++++++++++++ test/settings-hub-utils.test.ts | 32 +++++ 3 files changed, 280 insertions(+), 5 deletions(-) diff --git a/index.ts b/index.ts index 96714a9c..98908883 100644 --- a/index.ts +++ b/index.ts @@ -930,10 +930,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const CODEX_CLI_SELECTION_SYNC_TIMEOUT_MS = 5_000; const queueCodexCliSelectionSync = async ( - runSync: () => Promise, + runSync: (context: { isStale: () => boolean }) => Promise, ): Promise => { const priorSync = codexCliSelectionSyncQueue; let releaseSyncQueue!: () => void; + let syncTimedOut = false; codexCliSelectionSyncQueue = new Promise((resolve) => { releaseSyncQueue = resolve; }); @@ -942,9 +943,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let timeoutHandle: NodeJS.Timeout | null = null; try { return await Promise.race([ - runSync(), + runSync({ + isStale: () => syncTimedOut, + }), 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( @@ -1003,7 +1007,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } await saveAccounts(storage); - if (cachedAccountManager) { + const codexCliDirectInjectionEnabledForEvent = + getCodexCliDirectInjection(loadPluginConfig()); + if (cachedAccountManager && codexCliDirectInjectionEnabledForEvent) { const synced = await queueCodexCliSelectionSync(async () => { const freshManager = await reloadAccountManagerFromDisk(); try { @@ -1235,7 +1241,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return false; } const signature = buildCodexCliSelectionSignature(account); - const runSync = async (): Promise => { + const runSync = async (context: { + isStale: () => boolean; + }): Promise => { if ( !options?.force && lastCodexCliActiveSyncIndex === account.index && @@ -1267,7 +1275,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); return false; } - if (synced) { + if (synced && !context.isStale()) { lastCodexCliActiveSyncIndex = account.index; lastCodexCliActiveSyncSignature = signature; } @@ -2206,12 +2214,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(); @@ -2323,6 +2333,11 @@ while (attempted.size < Math.max(1, accountCount)) { sessionAffinityKey, fallbackAccount.index, ); + } + if ( + fallbackAccount.index !== account.index || + fallbackAuthRefreshed + ) { await syncCodexCliSelectionNow(fallbackAccount, { force: true, }); diff --git a/test/index.test.ts b/test/index.test.ts index f8ed8a43..55940ccc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1210,6 +1210,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { await syncStarted; expect(syncCodexCliSelectionMock).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).not.toHaveBeenCalled(); resolveSync?.(); const [firstResponse, secondResponse] = await Promise.all([firstFetch, secondFetch]); @@ -1299,9 +1300,120 @@ describe("OpenAIOAuthPlugin fetch handler", () => { } }); + 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 = { + accounts: [accountOne, accountTwo], + getCurrentOrNextForFamilyHybrid: currentAccount, + getAccountCount: () => 2, + getAccountsSnapshot: () => [accountOne, accountTwo], + isAccountAvailableForFamily: () => true, + getAccountByIndex: (index: number) => + index === 0 ? accountOne : index === 1 ? accountTwo : null, + toAuthDetails: (account: typeof accountOne) => ({ + 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, + }; + vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); + 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, + ]); + } 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({ @@ -1324,9 +1436,15 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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]); }); @@ -1550,6 +1668,116 @@ describe("OpenAIOAuthPlugin fetch handler", () => { } }); + 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 = { + accounts: [accountOne], + getCurrentOrNextForFamilyHybrid: () => accountOne, + getAccountCount: () => 1, + getAccountsSnapshot: () => [accountOne], + isAccountAvailableForFamily: () => true, + getAccountByIndex: (index: number) => (index === 0 ? accountOne : null), + toAuthDetails: () => ({ + type: "oauth" as const, + access: accountOne.access, + refresh: accountOne.refreshToken, + expires: accountOne.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, + }; + 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]); + } 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) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false); + + 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); + }); + 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 ( diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 68ef59cb..fc52c79e 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -754,5 +754,37 @@ describe("settings-hub utility coverage", () => { }), ); }); + + it("supports the experimental rotation quota o shortcut", async () => { + const api = await loadSettingsHubTestApi(); + let observedShortcutResult: 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) { + return { type: "back" }; + } + return { type: "save" }; + }; + + const selected = await api.promptExperimentalSettings({ + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 10, + }); + + expect(observedShortcutResult).toEqual({ type: "open-rotation-quota" }); + expect(selected).toEqual( + expect.objectContaining({ + preemptiveQuotaEnabled: true, + preemptiveQuotaRemainingPercent5h: 10, + }), + ); + }); }); }); From 3d4a63481ddccb55cbdaa15782fdb44cc4425501 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 23:35:20 +0800 Subject: [PATCH 09/17] test(sync): prove manual selection waits for queued injection --- test/index.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/index.test.ts b/test/index.test.ts index 55940ccc..3199acfa 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1250,11 +1250,19 @@ describe("OpenAIOAuthPlugin fetch handler", () => { }); await syncStarted; - const eventPromise = plugin.event({ - event: { type: "account.select", properties: { index: 1 } }, - }); + 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; From 7497e08627778f450ed41036c73c295340cf7d3e Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 19 Mar 2026 23:53:03 +0800 Subject: [PATCH 10/17] fix: preserve experimental toggles on backend reset --- lib/codex-manager/settings-hub.ts | 3 +++ test/settings-hub-utils.test.ts | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index c781366e..6e68fc7a 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -2467,6 +2467,9 @@ async function promptBackendSettings( draft = cloneBackendPluginConfig({ ...BACKEND_DEFAULTS, codexCliDirectInjection: draft.codexCliDirectInjection, + sessionAffinity: draft.sessionAffinity, + retryAllAccountsRateLimited: draft.retryAllAccountsRateLimited, + preemptiveQuotaEnabled: draft.preemptiveQuotaEnabled, }); for (const category of BACKEND_CATEGORY_OPTIONS) { focusByCategory[category.key] = diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index fc52c79e..bc251b07 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -735,7 +735,7 @@ describe("settings-hub utility coverage", () => { expect(selected?.codexCliDirectInjection).toBe(false); }); - it("preserves direct injection when backend settings reset is used", async () => { + it("preserves experimental toggles when backend settings reset is used", async () => { const api = await loadSettingsHubTestApi(); queueSelectResults( { type: "reset" }, @@ -744,13 +744,15 @@ describe("settings-hub utility coverage", () => { const selected = await api.promptBackendSettings({ codexCliDirectInjection: false, sessionAffinity: false, + retryAllAccountsRateLimited: false, preemptiveQuotaEnabled: false, }); expect(selected).toEqual( expect.objectContaining({ codexCliDirectInjection: false, - sessionAffinity: true, - preemptiveQuotaEnabled: true, + sessionAffinity: false, + retryAllAccountsRateLimited: false, + preemptiveQuotaEnabled: false, }), ); }); From 3b531732adf253c57c0bf7ff6e6ee89f95cc5993 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 00:06:32 +0800 Subject: [PATCH 11/17] fix: reload manager after manual account selection --- index.ts | 13 ++++++++--- test/index.test.ts | 54 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index 98908883..7c7f6aca 100644 --- a/index.ts +++ b/index.ts @@ -1007,13 +1007,20 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } await saveAccounts(storage); + if (cachedAccountManager) { + // Reload manager from disk so we don't overwrite newer rotated + // refresh tokens with stale in-memory state. + await reloadAccountManagerFromDisk(); + } + const activeAccountManager = cachedAccountManager; const codexCliDirectInjectionEnabledForEvent = getCodexCliDirectInjection(loadPluginConfig()); - if (cachedAccountManager && codexCliDirectInjectionEnabledForEvent) { + if (activeAccountManager && codexCliDirectInjectionEnabledForEvent) { const synced = await queueCodexCliSelectionSync(async () => { - const freshManager = await reloadAccountManagerFromDisk(); try { - return await freshManager.syncCodexCliActiveSelectionForIndex(index); + return await activeAccountManager.syncCodexCliActiveSelectionForIndex( + index, + ); } catch (error) { logWarn( `[${PLUGIN_NAME}] Codex CLI selection sync failed for manual account selection`, diff --git a/test/index.test.ts b/test/index.test.ts index 3199acfa..c8919e21 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1771,19 +1771,20 @@ describe("OpenAIOAuthPlugin fetch handler", () => { { accountId: "acc-2", email: "user2@example.com", refreshToken: "refresh-2" }, ]; const configModule = await import("../lib/config.js"); - vi.mocked(configModule.getCodexCliDirectInjection) - .mockReturnValueOnce(false) - .mockReturnValueOnce(false); - - const { plugin } = await setupPlugin(); - syncCodexCliSelectionMock.mockClear(); - await plugin.event({ - event: { type: "account.select", properties: { index: 1 } }, - }); + 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); + 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 () => { @@ -3282,6 +3283,35 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { ); }); + 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); + + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); + + expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + }); + it("handles openai.account.select with openai provider", async () => { const mockClient = createMockClient(); const { OpenAIOAuthPlugin } = await import("../index.js"); From 3ca4818ca0143445d1825440badabb07be7e86ad Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 00:37:29 +0800 Subject: [PATCH 12/17] fix(sync): guard manual CLI selection against stale request writes --- index.ts | 60 +++++++++++++++++++++++++----------- test/index.test.ts | 76 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/index.ts b/index.ts index 7c7f6aca..60286a00 100644 --- a/index.ts +++ b/index.ts @@ -928,9 +928,15 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }; const CODEX_CLI_SELECTION_SYNC_TIMEOUT_MS = 5_000; - - const queueCodexCliSelectionSync = async ( - runSync: (context: { isStale: () => boolean }) => Promise, + let codexCliSelectionGeneration = 0; + + const queueCodexCliSelectionSync = async ({ + generation, + runSync, + }: { + generation: number; + runSync: (context: { isStale: () => boolean }) => Promise; + }, ): Promise => { const priorSync = codexCliSelectionSyncQueue; let releaseSyncQueue!: () => void; @@ -944,7 +950,8 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { try { return await Promise.race([ runSync({ - isStale: () => syncTimedOut, + isStale: () => + syncTimedOut || codexCliSelectionGeneration !== generation, }), new Promise((resolve) => { timeoutHandle = setTimeout(() => { @@ -1006,17 +1013,21 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { storage.activeIndexByFamily[family] = index; } - await saveAccounts(storage); - if (cachedAccountManager) { - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state. - await reloadAccountManagerFromDisk(); - } - const activeAccountManager = cachedAccountManager; const codexCliDirectInjectionEnabledForEvent = getCodexCliDirectInjection(loadPluginConfig()); + await saveAccounts(storage); + const manualSelectionGeneration = ++codexCliSelectionGeneration; + let activeAccountManager = cachedAccountManager; + if (cachedAccountManager || codexCliDirectInjectionEnabledForEvent) { + // Reload manager from disk so we don't overwrite newer rotated + // refresh tokens with stale in-memory state, and so the first + // manual switch can still inject into Codex CLI. + activeAccountManager = await reloadAccountManagerFromDisk(); + } if (activeAccountManager && codexCliDirectInjectionEnabledForEvent) { - const synced = await queueCodexCliSelectionSync(async () => { + const synced = await queueCodexCliSelectionSync({ + generation: manualSelectionGeneration, + runSync: async () => { try { return await activeAccountManager.syncCodexCliActiveSelectionForIndex( index, @@ -1038,8 +1049,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); return false; } + }, }); - if (synced) { + if (synced && codexCliSelectionGeneration === manualSelectionGeneration) { lastCodexCliActiveSyncIndex = index; lastCodexCliActiveSyncSignature = null; } @@ -1242,15 +1254,20 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { index: number; addedAt?: number; }, - options?: { force?: boolean }, + options?: { force?: boolean; generation?: number }, ): Promise => { if (!codexCliDirectInjectionEnabled) { return false; } const signature = buildCodexCliSelectionSignature(account); + const selectionGenerationAtQueueTime = + options?.generation ?? codexCliSelectionGeneration; const runSync = async (context: { isStale: () => boolean; }): Promise => { + if (context.isStale()) { + return false; + } if ( !options?.force && lastCodexCliActiveSyncIndex === account.index && @@ -1258,10 +1275,11 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ) { return true; } + const activeAccountManager = cachedAccountManager ?? accountManager; let synced = false; try { synced = - await accountManager.syncCodexCliActiveSelectionForIndex( + await activeAccountManager.syncCodexCliActiveSelectionForIndex( account.index, ); } catch (error) { @@ -1288,7 +1306,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } return synced; }; - return await queueCodexCliSelectionSync(runSync); + return await queueCodexCliSelectionSync({ + generation: selectionGenerationAtQueueTime, + runSync, + }); }; @@ -1430,6 +1451,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; @@ -1733,6 +1755,7 @@ while (attempted.size < Math.max(1, accountCount)) { } await syncCodexCliSelectionNow(account, { force: accountAuthRefreshed, + generation: cliSelectionGenerationForRequest, }); let sameAccountRetryCount = 0; @@ -2347,6 +2370,7 @@ while (attempted.size < Math.max(1, accountCount)) { ) { await syncCodexCliSelectionNow(fallbackAccount, { force: true, + generation: cliSelectionGenerationForRequest, }); } @@ -2465,7 +2489,9 @@ while (attempted.size < Math.max(1, accountCount)) { ); runtimeMetrics.successfulRequests++; runtimeMetrics.lastError = null; - await syncCodexCliSelectionNow(successAccountForResponse); + await syncCodexCliSelectionNow(successAccountForResponse, { + generation: cliSelectionGenerationForRequest, + }); return successResponse; } if (retryNextAccountBeforeFallback) { diff --git a/test/index.test.ts b/test/index.test.ts index c8919e21..af5817a7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1276,6 +1276,41 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).toEqual([0, 1]); }); + 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("times out a stalled CLI sync so later requests can continue", async () => { vi.useFakeTimers(); try { @@ -1898,19 +1933,23 @@ describe("OpenAIOAuthPlugin fetch handler", () => { it("skips automatic CLI injection when direct injection is disabled", async () => { const configModule = await import("../lib/config.js"); - vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValueOnce(false); - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ content: "test" }), { status: 200 }), - ); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); + try { + 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).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + } finally { + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + } }); it("skips failover CLI injection when direct injection is disabled", async () => { @@ -1923,7 +1962,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { try { vi.resetModules(); const configModule = await import("../lib/config.js"); - vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValueOnce(false); + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); const fetchHelpers = await import("../lib/request/fetch-helpers.js"); vi.mocked(fetchHelpers.createCodexHeaders).mockImplementation( (_init, _accountId, accessToken) => @@ -2004,6 +2043,8 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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(); } @@ -3335,14 +3376,23 @@ 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; + 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 } }, }); + + expect(mockStorage.activeIndex).toBe(0); + expect(mockStorage.activeIndexByFamily["gpt-5.1"]).toBe(0); }); it("handles non-numeric index gracefully", async () => { From 6a613c1e1627a8de730dc8256f98538cf50fece3 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 00:42:45 +0800 Subject: [PATCH 13/17] fix: preserve hidden reset settings --- lib/codex-manager/settings-hub.ts | 8 +++++--- test/index.test.ts | 15 +++++++++------ test/settings-hub-utils.test.ts | 4 ++++ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 6e68fc7a..4ea65a18 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -1076,10 +1076,10 @@ function backendSettingsSnapshot( config: PluginConfig, ): Record { const snapshot: Record = {}; + const directInjectionDefault = + BACKEND_DEFAULTS.codexCliDirectInjection ?? true; snapshot.codexCliDirectInjection = - config.codexCliDirectInjection ?? - BACKEND_DEFAULTS.codexCliDirectInjection ?? - true; + config.codexCliDirectInjection ?? directInjectionDefault; for (const option of BACKEND_TOGGLE_OPTIONS) { snapshot[option.key] = config[option.key] ?? BACKEND_DEFAULTS[option.key] ?? false; @@ -2467,6 +2467,8 @@ async function promptBackendSettings( draft = cloneBackendPluginConfig({ ...BACKEND_DEFAULTS, codexCliDirectInjection: draft.codexCliDirectInjection, + proactiveRefreshGuardian: draft.proactiveRefreshGuardian, + proactiveRefreshIntervalMs: draft.proactiveRefreshIntervalMs, sessionAffinity: draft.sessionAffinity, retryAllAccountsRateLimited: draft.retryAllAccountsRateLimited, preemptiveQuotaEnabled: draft.preemptiveQuotaEnabled, diff --git a/test/index.test.ts b/test/index.test.ts index af5817a7..76e87313 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -3344,13 +3344,16 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { loadFromDiskSpy.mockClear(); syncCodexCliSelectionMock.mockClear(); vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); + try { + await plugin.event({ + event: { type: "account.select", properties: { index: 1 } }, + }); - await plugin.event({ - event: { type: "account.select", properties: { index: 1 } }, - }); - - expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); - expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(syncCodexCliSelectionMock).not.toHaveBeenCalled(); + } finally { + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + } }); it("handles openai.account.select with openai provider", async () => { diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index bc251b07..829f8c44 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -743,6 +743,8 @@ describe("settings-hub utility coverage", () => { ); const selected = await api.promptBackendSettings({ codexCliDirectInjection: false, + proactiveRefreshGuardian: false, + proactiveRefreshIntervalMs: 120_000, sessionAffinity: false, retryAllAccountsRateLimited: false, preemptiveQuotaEnabled: false, @@ -750,6 +752,8 @@ describe("settings-hub utility coverage", () => { expect(selected).toEqual( expect.objectContaining({ codexCliDirectInjection: false, + proactiveRefreshGuardian: false, + proactiveRefreshIntervalMs: 120_000, sessionAffinity: false, retryAllAccountsRateLimited: false, preemptiveQuotaEnabled: false, From 789413015361c1091c8e5cb4e0588739b9c43673 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 02:16:17 +0800 Subject: [PATCH 14/17] fix: close cli rotation review gaps --- index.ts | 16 +- lib/codex-manager/settings-hub.ts | 15 +- test/accounts-load-from-disk.test.ts | 5 +- test/index.test.ts | 323 ++++++++++----------------- test/plugin-config.test.ts | 5 + test/settings-hub-utils.test.ts | 34 ++- 6 files changed, 185 insertions(+), 213 deletions(-) diff --git a/index.ts b/index.ts index 60286a00..28cbd309 100644 --- a/index.ts +++ b/index.ts @@ -1017,17 +1017,17 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { getCodexCliDirectInjection(loadPluginConfig()); await saveAccounts(storage); const manualSelectionGeneration = ++codexCliSelectionGeneration; - let activeAccountManager = cachedAccountManager; - if (cachedAccountManager || codexCliDirectInjectionEnabledForEvent) { - // Reload manager from disk so we don't overwrite newer rotated - // refresh tokens with stale in-memory state, and so the first - // manual switch can still inject into Codex CLI. - activeAccountManager = await reloadAccountManagerFromDisk(); - } + // Reload immediately after the persisted selection change so the cached + // manager cannot keep stale active-index state, even on the first manual + // selection before any request path has populated the cache. + const activeAccountManager = await reloadAccountManagerFromDisk(); if (activeAccountManager && codexCliDirectInjectionEnabledForEvent) { const synced = await queueCodexCliSelectionSync({ generation: manualSelectionGeneration, - runSync: async () => { + runSync: async ({ isStale }) => { + if (isStale()) { + return false; + } try { return await activeAccountManager.syncCodexCliActiveSelectionForIndex( index, diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 4ea65a18..4f17a6f2 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -2472,6 +2472,15 @@ async function promptBackendSettings( 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] = @@ -2566,17 +2575,17 @@ async function promptExperimentalSettings( color: "green", }, { - label: `${formatDashboardSettingState(draft.sessionAffinity === true)} ${UI_COPY.settings.experimentalManualSessionLock}`, + label: `${formatDashboardSettingState(draft.sessionAffinity !== false)} ${UI_COPY.settings.experimentalManualSessionLock}`, value: { type: "toggle-session-affinity" }, color: "yellow", }, { - label: `${formatDashboardSettingState(draft.retryAllAccountsRateLimited === true)} ${UI_COPY.settings.experimentalPoolFallback}`, + label: `${formatDashboardSettingState(draft.retryAllAccountsRateLimited !== false)} ${UI_COPY.settings.experimentalPoolFallback}`, value: { type: "toggle-pool-retry" }, color: "green", }, { - label: `${formatDashboardSettingState(draft.preemptiveQuotaEnabled === true)} ${UI_COPY.settings.experimentalQuotaRotation}`, + label: `${formatDashboardSettingState(draft.preemptiveQuotaEnabled !== false)} ${UI_COPY.settings.experimentalQuotaRotation}`, value: { type: "toggle-preemptive-quota" }, color: "yellow", }, diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts index 9d8745a8..4bb10852 100644 --- a/test/accounts-load-from-disk.test.ts +++ b/test/accounts-load-from-disk.test.ts @@ -177,8 +177,9 @@ describe("AccountManager loadFromDisk", () => { ], }); - 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); expect(setCodexCliActiveSelection).not.toHaveBeenCalled(); await expect(manager.syncCodexCliActiveSelectionForIndex(0)).resolves.toBe(true); diff --git a/test/index.test.ts b/test/index.test.ts index 76e87313..ddeb55db 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -295,6 +295,58 @@ const withAccountStorageTransactionMock = vi.fn( 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, + }; +} + vi.mock("../lib/storage.js", async () => { const actual = await vi.importActual("../lib/storage.js"); return { @@ -1276,6 +1328,54 @@ describe("OpenAIOAuthPlugin fetch handler", () => { ).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" }, @@ -1372,40 +1472,9 @@ describe("OpenAIOAuthPlugin fetch handler", () => { .mockReturnValueOnce(accountTwo) .mockReturnValueOnce(accountTwo) .mockImplementation(() => null); - const customManager = { - accounts: [accountOne, accountTwo], + const customManager = buildCustomManager(accountOne, accountTwo, { getCurrentOrNextForFamilyHybrid: currentAccount, - getAccountCount: () => 2, - getAccountsSnapshot: () => [accountOne, accountTwo], - isAccountAvailableForFamily: () => true, - getAccountByIndex: (index: number) => - index === 0 ? accountOne : index === 1 ? accountTwo : null, - toAuthDetails: (account: typeof accountOne) => ({ - 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, - }; + }); vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); syncCodexCliSelectionMock .mockImplementationOnce( @@ -1547,40 +1616,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expires: Date.now() + 60_000, addedAt: 2, }; - const customManager = { - accounts: [accountOne, accountTwo], - getCurrentOrNextForFamilyHybrid: () => accountOne, - getAccountCount: () => 2, - getAccountsSnapshot: () => [accountOne, accountTwo], - isAccountAvailableForFamily: () => true, - getAccountByIndex: (index: number) => - index === 0 ? accountOne : index === 1 ? accountTwo : null, - toAuthDetails: (account: typeof accountOne) => ({ - 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, - }; + const customManager = buildCustomManager(accountOne, accountTwo); vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); globalThis.fetch = vi .fn() @@ -1642,40 +1678,9 @@ describe("OpenAIOAuthPlugin fetch handler", () => { .mockReturnValueOnce(accountTwo) .mockReturnValueOnce(accountOne) .mockImplementation(() => null); - const customManager = { - accounts: [accountOne, accountTwo], + const customManager = buildCustomManager(accountOne, accountTwo, { getCurrentOrNextForFamilyHybrid: currentAccount, - getAccountCount: () => 2, - getAccountsSnapshot: () => [accountOne, accountTwo], - isAccountAvailableForFamily: () => true, - getAccountByIndex: (index: number) => - index === 0 ? accountOne : index === 1 ? accountTwo : null, - toAuthDetails: (account: typeof accountOne) => ({ - 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, - }; + }); vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); syncCodexCliSelectionMock .mockResolvedValueOnce(true) @@ -1745,12 +1750,10 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expires: Date.now() + 60_000, addedAt: 1, }; - const customManager = { + const customManager = buildCustomManager(accountOne, accountOne, { accounts: [accountOne], - getCurrentOrNextForFamilyHybrid: () => accountOne, getAccountCount: () => 1, getAccountsSnapshot: () => [accountOne], - isAccountAvailableForFamily: () => true, getAccountByIndex: (index: number) => (index === 0 ? accountOne : null), toAuthDetails: () => ({ type: "oauth" as const, @@ -1759,25 +1762,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expires: accountOne.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, - }; + }); vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); globalThis.fetch = vi .fn() @@ -1857,44 +1842,13 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expires: Date.now() + 60_000, addedAt: 2, }; - const customManager = { - accounts: [accountOne, accountTwo], - getCurrentOrNextForFamilyHybrid: vi - .fn() - .mockReturnValueOnce(accountOne) - .mockReturnValueOnce(accountTwo) - .mockImplementation(() => null), - getAccountCount: () => 2, - getAccountsSnapshot: () => [accountOne, accountTwo], - isAccountAvailableForFamily: () => true, - getAccountByIndex: (index: number) => - index === 0 ? accountOne : index === 1 ? accountTwo : null, - toAuthDetails: (account: typeof accountOne) => ({ - 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, - }; + 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 @@ -1987,44 +1941,13 @@ describe("OpenAIOAuthPlugin fetch handler", () => { expires: Date.now() + 60_000, addedAt: 2, }; - const customManager = { - accounts: [accountOne, accountTwo], + const customManager = buildCustomManager(accountOne, accountTwo, { getCurrentOrNextForFamilyHybrid: vi .fn() .mockReturnValueOnce(accountOne) .mockReturnValueOnce(accountTwo) .mockImplementation(() => null), - getAccountCount: () => 2, - getAccountsSnapshot: () => [accountOne, accountTwo], - isAccountAvailableForFamily: () => true, - getAccountByIndex: (index: number) => - index === 0 ? accountOne : index === 1 ? accountTwo : null, - toAuthDetails: (account: typeof accountOne) => ({ - 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, - }; + }); vi.spyOn(AccountManager, "loadFromDisk").mockResolvedValue(customManager as never); globalThis.fetch = vi .fn() @@ -2535,8 +2458,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) { @@ -2585,7 +2507,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); @@ -3384,6 +3306,8 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { 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" }, @@ -3391,11 +3315,12 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { ]; await plugin.event({ - event: { type: "account.select", properties: { index: 0 } }, + event: { type: "account.select", properties: { index: 1 } }, }); - expect(mockStorage.activeIndex).toBe(0); - expect(mockStorage.activeIndexByFamily["gpt-5.1"]).toBe(0); + expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + 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 2e90560c..33bc79f5 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -822,6 +822,11 @@ describe('Plugin Configuration', () => { expect(getCodexCliDirectInjection({ codexCliDirectInjection: true })).toBe(false); 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 829f8c44..8ba26a04 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -735,6 +735,27 @@ describe("settings-hub utility coverage", () => { expect(selected?.codexCliDirectInjection).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( @@ -748,6 +769,9 @@ describe("settings-hub utility coverage", () => { sessionAffinity: false, retryAllAccountsRateLimited: false, preemptiveQuotaEnabled: false, + preemptiveQuotaRemainingPercent5h: 25, + preemptiveQuotaRemainingPercent7d: 15, + preemptiveQuotaMaxDeferralMs: 180_000, }); expect(selected).toEqual( expect.objectContaining({ @@ -757,13 +781,17 @@ describe("settings-hub utility coverage", () => { sessionAffinity: false, retryAllAccountsRateLimited: false, preemptiveQuotaEnabled: false, + preemptiveQuotaRemainingPercent5h: 25, + preemptiveQuotaRemainingPercent7d: 15, + preemptiveQuotaMaxDeferralMs: 180_000, }), ); }); - it("supports the experimental rotation quota o shortcut", async () => { + 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; @@ -774,6 +802,9 @@ describe("settings-hub utility coverage", () => { return observedShortcutResult; } if (callCount === 2) { + observedInnerShortcutResult = (options as { + onInput?: (raw: string) => unknown; + }).onInput?.("o"); return { type: "back" }; } return { type: "save" }; @@ -785,6 +816,7 @@ describe("settings-hub utility coverage", () => { }); expect(observedShortcutResult).toEqual({ type: "open-rotation-quota" }); + expect(observedInnerShortcutResult).toBeUndefined(); expect(selected).toEqual( expect.objectContaining({ preemptiveQuotaEnabled: true, From 26d338b1903255ae8f4da5c3fd527b3ec9b8f746 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 02:34:25 +0800 Subject: [PATCH 15/17] fix: address remaining review comments --- index.ts | 24 ++++++++++----------- lib/codex-manager/settings-hub.ts | 6 +++--- test/index.test.ts | 5 ++++- test/settings-hub-utils.test.ts | 36 +++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/index.ts b/index.ts index 28cbd309..6cfada14 100644 --- a/index.ts +++ b/index.ts @@ -1743,20 +1743,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; - } - await syncCodexCliSelectionNow(account, { - force: accountAuthRefreshed, - generation: cliSelectionGenerationForRequest, - }); + 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; diff --git a/lib/codex-manager/settings-hub.ts b/lib/codex-manager/settings-hub.ts index 4f17a6f2..0d743d36 100644 --- a/lib/codex-manager/settings-hub.ts +++ b/lib/codex-manager/settings-hub.ts @@ -2665,21 +2665,21 @@ async function promptExperimentalSettings( if (action.type === "toggle-session-affinity") { draft = { ...draft, - sessionAffinity: !draft.sessionAffinity, + sessionAffinity: !(draft.sessionAffinity !== false), }; continue; } if (action.type === "toggle-preemptive-quota") { draft = { ...draft, - preemptiveQuotaEnabled: !draft.preemptiveQuotaEnabled, + preemptiveQuotaEnabled: !(draft.preemptiveQuotaEnabled !== false), }; continue; } if (action.type === "toggle-pool-retry") { draft = { ...draft, - retryAllAccountsRateLimited: !draft.retryAllAccountsRateLimited, + retryAllAccountsRateLimited: !(draft.retryAllAccountsRateLimited !== false), }; continue; } diff --git a/test/index.test.ts b/test/index.test.ts index ddeb55db..944dacb7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1216,7 +1216,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { .mockImplementationOnce(() => accountOne) .mockImplementationOnce(() => accountTwo) .mockImplementation(() => null); - vi.spyOn(AccountManager.prototype, "consumeToken") + const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken") .mockReturnValueOnce(false) .mockReturnValueOnce(true); globalThis.fetch = vi.fn().mockResolvedValue( @@ -1230,6 +1230,9 @@ describe("OpenAIOAuthPlugin fetch handler", () => { }); 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]); }); diff --git a/test/settings-hub-utils.test.ts b/test/settings-hub-utils.test.ts index 8ba26a04..089b9810 100644 --- a/test/settings-hub-utils.test.ts +++ b/test/settings-hub-utils.test.ts @@ -735,6 +735,42 @@ describe("settings-hub utility coverage", () => { 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[] = []; From 5c629b61869e4e54f4813752627d2a87fc3ecb90 Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 03:02:17 +0800 Subject: [PATCH 16/17] fix: close remaining review gaps --- index.ts | 93 ++++++--- lib/accounts.ts | 1 + test/accounts-load-from-disk.test.ts | 13 +- test/index.test.ts | 284 +++++++++++++++++++++++---- test/plugin-config.test.ts | 6 + 5 files changed, 331 insertions(+), 66 deletions(-) diff --git a/index.ts b/index.ts index 6cfada14..0447ab56 100644 --- a/index.ts +++ b/index.ts @@ -1020,35 +1020,36 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { // Reload immediately after the persisted selection change so the cached // manager cannot keep stale active-index state, even on the first manual // selection before any request path has populated the cache. - const activeAccountManager = await reloadAccountManagerFromDisk(); - if (activeAccountManager && codexCliDirectInjectionEnabledForEvent) { + await reloadAccountManagerFromDisk(); + if (codexCliDirectInjectionEnabledForEvent) { const synced = await queueCodexCliSelectionSync({ generation: manualSelectionGeneration, runSync: async ({ isStale }) => { if (isStale()) { return false; } - try { - return await activeAccountManager.syncCodexCliActiveSelectionForIndex( - index, - ); - } catch (error) { - logWarn( - `[${PLUGIN_NAME}] Codex CLI selection sync failed for manual account selection`, - { - 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; - } + const activeAccountManager = await reloadAccountManagerFromDisk(); + try { + return await activeAccountManager.syncCodexCliActiveSelectionForIndex( + index, + ); + } catch (error) { + logWarn( + `[${PLUGIN_NAME}] Codex CLI selection sync failed for manual account selection`, + { + 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 === manualSelectionGeneration) { @@ -1259,35 +1260,60 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { if (!codexCliDirectInjectionEnabled) { return false; } - const signature = buildCodexCliSelectionSignature(account); 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 = + account.addedAt === undefined + ? activeAccountManager.getAccountByIndex(account.index) + : activeAccountManager + .getAccountsSnapshot() + .find((candidate) => candidate.addedAt === account.addedAt) ?? + null; + if (!liveAccount) { + return false; + } + const liveSignature = buildCodexCliSelectionSignature(liveAccount); if ( !options?.force && - lastCodexCliActiveSyncIndex === account.index && - lastCodexCliActiveSyncSignature === signature + lastCodexCliActiveSyncIndex === liveAccount.index && + lastCodexCliActiveSyncSignature === liveSignature ) { return true; } - const activeAccountManager = cachedAccountManager ?? accountManager; let synced = false; try { synced = await activeAccountManager.syncCodexCliActiveSelectionForIndex( - account.index, + liveAccount.index, ); } catch (error) { + if (options?.force) { + clearCachedSelection(liveAccount.index, liveSignature); + } logWarn( `[${PLUGIN_NAME}] Codex CLI selection sync failed`, { - accountIndex: account.index, - signature, + accountIndex: liveAccount.index, + signature: liveSignature, code: error && typeof error === "object" && @@ -1300,9 +1326,12 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); return false; } + if (!synced && options?.force) { + clearCachedSelection(liveAccount.index, liveSignature); + } if (synced && !context.isStale()) { - lastCodexCliActiveSyncIndex = account.index; - lastCodexCliActiveSyncSignature = signature; + lastCodexCliActiveSyncIndex = liveAccount.index; + lastCodexCliActiveSyncSignature = liveSignature; } return synced; }; diff --git a/lib/accounts.ts b/lib/accounts.ts index ba31c5ce..eb69ffda 100644 --- a/lib/accounts.ts +++ b/lib/accounts.ts @@ -393,6 +393,7 @@ export class AccountManager { if (index < 0 || index >= this.accounts.length) return false; const account = this.accounts[index]; if (!account) return false; + if (account.enabled === false) return false; return setCodexCliActiveSelection({ accountId: account.accountId, email: account.email, diff --git a/test/accounts-load-from-disk.test.ts b/test/accounts-load-from-disk.test.ts index 4bb10852..b0709b8c 100644 --- a/test/accounts-load-from-disk.test.ts +++ b/test/accounts-load-from-disk.test.ts @@ -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,12 +174,23 @@ 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 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 expect(manager.syncCodexCliActiveSelectionForIndex(0)).resolves.toBe(true); diff --git a/test/index.test.ts b/test/index.test.ts index 944dacb7..65357ff9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -347,6 +347,15 @@ function buildCustomManager( }; } +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"); return { @@ -1197,28 +1206,37 @@ describe("OpenAIOAuthPlugin fetch handler", () => { it("injects only the account that clears token-bucket admission", 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, }; - vi.spyOn(AccountManager.prototype, "getAccountCount").mockReturnValue(2); - vi.spyOn(AccountManager.prototype, "getCurrentOrNextForFamilyHybrid") - .mockImplementationOnce(() => accountOne) - .mockImplementationOnce(() => accountTwo) - .mockImplementation(() => null); - const consumeSpy = vi.spyOn(AccountManager.prototype, "consumeToken") + 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 }), ); @@ -1324,11 +1342,8 @@ describe("OpenAIOAuthPlugin fetch handler", () => { await eventPromise; expect(response.status).toBe(200); - expect( - syncCodexCliSelectionMock.mock.calls - .map((call) => call[0]) - .slice(0, 2), - ).toEqual([0, 1]); + 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 () => { @@ -1479,6 +1494,11 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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( () => @@ -1520,6 +1540,11 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 0, 1, ]); + expect(getFetchAccessTokenSequence()).toEqual([ + "access-account-1", + "access-account-2", + "access-account-2", + ]); } finally { vi.useRealTimers(); } @@ -1538,6 +1563,10 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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 }), ); @@ -1561,10 +1590,28 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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 failed write", async () => { + 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( @@ -1580,10 +1627,65 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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(syncCodexCliSelectionMock.mock.calls.map((call) => call[0])).toEqual([0, 0]); + 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 () => { @@ -1637,6 +1739,11 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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(); @@ -1713,6 +1820,12 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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(); @@ -1782,6 +1895,10 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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(); @@ -1976,6 +2093,36 @@ describe("OpenAIOAuthPlugin fetch handler", () => { } }); + 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 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).not.toHaveBeenCalled(); + } finally { + vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(true); + } + }); + it("uses the refreshed token email when checking entitlement blocks", async () => { mockStorage.accounts = [ { @@ -2067,30 +2214,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 }), ); @@ -2104,9 +2258,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 () => { @@ -2500,7 +2651,7 @@ describe("OpenAIOAuthPlugin fetch handler", () => { recordRateLimit: () => {}, consumeToken: () => true, refundToken: () => {}, - syncCodexCliActiveSelectionForIndex: async () => {}, + syncCodexCliActiveSelectionForIndex: async () => true, markSwitched: () => {}, removeAccount: () => {}, recordFailure: () => {}, @@ -3242,13 +3393,80 @@ 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[0]).toBeLessThan( + 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("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"); @@ -3321,7 +3539,7 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { event: { type: "account.select", properties: { index: 1 } }, }); - expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(loadFromDiskSpy).toHaveBeenCalledTimes(2); expect(mockStorage.activeIndex).toBe(1); expect(mockStorage.activeIndexByFamily["gpt-5.1"]).toBe(1); }); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 33bc79f5..5cbd587e 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -823,6 +823,12 @@ describe('Plugin Configuration', () => { 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); From 5336b1932ae5edf6e9699f4d0427852f5d5c22dc Mon Sep 17 00:00:00 2001 From: ndycode Date: Fri, 20 Mar 2026 03:44:14 +0800 Subject: [PATCH 17/17] fix: close remaining cli sync review gaps --- index.ts | 218 ++++++++++++++++++++++++++++++++------------ test/index.test.ts | 223 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 380 insertions(+), 61 deletions(-) diff --git a/index.ts b/index.ts index 0447ab56..c6042135 100644 --- a/index.ts +++ b/index.ts @@ -978,6 +978,59 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; + 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 { @@ -1016,47 +1069,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { const codexCliDirectInjectionEnabledForEvent = getCodexCliDirectInjection(loadPluginConfig()); await saveAccounts(storage); - const manualSelectionGeneration = ++codexCliSelectionGeneration; - // Reload immediately after the persisted selection change so the cached - // manager cannot keep stale active-index state, even on the first manual - // selection before any request path has populated the cache. - await reloadAccountManagerFromDisk(); - if (codexCliDirectInjectionEnabledForEvent) { - const synced = await queueCodexCliSelectionSync({ - generation: manualSelectionGeneration, - 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 manual account selection`, - { - 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 === manualSelectionGeneration) { - lastCodexCliActiveSyncIndex = index; - lastCodexCliActiveSyncSignature = null; - } - } + await syncPersistedCodexCliSelection(index, { + enabled: codexCliDirectInjectionEnabledForEvent, + reason: "manual account selection", + }); await showToast(`Switched to account ${index + 1}`, "info"); } @@ -1242,19 +1258,101 @@ 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: { - index: number; - addedAt?: number; - }, + account: CodexCliSelectionAccount, ): string => - [account.index, String(account.addedAt ?? "")].join("|"); + [ + 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: { - index: number; - addedAt?: number; - }, + account: CodexCliSelectionAccount, options?: { force?: boolean; generation?: number }, ): Promise => { if (!codexCliDirectInjectionEnabled) { @@ -1281,13 +1379,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return false; } const activeAccountManager = cachedAccountManager ?? accountManager; - const liveAccount = - account.addedAt === undefined - ? activeAccountManager.getAccountByIndex(account.index) - : activeAccountManager - .getAccountsSnapshot() - .find((candidate) => candidate.addedAt === account.addedAt) ?? - null; + const liveAccount = resolveLiveCodexCliSelectionAccount( + activeAccountManager, + account, + ); if (!liveAccount) { return false; } @@ -3772,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/test/index.test.ts b/test/index.test.ts index 65357ff9..c563f03b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -856,7 +856,7 @@ describe("OpenAIOAuthPlugin", () => { loadFromDiskSpy.mockClear(); await plugin.tool["codex-switch"].execute({ index: 2 }); - expect(loadFromDiskSpy).toHaveBeenCalledTimes(1); + expect(loadFromDiskSpy).toHaveBeenCalledTimes(2); }); }); @@ -1429,9 +1429,45 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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); @@ -1456,6 +1492,12 @@ describe("OpenAIOAuthPlugin fetch handler", () => { 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(); } @@ -2005,6 +2047,107 @@ describe("OpenAIOAuthPlugin fetch handler", () => { } }); + 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" }), + }); + + 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("skips automatic CLI injection when direct injection is disabled", async () => { const configModule = await import("../lib/config.js"); vi.mocked(configModule.getCodexCliDirectInjection).mockReturnValue(false); @@ -3467,6 +3610,84 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { 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");