Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down
31 changes: 31 additions & 0 deletions lib/quota-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}

/**
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -367,6 +389,7 @@ export async function fetchCodexQuotaSnapshot(
});
} finally {
clearTimeout(timeout);
options.signal?.removeEventListener("abort", onAbort);
}

const snapshotBase = parseQuotaSnapshotBase(response.headers, response.status);
Expand All @@ -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`,
);
Expand All @@ -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");
}
1 change: 1 addition & 0 deletions lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
34 changes: 34 additions & 0 deletions test/plugin-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Record<(typeof envKeys)[number], string | undefined>> = {};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -197,6 +200,7 @@ describe('Plugin Configuration', () => {
preemptiveQuotaRemainingPercent5h: 5,
preemptiveQuotaRemainingPercent7d: 5,
preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000,
codexCliSessionSupervisor: false,
});
});

Expand Down Expand Up @@ -452,6 +456,7 @@ describe('Plugin Configuration', () => {
preemptiveQuotaRemainingPercent5h: 5,
preemptiveQuotaRemainingPercent7d: 5,
preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000,
codexCliSessionSupervisor: false,
});
});

Expand Down Expand Up @@ -516,6 +521,7 @@ describe('Plugin Configuration', () => {
preemptiveQuotaRemainingPercent5h: 5,
preemptiveQuotaRemainingPercent7d: 5,
preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000,
codexCliSessionSupervisor: false,
});
expect(mockLogWarn).toHaveBeenCalled();
});
Expand Down Expand Up @@ -574,6 +580,7 @@ describe('Plugin Configuration', () => {
preemptiveQuotaRemainingPercent5h: 5,
preemptiveQuotaRemainingPercent7d: 5,
preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000,
codexCliSessionSupervisor: false,
});
expect(mockLogWarn).toHaveBeenCalled();
});
Expand Down Expand Up @@ -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;
});
});
});

126 changes: 126 additions & 0 deletions test/quota-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
markFetchStarted = resolve;
});
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
return new Promise<Response>((_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<string>((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;
Expand Down