diff --git a/lib/config.ts b/lib/config.ts index f9e7ecf8..4d63bd8e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -145,6 +145,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { liveAccountSync: true, liveAccountSyncDebounceMs: 250, liveAccountSyncPollMs: 2_000, + codexCliSessionSupervisor: false, sessionAffinity: true, sessionAffinityTtlMs: 20 * 60_000, sessionAffinityMaxEntries: 512, @@ -857,6 +858,25 @@ export function getLiveAccountSyncPollMs(pluginConfig: PluginConfig): number { ); } +/** + * Determines whether the CLI session supervisor wrapper is enabled. + * + * This accessor is synchronous, side-effect free, and safe for concurrent reads. + * It performs no filesystem I/O and does not expose token material. + * + * @param pluginConfig - The plugin configuration object used as the non-environment fallback + * @returns `true` when the session supervisor should wrap interactive Codex sessions + */ +export function getCodexCliSessionSupervisor( + pluginConfig: PluginConfig, +): boolean { + return resolveBooleanSetting( + "CODEX_AUTH_CLI_SESSION_SUPERVISOR", + pluginConfig.codexCliSessionSupervisor, + false, + ); +} + /** * Indicates whether session affinity is enabled. * diff --git a/lib/quota-probe.ts b/lib/quota-probe.ts index 9535b7f9..96c1acbf 100644 --- a/lib/quota-probe.ts +++ b/lib/quota-probe.ts @@ -305,6 +305,19 @@ export interface ProbeCodexQuotaOptions { model?: string; fallbackModels?: readonly string[]; timeoutMs?: number; + signal?: AbortSignal; +} + +function createAbortError(message: string): Error { + const error = new Error(message); + error.name = "AbortError"; + return error; +} + +function throwIfQuotaProbeAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw createAbortError("Quota probe aborted"); + } } /** @@ -331,8 +344,11 @@ export async function fetchCodexQuotaSnapshot( let lastError: Error | null = null; for (const model of models) { + throwIfQuotaProbeAborted(options.signal); try { + throwIfQuotaProbeAborted(options.signal); const instructions = await getCodexInstructions(model); + throwIfQuotaProbeAborted(options.signal); const probeBody: RequestBody = { model, stream: true, @@ -356,6 +372,12 @@ export async function fetchCodexQuotaSnapshot( headers.set("content-type", "application/json"); const controller = new AbortController(); + const onAbort = () => controller.abort(options.signal?.reason); + if (options.signal?.aborted) { + controller.abort(options.signal.reason); + } else { + options.signal?.addEventListener("abort", onAbort, { once: true }); + } const timeout = setTimeout(() => controller.abort(), timeoutMs); let response: Response; try { @@ -367,6 +389,7 @@ export async function fetchCodexQuotaSnapshot( }); } finally { clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); } const snapshotBase = parseQuotaSnapshotBase(response.headers, response.status); @@ -390,6 +413,7 @@ export async function fetchCodexQuotaSnapshot( const unsupportedInfo = getUnsupportedCodexModelInfo(errorBody); if (unsupportedInfo.isUnsupported) { + throwIfQuotaProbeAborted(options.signal); lastError = new Error( unsupportedInfo.message ?? `Model '${model}' unsupported for this account`, ); @@ -406,9 +430,16 @@ export async function fetchCodexQuotaSnapshot( } lastError = new Error("Codex response did not include quota headers"); } catch (error) { + if (options.signal?.aborted) { + throw error instanceof Error ? error : createAbortError("Quota probe aborted"); + } lastError = error instanceof Error ? error : new Error(String(error)); } } + if (options.signal?.aborted) { + throw createAbortError("Quota probe aborted"); + } + throw lastError ?? new Error("Failed to fetch quotas"); } diff --git a/lib/schemas.ts b/lib/schemas.ts index 1ab18caa..452e39e1 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -44,6 +44,7 @@ export const PluginConfigSchema = z.object({ liveAccountSync: z.boolean().optional(), liveAccountSyncDebounceMs: z.number().min(50).optional(), liveAccountSyncPollMs: z.number().min(500).optional(), + codexCliSessionSupervisor: z.boolean().optional(), sessionAffinity: z.boolean().optional(), sessionAffinityTtlMs: z.number().min(1_000).optional(), sessionAffinityMaxEntries: z.number().min(8).optional(), diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 9caebf96..643f61e8 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -21,6 +21,7 @@ import { getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, getPreemptiveQuotaMaxDeferralMs, + getCodexCliSessionSupervisor, } from '../lib/config.js'; import type { PluginConfig } from '../lib/types.js'; import * as fs from 'node:fs'; @@ -67,6 +68,7 @@ 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_CLI_SESSION_SUPERVISOR', ] as const; const originalEnv: Partial> = {}; @@ -139,6 +141,7 @@ describe('Plugin Configuration', () => { preemptiveQuotaRemainingPercent5h: 5, preemptiveQuotaRemainingPercent7d: 5, preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, + codexCliSessionSupervisor: false, }); // existsSync is called with multiple candidate config paths (primary + legacy fallbacks) expect(mockExistsSync).toHaveBeenCalled(); @@ -197,6 +200,7 @@ describe('Plugin Configuration', () => { preemptiveQuotaRemainingPercent5h: 5, preemptiveQuotaRemainingPercent7d: 5, preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, + codexCliSessionSupervisor: false, }); }); @@ -452,6 +456,7 @@ describe('Plugin Configuration', () => { preemptiveQuotaRemainingPercent5h: 5, preemptiveQuotaRemainingPercent7d: 5, preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, + codexCliSessionSupervisor: false, }); }); @@ -516,6 +521,7 @@ describe('Plugin Configuration', () => { preemptiveQuotaRemainingPercent5h: 5, preemptiveQuotaRemainingPercent7d: 5, preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, + codexCliSessionSupervisor: false, }); expect(mockLogWarn).toHaveBeenCalled(); }); @@ -574,6 +580,7 @@ describe('Plugin Configuration', () => { preemptiveQuotaRemainingPercent5h: 5, preemptiveQuotaRemainingPercent7d: 5, preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000, + codexCliSessionSupervisor: false, }); expect(mockLogWarn).toHaveBeenCalled(); }); @@ -980,5 +987,32 @@ describe('Plugin Configuration', () => { expect(getPreemptiveQuotaMaxDeferralMs({ preemptiveQuotaMaxDeferralMs: 2_000 })).toBe(123000); }); }); + + describe('CLI session supervisor setting', () => { + it('should default the supervisor wrapper to disabled', () => { + expect(getCodexCliSessionSupervisor({})).toBe(false); + }); + + it('should honor the config value when the env override is unset', () => { + delete process.env.CODEX_AUTH_CLI_SESSION_SUPERVISOR; + expect( + getCodexCliSessionSupervisor({ codexCliSessionSupervisor: true }), + ).toBe(true); + }); + + it('should allow the env override to disable the supervisor wrapper', () => { + process.env.CODEX_AUTH_CLI_SESSION_SUPERVISOR = '0'; + expect( + getCodexCliSessionSupervisor({ codexCliSessionSupervisor: true }), + ).toBe(false); + delete process.env.CODEX_AUTH_CLI_SESSION_SUPERVISOR; + }); + + it('should prioritize environment override for the supervisor wrapper', () => { + process.env.CODEX_AUTH_CLI_SESSION_SUPERVISOR = '1'; + expect(getCodexCliSessionSupervisor({ codexCliSessionSupervisor: false })).toBe(true); + delete process.env.CODEX_AUTH_CLI_SESSION_SUPERVISOR; + }); + }); }); diff --git a/test/quota-probe.test.ts b/test/quota-probe.test.ts index 96e9605f..4a948920 100644 --- a/test/quota-probe.test.ts +++ b/test/quota-probe.test.ts @@ -170,6 +170,132 @@ describe("quota-probe", () => { await assertion; expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it("aborts immediately when the caller abort signal fires", async () => { + const controller = new AbortController(); + let markFetchStarted!: () => void; + const fetchStarted = new Promise((resolve) => { + markFetchStarted = resolve; + }); + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + const error = new Error("aborted"); + (error as Error & { name?: string }).name = "AbortError"; + reject(error); + }, + { once: true }, + ); + markFetchStarted(); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const pending = fetchCodexQuotaSnapshot({ + accountId: "acc-abort", + accessToken: "token-abort", + model: "gpt-5-codex", + fallbackModels: [], + timeoutMs: 30_000, + signal: controller.signal, + }); + + await fetchStarted; + controller.abort(); + + await expect(pending).rejects.toThrow(/abort/i); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects immediately when the caller signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-pre-aborted", + accessToken: "token-pre-aborted", + model: "gpt-5-codex", + fallbackModels: [], + timeoutMs: 30_000, + signal: controller.signal, + }), + ).rejects.toThrow(/abort/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("re-checks the caller abort signal after loading instructions", async () => { + const controller = new AbortController(); + const instructionsReady = (() => { + let resolve!: (value: string) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; + })(); + getCodexInstructionsMock.mockImplementationOnce(() => instructionsReady.promise); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const pending = fetchCodexQuotaSnapshot({ + accountId: "acc-post-instructions-abort", + accessToken: "token-post-instructions-abort", + model: "gpt-5-codex", + fallbackModels: [], + timeoutMs: 30_000, + signal: controller.signal, + }); + + controller.abort(); + instructionsReady.resolve("instructions:gpt-5-codex"); + + await expect(pending).rejects.toThrow(/abort/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not start probing a fallback model after the caller aborts an unsupported first attempt", async () => { + const controller = new AbortController(); + const unsupported = new Response( + JSON.stringify({ + error: { message: "Model gpt-5.3-codex unsupported", type: "invalid_request_error" }, + }), + { status: 400, headers: new Headers({ "content-type": "application/json" }) }, + ); + const fetchMock = vi.fn(async () => { + controller.abort(); + return unsupported; + }); + vi.stubGlobal("fetch", fetchMock); + + getUnsupportedCodexModelInfoMock + .mockReturnValueOnce({ + isUnsupported: true, + unsupportedModel: "gpt-5.3-codex", + message: "unsupported", + }) + .mockReturnValue({ + isUnsupported: false, + unsupportedModel: undefined, + message: undefined, + }); + + await expect( + fetchCodexQuotaSnapshot({ + accountId: "acc-abort-before-fallback", + accessToken: "token-abort-before-fallback", + model: "gpt-5.3-codex", + fallbackModels: ["gpt-5.2-codex"], + signal: controller.signal, + }), + ).rejects.toThrow(/abort/i); + expect(getCodexInstructionsMock).toHaveBeenCalledTimes(1); + expect(getCodexInstructionsMock).toHaveBeenCalledWith("gpt-5.3-codex"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); it("parses reset-at values expressed as epoch seconds and epoch milliseconds", async () => { const nowSec = Math.floor(Date.now() / 1000); const primarySeconds = nowSec + 120;