From fdf0236214535c50bb752958a776fb4d1b886a73 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:10:47 +0200 Subject: [PATCH 01/16] fix(windows): async ACL harden for response-state writes (#612) Stop blocking the event loop on sync icacls during debounced response-state persistence. Add an async ACL runner with explicit timeout provenance, async atomic writes keyed by destination path (not parent dir), single-flight serialization, and awaited flush on shutdown. --- src/config.ts | 86 ++++++++++++++- src/lib/windows-secret-acl.ts | 177 ++++++++++++++++++++++++++++++- src/responses/state.ts | 30 ++++-- src/server/lifecycle.ts | 2 +- tests/responses-state.test.ts | 24 ++--- tests/windows-secret-acl.test.ts | 85 +++++++++++++++ 6 files changed, 378 insertions(+), 26 deletions(-) diff --git a/src/config.ts b/src/config.ts index 1168e0e4e..833464904 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import * as z from "zod/v4"; import { comboConfigIssues } from "./combos/types"; -import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; +import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { @@ -128,6 +128,90 @@ export function atomicWriteFile(path: string, content: string, io: AtomicWriteIO } } +/** Async atomic-write I/O: harden may await icacls without blocking the event loop (#612). */ +export interface AtomicWriteAsyncIO { + write: (path: string, content: string) => void | Promise; + harden: (path: string) => void | Promise; + rename: (source: string, destination: string) => void | Promise; + truncate: (path: string) => void | Promise; + unlink: (path: string) => void | Promise; +} + +async function renameAtomicFileAsync(source: string, destination: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + renameSync(source, destination); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const transientWindowsError = process.platform === "win32" + && (code === "EBUSY" || code === "EPERM" || code === "EACCES"); + if (!transientWindowsError || attempt >= 2) throw error; + await Bun.sleep(25 * (attempt + 1)); + } + } +} + +/** + * Async atomic write (#612): same temp+harden+rename and residual-temp policy as + * atomicWriteFile, but Windows ACL harden yields the event loop. Timeout memo is keyed + * by the final destination path (not the unique temp, not the parent directory). + */ +export async function atomicWriteFileAsync( + path: string, + content: string, + io?: AtomicWriteAsyncIO, +): Promise { + const effective: AtomicWriteAsyncIO = io ?? { + write: (target, value) => writeFileSync(target, value, { encoding: "utf-8", mode: 0o600 }), + harden: async target => { + try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + if (process.platform === "win32") { + await hardenSecretPathAsync(target, { required: true, timeoutMemoKey: path }); + } + }, + rename: renameAtomicFileAsync, + truncate: target => truncateSync(target, 0), + unlink: unlinkSync, + }; + const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`; + let hardened = false; + try { + await effective.write(tmp, content); + await effective.harden(tmp); + hardened = true; + await effective.rename(tmp, path); + } catch (cause) { + let scrubbed = false; + try { + await effective.truncate(tmp); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { await effective.write(tmp, ""); scrubbed = true; } catch { /* removal may still succeed */ } + } + } + let removed = false; + try { + await effective.unlink(tmp); + removed = true; + } catch (error) { + if (isMissingPathError(error)) removed = true; + else { + try { await effective.unlink(tmp); removed = true; } + catch (retryError) { if (isMissingPathError(retryError)) removed = true; } + } + } + if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(tmp, { cause }); + if (!removed && !hardened) { + try { await effective.harden(tmp); hardened = true; } catch { /* zero-byte residual is reported honestly */ } + } + if (!removed) throw new AtomicWriteResidualTempError(tmp, hardened, { cause }); + throw cause; + } +} + export class OpenAiTierBackupCleanupError extends Error { constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; } } diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 7cdc48a85..e5ce2eee9 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -24,6 +24,10 @@ * icacls cannot block OAuth logins or token refresh (field report: Kimi auth * stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw: * availability never silently overrides confidentiality for those. + * hardenSecretPathAsync / hardenSecretDirAsync — same policy, async icacls + * runner so the event loop is not held for the child lifetime (#612). + * HardenOptions.timeoutMemoKey — optional destination-path key for the + * timeout memo (atomic writers mint unique temps; never a parent directory). * hardenSecretDir — same contract for directories. */ @@ -42,6 +46,13 @@ export interface HardenResult { export interface HardenOptions { required: boolean; + /** + * Optional timeout-memo key distinct from `targetPath` (issue #612). + * Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the + * final destination path prevents re-stalling the event loop on every subsequent temp. + * Must NOT be a parent directory — directory ACLs are not authoritative for new files. + */ + timeoutMemoKey?: string; } /** @@ -72,6 +83,7 @@ export interface IcaclsResult { } type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult; +type AsyncIcaclsRunner = (args: string[], timeoutMs: number) => Promise; function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even @@ -91,7 +103,43 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult { }; } +/** + * Async icacls runner (#612): yields the event loop while waiting for the child. + * Timeout provenance is recorded by our timer (async Subprocess has no exitedDueToTimeout); + * we still await process exit before classifying so settlement is confirmed. + */ +async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise { + const proc = Bun.spawn(["icacls.exe", ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + let timedOutByUs = false; + const timer = setTimeout(() => { + timedOutByUs = true; + try { proc.kill(); } catch { /* already exited */ } + }, Math.max(1, timeoutMs)); + let exitCode: number | null = null; + try { + exitCode = await proc.exited; + } finally { + clearTimeout(timer); + } + const stdout = proc.stdout + ? await new Response(proc.stdout).text().catch(() => "") + : ""; + const timedOut = timedOutByUs; + return { + success: !timedOut && exitCode === 0, + exitCode: timedOut ? null : exitCode, + timedOut, + stdout, + }; +} + let icaclsRunner: IcaclsRunner = defaultIcaclsRunner; +let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner; let platformOverride: string | null = null; let nowFn: () => number = Date.now; @@ -100,6 +148,11 @@ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void { icaclsRunner = runner ?? defaultIcaclsRunner; } +/** Test seam: replace the async icacls runner. Pass null to restore the default. */ +export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): void { + asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; +} + /** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */ export function setPlatformForTests(value: string | null): void { platformOverride = value; @@ -156,6 +209,10 @@ function currentWindowsUser(): string | undefined { */ const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const; +function grantAce(user: string, directory: boolean): string { + return directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`; +} + function runIcacls(targetPath: string, directory: boolean, deadline: number): void { const user = currentWindowsUser(); if (!user) { @@ -177,8 +234,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo // Step 1: grant current user full control BEFORE any destructive ACL change. // If this fails, inheritance is untouched and the writer keeps inherited access. - const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`; - runOrThrow("/grant:r", [targetPath, "/grant:r", grant]); + runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE // from step 1 survives this transition, so a later failure still leaves cleanup access. @@ -205,6 +261,41 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo } } +/** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */ +async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise { + const user = currentWindowsUser(); + if (!user) { + throw new Error("Cannot determine current Windows user for ACL hardening"); + } + + const run = async (step: string, args: string[]): Promise => { + const remaining = deadline - nowFn(); + if (remaining <= 0) { + throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" }); + } + return asyncIcaclsRunner(args, remaining); + }; + const runOrThrow = async (step: string, args: string[]): Promise => { + const result = await run(step, args); + if (!result.success) throw icaclsError(step, result); + }; + + await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]); + + const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]); + if (!removal.success) { + if (removal.timedOut) throw icaclsError("/remove:g", removal); + for (const sid of BROAD_SIDS) { + const found = await run("/findsid", [targetPath, "/findsid", sid]); + if (!found.success) throw icaclsError("/findsid", found); + if (found.stdout.includes(targetPath)) { + throw icaclsError("/remove:g", removal); + } + } + } +} + /** * Sanitize an error from a failed ACL operation into a safe diagnostic string. * The raw path must not appear in the returned string (it may contain @@ -254,6 +345,27 @@ function describeAclStateAfterTimeout(targetPath: string, deadline: number): str } } +async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: number): Promise { + try { + for (const sid of BROAD_SIDS) { + const remaining = deadline - nowFn(); + if (remaining <= 0) return "ACL state unverified (budget exhausted)"; + const found = await asyncIcaclsRunner([targetPath, "/findsid", sid], remaining); + if (!found.success) return "ACL state unverified (probe failed)"; + if (found.stdout.includes(targetPath)) return "broad ACL grants still present"; + } + return "no broad ACL grants detected (hardening still incomplete)"; + } catch { + return "ACL state unverified (probe failed)"; + } +} + +function timeoutMemoKey(targetPath: string, opts: HardenOptions): string { + // Destination-path memo only (issue #612). Never a parent directory — directory ACLs + // are not authoritative for newly created temps. + return opts.timeoutMemoKey ?? targetPath; +} + /** * Shared harden flow for files and directories: one total budget (env-configurable) * covering the initial attempt, ONE timeout retry, and the diagnostic verification. @@ -269,7 +381,8 @@ function hardenEntry( if (!existsSync(targetPath)) return { ok: true }; if (effectivePlatform() !== "win32") return { ok: true }; if (cache.has(targetPath)) return { ok: true }; - if (timedOutPaths.has(targetPath)) { + const memoKey = timeoutMemoKey(targetPath, opts); + if (timedOutPaths.has(memoKey)) { return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" }; } @@ -289,7 +402,7 @@ function hardenEntry( const diagnostics = sanitizeDiagnostics(lastErr); if (isTimeoutError(lastErr)) { - timedOutPaths.add(targetPath); + timedOutPaths.add(memoKey); const state = describeAclStateAfterTimeout(targetPath, deadline); const annotated = `${diagnostics}; ${state}`; // Timeout-only soft-fail: a hung icacls must not block OAuth/token writes. @@ -301,6 +414,47 @@ function hardenEntry( return { ok: false, diagnostics }; } +/** Async counterpart of hardenEntry — yields while waiting on icacls (#612). */ +async function hardenEntryAsync( + targetPath: string, + directory: boolean, + opts: HardenOptions, + cache: Set, +): Promise { + if (!existsSync(targetPath)) return { ok: true }; + if (effectivePlatform() !== "win32") return { ok: true }; + if (cache.has(targetPath)) return { ok: true }; + const memoKey = timeoutMemoKey(targetPath, opts); + if (timedOutPaths.has(memoKey)) { + return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" }; + } + + const deadline = nowFn() + resolveHardenDeadlineMs(); + let lastErr: unknown; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0 && deadline - nowFn() <= 0) break; + try { + await runIcaclsAsync(targetPath, directory, deadline); + cache.add(targetPath); + return { ok: true }; + } catch (err) { + lastErr = err; + if (!isTimeoutError(err)) break; + } + } + + const diagnostics = sanitizeDiagnostics(lastErr); + if (isTimeoutError(lastErr)) { + timedOutPaths.add(memoKey); + const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline); + const annotated = `${diagnostics}; ${state}`; + console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`); + return { ok: false, diagnostics: annotated }; + } + if (opts.required) throw new Error(diagnostics); + return { ok: false, diagnostics }; +} + /** * Harden a single file path with per-user NTFS ACLs on Windows. * On non-Windows platforms, returns ok:true immediately (caller owns chmod). @@ -312,6 +466,14 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde return hardenEntry(targetPath, false, opts, hardenedPaths); } +/** + * Async harden for write paths that must not block the event loop (#612). + * Same success/timeout/error policy as hardenSecretPath. + */ +export function hardenSecretPathAsync(targetPath: string, opts: HardenOptions): Promise { + return hardenEntryAsync(targetPath, false, opts, hardenedPaths); +} + /** * Harden a directory path with per-user NTFS ACLs on Windows. * On non-Windows platforms, returns ok:true immediately (caller owns chmod). @@ -322,3 +484,10 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult { return hardenEntry(targetPath, true, opts, hardenedDirectories); } + +/** + * Async directory harden (#612). Same policy as hardenSecretDir. + */ +export function hardenSecretDirAsync(targetPath: string, opts: HardenOptions): Promise { + return hardenEntryAsync(targetPath, true, opts, hardenedDirectories); +} diff --git a/src/responses/state.ts b/src/responses/state.ts index c9201805b..3345e4626 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -1,6 +1,6 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs"; import { dirname, join } from "node:path"; -import { atomicWriteFile, getConfigDir } from "../config"; +import { atomicWriteFileAsync, getConfigDir } from "../config"; import type { OcxProviderContinuationState } from "../types"; const MAX_STORED_RESPONSES = 1_000; @@ -83,6 +83,8 @@ const replayedInputPrefixLengths = new WeakMap(); let loaded = false; let persistTimer: ReturnType | null = null; let pendingPersistPath: string | null = null; +/** Single-flight gate: overlapping response-state writes serialize (#612). */ +let persistGate: Promise = Promise.resolve(); function now(): number { return Date.now(); @@ -247,12 +249,18 @@ function ensureLoaded(): void { } } -function persistNow(path: string): void { +async function persistNow(path: string): Promise { if (persistTimer) { clearTimeout(persistTimer); persistTimer = null; } pendingPersistPath = null; + + // Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612). + const previous = persistGate; + let release!: () => void; + persistGate = new Promise(resolve => { release = resolve; }); + await previous; try { const entries: [string, StoredResponseState][] = []; let total = 0; @@ -273,9 +281,11 @@ function persistNow(path: string): void { // mkdirSync's mode only applies on creation — re-harden an existing config dir so the // conversation-content snapshot never lands in a group/world-readable directory. try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ } - atomicWriteFile(path, JSON.stringify({ version: 2, states: entries })); + await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries })); } catch { /* best-effort: disk trouble must never affect request handling */ + } finally { + release(); } } @@ -285,15 +295,18 @@ function schedulePersist(): void { // debounce fires, and a late write must land in the home that owned the recorded state. pendingPersistPath = snapshotPath(); const path = pendingPersistPath; - persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS); + persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS); (persistTimer as { unref?: () => void }).unref?.(); } /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */ -export function flushResponseState(): void { - if (!persistTimer) return; - // Use the path captured when the write was scheduled — OPENCODEX_HOME may have moved since. - persistNow(pendingPersistPath ?? snapshotPath()); +export async function flushResponseState(): Promise { + if (persistTimer) { + await persistNow(pendingPersistPath ?? snapshotPath()); + return; + } + // No pending timer: still await any in-flight write so shutdown does not race (#612). + await persistGate; } function inputItems(input: unknown): unknown[] { @@ -448,6 +461,7 @@ export function clearResponseStateMemoryForTests(): void { clearTimeout(persistTimer); persistTimer = null; } + pendingPersistPath = null; states.clear(); storedResponseBytes = 0; loaded = false; diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index a07b493e4..cc063240d 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -86,7 +86,7 @@ export async function drainAndShutdown( } // Debounced replay-state snapshot may still be pending; flush so the last completed turn's // previous_response_id chain survives the restart this shutdown is usually part of. - flushResponseState(); + await flushResponseState(); // Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop. stopStorageCleanupScheduler(); abortStorageCleanupPolicyJob(); diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 1e3e200ce..705b71121 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -522,14 +522,14 @@ describe("Responses previous_response_id state", () => { } }); - test("byte accounting survives restart (sizes recomputed on load)", () => { + test("byte accounting survives restart (sizes recomputed on load)", async () => { setResponseStateByteCapForTests(4_000); try { const bulk = "y".repeat(1_500); const bodyA = { model: "cursor/grok-4.5", input: `${bulk}-a`, store: false }; const jsonA = buildResponseJSON([{ type: "text_delta", text: "ok" }, { type: "done" }], "cursor/grok-4.5"); rememberResponseState(bodyA, jsonA, { cursor: { conversationId: "conv_a" } }, { force: true }); - flushResponseState(); + await flushResponseState(); // Simulated restart: memory wiped, snapshot reloaded lazily. clearResponseStateMemoryForTests(); @@ -625,14 +625,14 @@ describe("Responses previous_response_id state", () => { } }); - test("snapshot survives a simulated restart (memory clear + disk load)", () => { + test("snapshot survives a simulated restart (memory clear + disk load)", async () => { const firstBody = { model: "gpt-5.5", input: "hello" }; const first = buildResponseJSON([ { type: "text_delta", text: "hi" }, { type: "done" }, ], "gpt-5.5"); rememberResponseState(firstBody, first, "cursor_conv_9"); - flushResponseState(); + await flushResponseState(); // Simulate restart: wipe memory, keep the snapshot file. clearResponseStateMemoryForTests(); @@ -747,7 +747,7 @@ describe("Responses previous_response_id state", () => { expect(previousResponseConversationId("resp_v1")).toBe("cursor_v1"); }); - test("persists provider-keyed Cursor and Kiro continuation state across restart", () => { + test("persists provider-keyed Cursor and Kiro continuation state across restart", async () => { const first = buildResponseJSON([ { type: "text_delta", text: "answer", phase: "final_answer" }, { type: "done", endTurn: true }, @@ -760,7 +760,7 @@ describe("Responses previous_response_id state", () => { kiro: { conversationId: "kiro_conv_2" }, }, ); - flushResponseState(); + await flushResponseState(); clearResponseStateMemoryForTests(); expect(previousResponseProviderState(first.id as string)).toEqual({ @@ -771,13 +771,13 @@ describe("Responses previous_response_id state", () => { expect(snapshot.version).toBe(2); }); - test("stale snapshot entries are pruned on load", () => { + test("stale snapshot entries are pruned on load", async () => { const first = buildResponseJSON([ { type: "text_delta", text: "old" }, { type: "done" }, ], "gpt-5.5"); rememberResponseState({ model: "gpt-5.5", input: "old turn" }, first); - flushResponseState(); + await flushResponseState(); clearResponseStateMemoryForTests(); // Rewrite the snapshot with an expired createdAt (2h ago > 1h TTL). @@ -821,7 +821,7 @@ describe("Responses previous_response_id state", () => { expect(expanded.input).toHaveLength(3); }); - test("oversized entries stay in memory but are skipped on disk", () => { + test("oversized entries stay in memory but are skipped on disk", async () => { const big = "x".repeat(3 * 1024 * 1024); // > 2MiB per-entry cap const first = buildResponseJSON([ { type: "text_delta", text: big }, @@ -834,7 +834,7 @@ describe("Responses previous_response_id state", () => { { type: "done" }, ], "gpt-5.5"); rememberResponseState({ model: "gpt-5.5", input: "small turn" }, small); - flushResponseState(); + await flushResponseState(); // In-memory: both expand. expect((expandPreviousResponseInput({ @@ -964,10 +964,10 @@ describe("Responses previous_response_id state", () => { } }); - test("is side-effect free: it never lazy-loads the disk snapshot, prunes, or evicts", () => { + test("is side-effect free: it never lazy-loads the disk snapshot, prunes, or evicts", async () => { const first = buildResponseJSON([{ type: "text_delta", text: "persisted" }, { type: "done" }], "gpt-5.5"); rememberResponseState({ model: "gpt-5.5", input: "persisted turn" }, first); - flushResponseState(); + await flushResponseState(); // Simulated restart: memory wiped, snapshot on disk, `loaded` reset to false. clearResponseStateMemoryForTests(); diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index e3a19b1d8..95871b44b 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -16,7 +16,9 @@ import { join } from "node:path"; import { hardenSecretDir, hardenSecretPath, + hardenSecretPathAsync, resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, setNowForTests, setPlatformForTests, @@ -475,3 +477,86 @@ describe("icacls failure paths (injected seams)", () => { expect(ownerHasExplicitAce).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Async harden (#612): same policy as sync, but yields via asyncIcaclsRunner. +// --------------------------------------------------------------------------- + +describe("async hardenSecretPath (issue #612)", () => { + const ok: IcaclsResult = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + const timeout: IcaclsResult = { success: false, exitCode: null, timedOut: true, stdout: "" }; + const denied: IcaclsResult = { success: false, exitCode: 5, timedOut: false, stdout: "" }; + let warnings: string[] = []; + const realWarn = console.warn; + + beforeEach(() => { + setPlatformForTests("win32"); + resetHardenedStateForTests(); + process.env.USERNAME ??= "tester"; + warnings = []; + console.warn = (...args: unknown[]) => { warnings.push(args.join(" ")); }; + }); + + afterEach(() => { + setPlatformForTests(null); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + setNowForTests(null); + resetHardenedStateForTests(); + console.warn = realWarn; + }); + + function secretFile(name = "secret.json"): string { + const filePath = join(testDir, name); + writeFileSync(filePath, "data", "utf-8"); + return filePath; + } + + test("async timeout soft-fails with the same policy as sync", async () => { + setAsyncIcaclsRunnerForTests(async () => timeout); + const result = await hardenSecretPathAsync(secretFile(), { required: true }); + expect(result.ok).toBe(false); + expect(result.diagnostics).toContain("ETIMEDOUT"); + expect(warnings.some(w => w.includes("continuing without NTFS ACL harden"))).toBe(true); + }); + + test("async permission failure still throws on required paths", async () => { + setAsyncIcaclsRunnerForTests(async () => denied); + await expect(hardenSecretPathAsync(secretFile(), { required: true })).rejects.toThrow(/EICACLS/); + }); + + test("timeoutMemoKey shares the timeout cache across distinct temp paths", async () => { + setAsyncIcaclsRunnerForTests(async () => timeout); + const dest = join(testDir, "responses-state.json"); + const tempA = join(testDir, "responses-state.json.ocx.1.1.tmp"); + const tempB = join(testDir, "responses-state.json.ocx.1.2.tmp"); + writeFileSync(tempA, "a", "utf-8"); + writeFileSync(tempB, "b", "utf-8"); + + const first = await hardenSecretPathAsync(tempA, { required: true, timeoutMemoKey: dest }); + expect(first.ok).toBe(false); + expect(first.diagnostics).toContain("ETIMEDOUT"); + + let calls = 0; + setAsyncIcaclsRunnerForTests(async () => { + calls += 1; + return timeout; + }); + const second = await hardenSecretPathAsync(tempB, { required: true, timeoutMemoKey: dest }); + expect(second.ok).toBe(false); + expect(second.diagnostics).toContain("skipped"); + expect(calls).toBe(0); // destination-keyed memo; not a parent-directory shortcut + }); + + test("async harden still grants owner before inheritance removal", async () => { + const steps: string[] = []; + setAsyncIcaclsRunnerForTests(async args => { + if (args.includes("/grant:r")) steps.push("grant-owner"); + else if (args.includes("/inheritance:r")) steps.push("remove-inheritance"); + else if (args.includes("/remove:g")) steps.push("remove-broad"); + return ok; + }); + expect(await hardenSecretPathAsync(secretFile(), { required: true })).toEqual({ ok: true }); + expect(steps).toEqual(["grant-owner", "remove-inheritance", "remove-broad"]); + }); +}); From 2bbac196ef01b2081bbd894e9d4b81e3cbac1690 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 29 Jul 2026 13:17:19 +0900 Subject: [PATCH 02/16] docs: a dev merge is not done until dev2-go carries it too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project is moving its primary runtime to the Go native port, so dev2-go has to keep receiving everything that lands on dev. Nothing changes for contributors: pull requests still target dev. The extra step belongs to the maintainer who merges one — rebase the work onto dev2-go, port whatever needs a Go counterpart under go/, and merge that. The item is finished only when both lines carry the change. An unported dev merge is the failure mode this rule exists to prevent: dev2-go silently falls behind and the divergence surfaces later as a conflict nobody has context for. So the port happens in the same session as the merge; a change with no Go counterpart gets that decision recorded; a port that has to wait gets a tracking issue against dev2-go naming the source commits. MAINTAINERS.md carries the authoritative wording and the other seven files summarise it. The maintainer table widens both maintainers' integration duty to dev and dev2-go, since that is what the rule actually asks of them. Also correct the 2026-07-27 change-log entry. It claimed requirement 2 (review by another current maintainer) would be satisfied "when this change is reviewed and merged". It never was: a2693c02, dc3a4ade and 02bbd47a landed on dev as direct owner pushes with no associated pull request. The addition is in effect regardless — @Wibias holds write access and has been merging since 2026-07-26 — so the entry now records the gap instead of asserting a procedure that did not happen. --- AGENTS.md | 15 +++++ CONTRIBUTING.md | 7 +++ MAINTAINERS.md | 55 +++++++++++++++---- docs-site/src/content/docs/contributing.md | 6 ++ docs-site/src/content/docs/ja/contributing.md | 5 ++ docs-site/src/content/docs/ko/contributing.md | 5 ++ docs-site/src/content/docs/ru/contributing.md | 6 ++ .../src/content/docs/zh-cn/contributing.md | 4 ++ 8 files changed, 91 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3e9989d2..f1c4fb4d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,21 @@ non-trivial change. CI runs these on Linux, Windows, and macOS. from `dev` (releases, docs deploys). Do not open feature PRs against `main`. - `preview` — prerelease train (`x.y.z-preview.*` versions). +### Transition to `dev2-go` + +The project is moving its primary runtime to the Go native port, so `dev2-go` +has to keep receiving everything that lands on `dev`. Pull requests against +`dev` stay welcome and unchanged — the extra work belongs to the maintainer who +merges them. + +A merge into `dev` does not finish the task. The merging maintainer also +rebases that work onto `dev2-go`, ports whatever needs a Go counterpart under +`go/`, and merges the port. The item is done only when both lines carry the +change. If a change has no Go counterpart, say so in the merge or tracking +issue; if the port has to wait, open a tracking issue against `dev2-go` naming +the source commits before closing out the `dev` merge. +[`MAINTAINERS.md`](./MAINTAINERS.md) holds the authoritative wording. + The Claude Desktop integration formerly carried on the `claudedesktop` branch is now fully merged into `dev`, and that branch has been retired. Desktop work continues as normal pull requests against `dev`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbf673adf..4a99de54f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,13 @@ Thanks for helping with opencodex. - `main` — releases only; moves by maintainer-controlled promotion from `dev`. - `preview` — prerelease train. +While the project moves its primary runtime to the Go native port, `dev2-go` +has to keep receiving everything that lands on `dev`. Nothing changes for +contributors: keep opening pull requests against `dev`. After merging, a +maintainer rebases the work onto `dev2-go` and ports whatever needs a Go +counterpart, and the item is finished only once both lines carry it. See +[`MAINTAINERS.md`](./MAINTAINERS.md) for the full rule. + Porting and rebase pull requests are welcome: carrying a fix across integration lines, or rebasing a stale branch onto the current head, is normal contribution. Note the source commits in the description. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index bd5f3a487..79f0f39e0 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -8,12 +8,16 @@ review and merge policy. | GitHub account | Project role | Responsibilities | | --- | --- | --- | | [@lidge-jun](https://github.com/lidge-jun) | Project owner | Project direction, releases, repository administration, and final governance decisions | -| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` integration, security review, and repository maintenance | -| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` integration, and provider/CI maintenance | +| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` and `dev2-go` integration, security review, and repository maintenance | +| [@Wibias](https://github.com/Wibias) | Maintainer | Issue and pull-request triage, `dev` and `dev2-go` integration, and provider/CI maintenance | The table describes project responsibilities. Actual repository permissions remain controlled through GitHub repository settings. +Integration duty spans both lines while the Go native port is in transition: a +maintainer who merges into `dev` also carries that work onto `dev2-go`. The +rule is written out under [Review and merge policy](#review-and-merge-policy). + ## Review and merge policy - Pull requests target `dev` by default. `dev2-go` is a parallel integration @@ -23,6 +27,25 @@ through GitHub repository settings. distinguish scoped Go port work from anything else aimed at `dev2-go`, so that boundary is enforced in review: redirect an out-of-scope pull request to `dev` rather than treating the automation's silence as approval. +- **Transition to `dev2-go` (current phase).** The project is moving its + primary runtime to the Go native port, so `dev2-go` has to keep receiving + everything that lands on `dev`. Contributors still open pull requests against + `dev`, and that stays allowed — the extra work is the maintainer's, not + theirs. A merge into `dev` is therefore not the end of the task. The + maintainer who merges it also rebases that work onto `dev2-go`, ports + whatever needs a Go counterpart under `go/`, and merges the port. The item is + finished only when both lines carry the change. + - Do the rebase and the port in the same session as the merge. A `dev` merge + left unported is the failure mode this rule exists to prevent: `dev2-go` + silently falls behind, and the divergence surfaces later as a conflict + nobody has context for. + - When a change genuinely has no Go counterpart — docs, TypeScript-only + paths the port does not cover yet, GUI-only work — record that decision in + the merge or the tracking issue. An unexplained missing port is + indistinguishable from a forgotten one. + - If the port cannot be completed immediately (a blocking dependency, a + subsystem the port has not reached), open a tracking issue against + `dev2-go` before closing out the `dev` merge, and name the source commits. - A pull request requires approval from at least one maintainer and successful required CI checks before merge. - Authors do not approve their own pull requests. @@ -46,16 +69,24 @@ Adding or removing a maintainer requires: - 2026-07-27 — [@Wibias](https://github.com/Wibias) added as a maintainer. Requirement 1 (agreement from the project owner) is met: the owner requested - the addition. **Requirement 2 (review by another current maintainer) is still - open** and is satisfied when this change is reviewed and merged; until then - this entry records an in-progress change, not a completed procedure. - Requirement 3 is met by this file and `.github/CODEOWNERS`. - - Scope covers issue and pull-request triage, `dev` integration, and - provider/CI maintenance. Security-boundary ownership in `.github/CODEOWNERS` - is deliberately unchanged: authentication, credential handling, GitHub - Actions, and release automation keep the two owners already listed for those - paths, so this addition does not widen the review surface for them. + the addition. **Requirement 2 (review by another current maintainer) was + never satisfied in the form this document describes.** The three commits that + carried the addition (`a2693c02`, `dc3a4ade`, `02bbd47a`) landed on `dev` as + direct owner pushes with no associated pull request, so no second maintainer + reviewed them. Requirement 3 is met by this file and `.github/CODEOWNERS`. + The addition is in effect regardless: @Wibias holds write access on the + repository and has been merging pull requests since 2026-07-26. This entry + records the gap rather than papering over it — a later maintainer change + should go through a reviewed pull request. + + Scope covers issue and pull-request triage, `dev` and `dev2-go` integration, + and provider/CI maintenance. While the Go native port is in transition, + integration duty includes carrying merged `dev` work onto `dev2-go` as + described in the review and merge policy above. Security-boundary ownership + in `.github/CODEOWNERS` is deliberately unchanged: authentication, credential + handling, GitHub Actions, and release automation keep the two owners already + listed for those paths, so this addition does not widen the review surface + for them. CODEOWNERS requests reviews rather than enforcing them — no branch protection rule is configured on this repository, so code-owner approval is a convention diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index ebc1de681..9a2b41894 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -98,6 +98,12 @@ bun run release:watch # watch the newest Release workflow run `dev`; do not open feature pull requests against it. - `preview` — the prerelease train. +While the project moves its primary runtime to the Go native port, `dev2-go` +has to keep receiving everything that lands on `dev`. Nothing changes for you: +keep opening pull requests against `dev`. After a merge, a maintainer rebases +the work onto `dev2-go` and ports whatever needs a Go counterpart under `go/`, +and the item counts as finished only once both lines carry it. + Porting and rebase pull requests are welcome. Carrying a fix from one integration line to another, or rebasing a stale branch onto the current head, is normal contribution rather than noise — note the source commits in the diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 8194e3f54..f0727b0a9 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -90,6 +90,11 @@ bun run release:watch # 直近の Release ワークフロー run 出さないでください。 - `preview` — プレリリーストレイン。 +主要ランタイムを Go ネイティブポートへ移行している間、`dev` に入った変更は `dev2-go` にも +反映される必要があります。コントリビューターの手順は変わりません。これまでどおり `dev` に +PR を出してください。マージしたメンテナーがその作業を `dev2-go` にリベースし、`go/` 側で +対応が必要な部分をポートします。両方のラインに入って初めて完了とみなします。 + ポーティング PR とリベース PR を歓迎します。ある統合ラインの修正を別のラインへ運ぶこと、 古いブランチを現在の head にリベースすることは、ノイズではなく通常の貢献です。説明欄に 元のコミットを記載してください。 diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 2a808bfdd..17a2d6520 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -89,6 +89,11 @@ bun run release:watch # 가장 최근 Release workflow run 감시 올리지 않습니다. - `preview` — 프리릴리즈 트레인. +주 런타임을 Go 네이티브 포트로 옮기는 동안 `dev`에 들어간 변경은 `dev2-go`에도 그대로 +올라가야 합니다. 기여자가 할 일은 달라지지 않습니다. 지금처럼 `dev`로 PR을 올리면 됩니다. +머지한 메인테이너가 그 작업을 `dev2-go` 위로 리베이스하고 `go/` 쪽에 대응이 필요한 부분을 +포팅하며, 두 라인에 모두 반영되어야 작업이 끝난 것으로 봅니다. + 포팅 PR과 리베이스 PR을 환영합니다. 한 통합선의 수정을 다른 쪽으로 옮기거나, 오래된 브랜치를 현재 head 위로 리베이스하는 것은 잡음이 아니라 정상적인 기여입니다. 설명란에 출처 커밋을 적어주세요. diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 8f21c6303..3294e6348 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -91,6 +91,12 @@ bun run release:watch # наблюдение за последни открывайте сюда PR с функциональностью. - `preview` — ветка предрелизов. +Пока проект переводит основной рантайм на нативный порт Go, всё, что попадает в `dev`, +должно попадать и в `dev2-go`. Для контрибьюторов ничего не меняется: продолжайте +открывать pull request'ы в `dev`. После merge мейнтейнер выполняет rebase этой работы на +`dev2-go` и переносит то, чему нужен аналог в `go/`. Задача считается завершённой, только +когда изменение есть в обеих линиях. + Pull request'ы с портированием и ребейзом приветствуются. Перенос исправления между линиями интеграции или ребейз устаревшей ветки на текущий head — это обычный вклад, а не шум. Укажите исходные коммиты в описании. diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index bd7723325..19c5c2154 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -84,6 +84,10 @@ bun run release:watch # 观察最新的 Release workflow run - `main` — 仅用于发布。只有维护者从 `dev` 提升时才会变动,请勿直接提功能 PR。 - `preview` — 预发布通道。 +在主运行时迁移到 Go 原生移植期间,进入 `dev` 的改动同样要进入 `dev2-go`。贡献者的流程 +不变:照常向 `dev` 提 pull request。合并之后,维护者会把这份工作变基到 `dev2-go`,并把 +需要 Go 对应实现的部分移植到 `go/`。只有两条线都带上该改动,这件事才算完成。 + 欢迎移植 PR 和变基 PR。把一条集成线上的修复带到另一条线,或把陈旧分支变基到当前 head, 都是正常的贡献而非噪音。请在描述中注明来源提交。 From a47aa3fecb12bd79c351c3c0002f6c8467407d1e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:48:13 +0200 Subject: [PATCH 03/16] docs: harden and scope agent guidance (#583) Adds nested AGENTS.md files and CODEOWNERS coverage for agent guidance. Keeps the owner Transition intake rule already on dev (merge to dev, carry onto dev2-go, port go/ only when needed). --- .github/AGENTS.md | 26 ++++++++++++++++++++++++++ .github/CODEOWNERS | 2 ++ AGENTS.md | 4 ++++ docs-site/AGENTS.md | 30 ++++++++++++++++++++++++++++++ gui/AGENTS.md | 35 +++++++++++++++++++++++++++++++++++ scripts/AGENTS.md | 27 +++++++++++++++++++++++++++ src/AGENTS.md | 28 ++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+) create mode 100644 .github/AGENTS.md create mode 100644 docs-site/AGENTS.md create mode 100644 scripts/AGENTS.md create mode 100644 src/AGENTS.md diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..fc363e66f --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,26 @@ +# GitHub automation instructions + +This file applies to `.github/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Security boundary + +Every workflow, ownership, branch-enforcement, release, or repository-automation +change requires explicit security review under `MAINTAINERS.md`. + +## Workflow rules + +- Grant the minimum required `permissions`. +- Pin third-party actions to immutable full commit SHAs. +- Preserve the human-readable version comment beside each pinned action. +- Do not run untrusted pull-request code with secrets or write permissions. +- Treat `pull_request_target`, workflow dispatch, reusable workflows, artifacts, caches, and generated command input as trust boundaries. +- Do not broaden triggers, write permissions, token exposure, release eligibility, or publish capability without an explicit task requirement. +- Preserve cross-platform coverage where the workflow currently promises Linux, macOS, and Windows behavior. +- Keep branch-enforcement text synchronized with `AGENTS.md`, `MAINTAINERS.md`, and the public contributing guide. + +## Validation + +- Inspect the complete workflow diff, including event triggers, permissions, conditions, interpolation, and shell behavior. +- Run the local commands represented by changed workflow steps where possible. +- Run `bun run prepush` for CI, release, dependency, packaging, or cross-platform workflow changes. +- Do not claim the workflow itself passed until GitHub Actions reports success for the exact commit. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 556398275..297343edf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,5 +14,7 @@ /src/server/management-api.ts @lidge-jun @Ingwannu # Governance and security policy +/AGENTS.md @lidge-jun @Ingwannu +**/AGENTS.md @lidge-jun @Ingwannu /MAINTAINERS.md @lidge-jun @Ingwannu /SECURITY.md @lidge-jun @Ingwannu diff --git a/AGENTS.md b/AGENTS.md index f1c4fb4d6..4c7c56de9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,12 +16,16 @@ Bun-native TypeScript with no separate server compile step. `tests/helpers/`, broader scenarios in `tests/e2e-style/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. +- `go/` — Go native runtime (primary on the `dev2-go` line during transition). - `structure/` — maintainer invariants and architecture notes; read before changing shared subsystems. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the release authority. - `devlog/` — planning and investigation artifacts (mostly gitignored). +Read the nearest nested `AGENTS.md` before changing files in a scoped +directory (`src/`, `gui/`, `docs-site/`, `scripts/`, `.github/`). + ## Commands ```bash diff --git a/docs-site/AGENTS.md b/docs-site/AGENTS.md new file mode 100644 index 000000000..eb3fa13fa --- /dev/null +++ b/docs-site/AGENTS.md @@ -0,0 +1,30 @@ +# Documentation-site instructions + +This file applies to `docs-site/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Source-of-truth rules + +- `docs-site/` is the public user-documentation source. +- Document current shipped or intentionally pending behavior. Do not copy claims from historical `docs/` or `devlog/` material without verifying them against current code and configuration. +- English documentation is the canonical source. Translated content must not contradict it. +- Keep commands, paths, configuration keys, defaults, branch names, and URLs synchronized with the repository. +- Do not edit generated build output. + +## Editing rules + +- Reuse the existing Astro and Starlight structure, navigation, components, and style. +- Update all directly affected pages when a user workflow changes. +- Do not add duplicated policy text when a stable canonical document can be linked instead. +- Use repository-relative links for repository files and site-relative links for documentation pages where the existing site does so. + +## Required validation + +Run: + +```bash +cd docs-site +bun install --frozen-lockfile +bun run build +``` + +Do not claim documentation validation passed unless this build completes successfully. diff --git a/gui/AGENTS.md b/gui/AGENTS.md index 4216f1543..61459e58b 100644 --- a/gui/AGENTS.md +++ b/gui/AGENTS.md @@ -1,5 +1,14 @@ # OpenCodex GUI — agent rules +This file applies to `gui/` and inherits the repository-wide rules in `/AGENTS.md`. + +## Ownership and generated output + +- `gui/` is the React + Vite dashboard. +- `gui/dist/` is generated packaged output. Do not edit it by hand. +- Use the existing component, state, routing, styling, and data-access patterns before introducing a new abstraction. +- Keep dashboard behavior aligned with the management API and provider configuration model. + ## Text and i18n - **No hardcoded visible UI text** in `src/pages`, `src/components`, `src/App.tsx`, or `src/ui.tsx`. @@ -19,7 +28,33 @@ - Keep **code comments** (including shell `# …` comments in samples). Never strip them to “satisfy” i18n. - Run `bun run lint:i18n` after UI copy changes; fix real violations before committing. If a hit is technical, extend the allowlist or put the string in `
`/`` — do not invent nonsense translation keys.
 
+## Implementation rules
+
+- Preserve accessibility: keyboard operation, labels, focus behavior, semantic controls, and readable validation errors.
+- Do not introduce a dependency for behavior already provided by the current stack or a small local implementation.
+- Dependency changes require explicit security review.
+- Update `docs-site/` when dashboard behavior, setup, or configuration changes for users.
 
 ## Failure mode
 
 Hardcoding English (or German) in JSX to “fix” a bad translation is **not** allowed. Add or fix the key in all locale files instead.
+
+## Required validation
+
+Run all of the following for every functional `gui/` change:
+
+```bash
+cd gui
+bun test tests
+bun run lint
+bun run build
+```
+
+After any UI-copy or locale change, also run:
+
+```bash
+cd gui
+bun run lint:i18n
+```
+
+Run the repository-level checks required by any non-GUI files changed in the same work.
diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md
new file mode 100644
index 000000000..da4c2d0a1
--- /dev/null
+++ b/scripts/AGENTS.md
@@ -0,0 +1,27 @@
+# Script and release-tooling instructions
+
+This file applies to `scripts/` and inherits the repository-wide rules in `/AGENTS.md`.
+
+## Safety boundary
+
+- `scripts/release.ts` is the release authority.
+- Release, publish, version-bump, deployment, and package-signing behavior is maintainer-controlled.
+- Never run `bun run release`, a publish command, or a deployment command unless the task explicitly requires the action and `MAINTAINERS.md` permits it.
+- Changes to release, packaging, dependency-installation, credential, or repository-automation scripts require explicit security review.
+
+## Implementation rules
+
+- Preserve Linux, macOS, and Windows behavior. Do not add shell-specific assumptions to cross-platform scripts.
+- Use explicit paths, deterministic inputs, bounded resource use, and actionable failures.
+- Use atomic replacement for files whose partial write would corrupt configuration, package metadata, release state, or recovery data.
+- Do not log secrets, tokens, request bodies, account identifiers, private paths, or personal data.
+- Do not weaken dry-run, exact-commit, CI-success, or explicit-confirmation gates.
+- Generated package assets must be produced by the owning preparation command, not edited manually.
+
+## Required validation
+
+- Run focused tests or probes for the changed script.
+- Run `bun run typecheck`.
+- Run `bun run privacy:scan` when the script handles configuration, credentials, requests, logs, or account data.
+- Run `bun run prepush` for release, packaging, dependency, or cross-platform tooling changes.
+- Report any platform-specific validation that was not executed.
diff --git a/src/AGENTS.md b/src/AGENTS.md
new file mode 100644
index 000000000..ab7a5fef4
--- /dev/null
+++ b/src/AGENTS.md
@@ -0,0 +1,28 @@
+# Source runtime instructions
+
+This file applies to `src/` and inherits the repository-wide rules in `/AGENTS.md`.
+
+## Runtime and module rules
+
+- `src/` is Bun-native TypeScript in strict mode and uses ES modules only.
+- Do not assume a separate server compilation step.
+- Prefer Bun and Web-platform APIs. Introduce a Node-only runtime dependency only when the task explicitly requires compatibility code and the owning module already has that role.
+- Preserve existing public exports and configuration compatibility unless the task explicitly changes them.
+- Read the applicable documents in `structure/` before changing shared routing, adapters, transports, sidecars, authentication, configuration, or server architecture.
+
+## Implementation rules
+
+- Follow the existing subsystem boundaries and naming patterns.
+- Do not combine unrelated responsibilities to avoid creating another large shared module.
+- Handle asynchronous failures at request, transport, and sidecar boundaries. Optional integrations must degrade through the existing failure representation rather than crash the request path.
+- Provider catalog metadata belongs in the canonical provider registry and derivation flow. Do not duplicate provider facts across independent pickers or seeds.
+- Adapter changes must preserve the internal event contract, streaming behavior, tool calls, cancellation, error mapping, and image handling relevant to that adapter.
+- Authentication, OAuth, token, credential, management API, and CORS changes are security-boundary changes.
+
+## Tests and validation
+
+- Place focused regression coverage near the existing tests for the affected subsystem.
+- For focused behavior, run the relevant `bun test tests/.test.ts` and `bun run typecheck`.
+- For shared routing, adapters, config, OAuth, or server behavior, also run `bun run test`.
+- For logging, requests, credentials, account data, or fixtures, also run `bun run privacy:scan`.
+- Update `docs-site/` when the change affects user-visible behavior or configuration.

From 257c626c66ee0334427b8630258a3915af02f5f1 Mon Sep 17 00:00:00 2001
From: tizerluo 
Date: Wed, 29 Jul 2026 12:51:52 +0800
Subject: [PATCH 04/16] feat(videos): add Grok video bridge for non-OpenAI
 models (#582)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(videos): add Grok video bridge for non-OpenAI models

Extends the image bridge to support asynchronous video generation via xAI
Grok Imagine Video. Video generation uses a submit→poll→download pattern
(POST /v1/videos/generations → GET /v1/videos/{id}) with heartbeat forwarding
to keep the SSE stream alive during the 30-180s generation window.

New files:
- src/images/xai-video-client.ts: submitVideoJob + pollVideoJob
- src/images/fulfill-video.ts: arg parsing, async polling generator, result builder
- tests/videos/: 32 tests (xai-video-client, fulfill-video, plan-video)
- docs-site/.../video-bridge.md: user guide

Modified files:
- src/images/loop.ts: unified image+video fulfillment, per-turn call caps
- src/images/types.ts: VideoBridgePlan/VideoCallResult shared types
- src/images/synthetic-tool.ts: buildVideoTool, VIDEO_GEN_TOOL_NAME
- src/images/plan.ts: planVideoBridge (opt-in via videoBridgeEnabled)
- src/images/artifacts.ts: downloadVideoToArtifact (200MB cap, SSRF-protected)
- src/server/responses/core.ts: video bridge wiring + tool dedup
- src/types.ts: videoGeneration flag, video config fields

Video bridge is opt-in (videoBridgeEnabled defaults to false). All upstream
image-bridge hardening preserved (SSRF, size caps, abort linking, pinned HTTPS).

* fix(videos): address Codex P1/P2 + CodeRabbit review findings

P1 fixes:
- Non-streaming requests no longer 400 when video bridge is enabled
  (only image bridge requires stream=true; video-only skips bridge for
  non-stream requests)
- Restore globalThis.fetch after video client tests (afterEach cleanup)

P2 fixes:
- Use clamped vidPlan.timeoutMs instead of raw config value
- Honor videoMaxRounds when both image+video bridges active (tighter wins)
- guessVideoExtFromMagic throws on unrecognized magic (was defaulting to mp4)
- Poll 4xx permanent failures (except 429) fail fast instead of retrying
- buildVideoResult uses pathToFileURL for cross-platform markdown links
- Initial poll interval restored to 5s (was 200ms)
- done-without-videoUrl returns error instead of spinning until timeout
- VideoBudget charges every streamed chunk (was only first chunk)
- downloadVideoToArtifact: reader cleanup on file-open failure
- readBoundedText: cancel reader body on size cap breach
- Namespaced video_gen tools preserved in dedup filter
- Fix JSON missing closing brace in docs
- Clarify API key auth requirement in docs prerequisites

* fix(videos): encode requestId, defer paidVideoCalls, guard plan! assertion

- Encode requestId in poll URL to prevent path injection (CodeRabbit)
- Move paidVideoCalls++ past arg validation so malformed calls don't burn budget
- Add early guard for undefined plan in image branch, removing non-null assertions

* fix(videos): batch-aware artifact pruning + clarify provider key docs

- Move pruneArtifacts from per-download to post-batch so multi-video turns
  don't delete earlier videos before tool results are injected (Codex P2)
- Document that video bridge requires providers.xai with apiKey authMode,
  not OAuth from 'ocx login xai' (Codex P2)

* fix(videos): web search coexistence, unified timeout budget, VideoBudget ceiling

Address Wibias CHANGES_REQUESTED review:

1. Web search coexistence (P1):
   - When wsPlan is active, media bridge is skipped (existing behavior)
   - Now emits console.warn so the user sees the skip instead of silent loss
   - Documented priority rule in video-bridge.md

2. Timeout budget (P2):
   - Start deadline BEFORE submitVideoJob, not after
   - Poll receives remaining budget (deadline - now), min 5s floor
   - Submit (60s) + poll now share one videoTimeoutMs deadline

3. VideoBudget aggregate ceiling (optional, addressed):
   - VideoBudget now has a cap (600 MiB = 3 × single-download max)
   - chargeVideoBudget() enforces the ceiling per-chunk during streaming
   - Exceeding the budget throws mid-download (partial file is cleaned up)

4. New tests: done-without-videoUrl, permanent 4xx poll stop, requestId encoding

* fix(videos): gate tool_choice on imgPlan, shared deadline signal, buffer magic sniff

Address Wibias second CHANGES_REQUESTED review:

Required:
B. Gate image tool_choice rewriting on imgPlan — video-only turns no longer
   rewrite image_generation/image_gen aliases to an undeclared tool (core.ts)
C. Shared timeout complete — deadline-bound AbortSignal passed into submitVideoJob;
   if budget expired after submit, poll is skipped with timeout error (no 5s floor)

Preferred:
A. Buffer ≥12 bytes before magic-byte sniff — accumulate chunks until sniff minimum
   so short first reads don't crash guessVideoExtFromMagic (artifacts.ts)

Optional:
D. Docs: scope web-search priority to runnable sidecar / non-runTurn path

* fix(videos): pass deadline-bound signal into poll generator

Pass linkedDeadline (not raw client signal) into pollVideoWithHeartbeats so
in-flight pollVideoJob fetches and sleep() calls abort when the wall-clock
budget expires. Abort error messages now distinguish deadline-expiry from
client-cancel based on elapsed time vs timeoutMs threshold.

* fix(videos): deadline abort classification, pruned-path guard, video aliases, DNS noun

Address Wibias fourth CHANGES_REQUESTED review:

1. Deadline abort classification (required #1):
   - Replace 0.95 wall-clock heuristic with signal.reason.name === 'TimeoutError'
   - deadlineSignal declared outside try so catch can check deadlineSignal.aborted
   - Submit catch returns timeout error when deadlineSignal fires (not generic abort)

2. Don't deliver pruned artifact paths (required #2):
   - After batch prune, scan fulfilled results and rewrite any whose path
     no longer exists to ok:false with explanatory error

3. Wire video aliases (required #3):
   - planVideoBridge seeds toolNames with VIDEO_GEN_TOOL_NAME + any request
     function tools matching isVideoGenName (image-bridge parity)
   - core.ts filter only strips unnamespaced video_gen aliases (namespaced
     MCP tools are left alone)

4. Fix test fixtures (required #4):
   - authMode 'api_key' → 'key' (valid OcxProviderConfig value)

5. DNS error noun (small):
   - resolvePublicAddresses accepts optional noun param (default 'image')
   - downloadVideoToArtifact passes 'video'

* fix(videos): complete pruned-path guard, skip namespaced aliases, mock sleep in tests

Address Wibias fifth CHANGES_REQUESTED review:

1. Finish pruned-path guard:
   - Filter files[] through existsSync, not just path
   - If all pruned: ok:false with no markdown/path
   - If some survive: refresh path/files/count/markdown from survivors

2. Skip namespaced tools when seeding videoPlan.toolNames:
   - if (t.namespace) continue — matches core.ts filter rule

3. Fix ubuntu CI timeout:
   - Mock setTimeout in heartbeat test so 5s poll interval resolves instantly
   - Suite now runs in ~156ms (was ~5s)

* fix(videos): stop hanging ubuntu CI with global setTimeout mock

cf76fb1e mocked globalThis.setTimeout to skip the 5s poll sleep. That races
parallel Bun workers and cancelled ubuntu-latest at the 12m job cap
(938f25e was green; cf76fb1e hung). Inject sleep/poll seams instead and drop
mock.module so video tests no longer poison each other.

---------

Co-authored-by: Your Name 
Co-authored-by: Wibias <37517432+Wibias@users.noreply.github.com>
---
 .../src/content/docs/guides/video-bridge.md   |  85 +++++++
 src/images/artifacts.ts                       | 146 ++++++++++-
 src/images/fulfill-video.ts                   | 163 ++++++++++++
 src/images/index.ts                           |   6 +-
 src/images/loop.ts                            | 236 ++++++++++++++----
 src/images/plan.ts                            |  57 ++++-
 src/images/synthetic-tool.ts                  |  45 ++++
 src/images/types.ts                           |  15 ++
 src/images/xai-video-client.ts                | 163 ++++++++++++
 src/lib/destination-policy.ts                 |  14 +-
 src/server/responses/core.ts                  |  69 +++--
 src/types.ts                                  |  10 +
 tests/videos/fulfill-video.test.ts            | 153 ++++++++++++
 tests/videos/plan-video.test.ts               |  80 ++++++
 tests/videos/xai-video-client.test.ts         | 144 +++++++++++
 15 files changed, 1299 insertions(+), 87 deletions(-)
 create mode 100644 docs-site/src/content/docs/guides/video-bridge.md
 create mode 100644 src/images/fulfill-video.ts
 create mode 100644 src/images/xai-video-client.ts
 create mode 100644 tests/videos/fulfill-video.test.ts
 create mode 100644 tests/videos/plan-video.test.ts
 create mode 100644 tests/videos/xai-video-client.test.ts

diff --git a/docs-site/src/content/docs/guides/video-bridge.md b/docs-site/src/content/docs/guides/video-bridge.md
new file mode 100644
index 000000000..4d0c4930f
--- /dev/null
+++ b/docs-site/src/content/docs/guides/video-bridge.md
@@ -0,0 +1,85 @@
+---
+title: Video Bridge
+description: Generate videos with Grok Imagine Video through a non-OpenAI model.
+---
+
+## Overview
+
+The Video Bridge lets you use xAI's Grok Imagine Video generation through any non-OpenAI model
+routed by opencodex. When enabled, a synthetic `video_gen` tool is injected into the conversation.
+The model calls it like any function tool; opencodex intercepts the call, submits a video generation
+job to xAI, polls until completion, and downloads the result.
+
+## Prerequisites
+
+- An `xai` provider entry with an **API key** (`ocx login xai` alone is not sufficient — the video bridge requires key auth, not OAuth)
+- A non-OpenAI model as your routed provider (e.g. Anthropic Claude, Google Gemini)
+- opencodex configured to route through the non-OpenAI provider
+
+> **⚠ Provider key required:** The video bridge only activates when the `xai` provider uses
+> API key auth. Add this to your config:
+>
+> ```json
+> {
+>   "providers": {
+>     "xai": { "adapter": "openai-chat", "apiKey": "xai-…", "authMode": "key" }
+>   }
+> }
+> ```
+>
+> If you onboarded via `ocx login xai` (OAuth), the provider stays in `authMode: "oauth"`
+> and the bridge silently won't activate. Set `XAI_API_KEY` in the environment **or**
+> hard-code the key as shown above.
+
+## Configuration
+
+Add `videoBridgeEnabled: true` to your `images` config:
+
+```json
+{
+  "images": {
+    "bridgeEnabled": true,
+    "videoBridgeEnabled": true,
+    "videoBridgeModel": "grok-imagine-video",
+    "videoMaxRounds": 2,
+    "videoTimeoutMs": 300000
+  }
+}
+```
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `videoBridgeEnabled` | `false` | Master switch. Must be explicitly enabled. |
+| `videoBridgeModel` | `"grok-imagine-video"` | xAI video model id. |
+| `videoMaxRounds` | `2` | Max video-gen rounds before forced final answer. |
+| `videoTimeoutMs` | `300000` (5 min) | Per-video timeout including polling. |
+
+## How It Works
+
+1. opencodex detects a non-OpenAI routed model with `videoBridgeEnabled: true`
+2. A synthetic `video_gen` function tool is injected into the conversation
+3. When the model calls `video_gen`, opencodex submits a job to xAI's `/videos/generations`
+4. The bridge polls the job status every 5-15 seconds, sending heartbeat messages to keep the stream alive
+5. When the video is ready, it's downloaded to the artifacts directory
+6. The local file path is returned to the model as a tool result
+
+## Supported Parameters
+
+The `video_gen` tool accepts:
+
+| Parameter | Type | Range | Description |
+|-----------|------|-------|-------------|
+| `prompt` | string | required | Detailed video generation prompt |
+| `duration` | integer | 1-15 | Video length in seconds |
+| `resolution` | string | `"480p"`, `"720p"` | Video resolution |
+| `aspect_ratio` | string | 7 ratios | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `3:2`, `2:3` |
+
+## Limitations
+
+- **xAI only**: Video generation is only available through xAI's Grok Imagine Video API
+- **Asynchronous**: Video generation takes 30-120 seconds
+- **Cost**: Video generation is a paid xAI feature (~$0.05/sec @480p, ~$0.07/sec @720p)
+- **One video per call**: Each `video_gen` call produces one video
+- **Coexists with Image Bridge**: Both bridges can be enabled simultaneously
+- **Web search priority**: When a web search sidecar is active for a turn (non-`runTurn` adapter), the video bridge is skipped — the two cannot run concurrently. A `console.warn` is emitted so you can detect this in logs.
+- **Timeout covers submit + poll**: The `videoTimeoutMs` budget starts before job submission, so the submit call (60 s) and subsequent polling share the same deadline.
diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts
index d6c887e8a..beceb2e25 100644
--- a/src/images/artifacts.ts
+++ b/src/images/artifacts.ts
@@ -1,5 +1,5 @@
 import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs";
-import { mkdir, writeFile } from "node:fs/promises";
+import { mkdir, writeFile, open, unlink } from "node:fs/promises";
 import type { IncomingMessage } from "node:http";
 import https from "node:https";
 import type { RequestOptions } from "node:https";
@@ -475,3 +475,147 @@ export async function downloadImageToArtifact(
   // Retention is post-batch via pruneArtifacts (see fulfill.ts).
   return writeArtifactUnique(dir, "dl-", bytes, ext);
 }
+
+const MAX_VIDEO_DOWNLOAD_BYTES = 200 * 1024 * 1024; // 200 MiB
+/** Aggregate per-turn video download cap (600 MiB = 3 × max single download). */
+const MAX_VIDEO_BYTES_PER_TURN = MAX_VIDEO_DOWNLOAD_BYTES * 3;
+
+export interface VideoBudget {
+  spent: number;
+  /** Ceiling on total bytes across all downloads this turn. */
+  cap: number;
+}
+
+export function createVideoBudget(): VideoBudget {
+  return { spent: 0, cap: MAX_VIDEO_BYTES_PER_TURN };
+}
+
+/** Charge bytes to the budget; returns false if the ceiling would be exceeded. */
+export function chargeVideoBudget(budget: VideoBudget, bytes: number): boolean {
+  if (budget.spent + bytes > budget.cap) return false;
+  budget.spent += bytes;
+  return true;
+}
+
+export function guessVideoExtFromMagic(bytes: Uint8Array): string {
+  if (bytes.byteLength < 12) throw new Error("video data too short for magic byte sniffing");
+  const sig = Buffer.from(bytes.slice(0, 12)).toString("latin1");
+  // MP4/QuickTime/MOV: bytes 4-7 == "ftyp" (ISO BMFF)
+  if (sig.slice(4, 8) === "ftyp") return "mp4";
+  // WebM/Matroska: \x1a\x45\xdf\xa3
+  if (sig.startsWith("\x1a\x45\xdf\xa3")) return "webm";
+  throw new Error("unrecognized video format — magic bytes do not match MP4 or WebM");
+}
+
+/**
+ * Download a video from a URL to an artifact file with a 200 MiB hard cap, streaming the body
+ * to disk to avoid buffering. SSRF protection reuses the same destination policy + pinned HTTPS
+ * as image downloads. Format is sniffed from magic bytes.
+ */
+export async function downloadVideoToArtifact(
+  url: string,
+  budget?: VideoBudget,
+  signal?: AbortSignal,
+): Promise {
+  // For data: URLs, handle inline (unlikely for video but keep parity)
+  if (url.startsWith("data:")) {
+    const commaIdx = url.indexOf(",");
+    const meta = url.slice(0, commaIdx);
+    const data = url.slice(commaIdx + 1);
+    const isBase64 = meta.includes(";base64");
+    if (!isBase64) throw new Error("non-base64 data URI for video is not supported");
+    const buf = Buffer.from(data, "base64");
+    if (budget && !chargeVideoBudget(budget, buf.byteLength)) {
+      throw new Error("video data URI exceeds per-turn download budget");
+    }
+    if (buf.byteLength > MAX_VIDEO_DOWNLOAD_BYTES) throw new Error("video data URI exceeds size cap");
+    const ext = guessVideoExtFromMagic(buf);
+    const dir = getArtifactsDir();
+    await mkdir(dir, { recursive: true, mode: 0o700 });
+    const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`;
+    const dest = join(dir, name);
+    await writeFile(dest, buf, { mode: 0o600 });
+    return dest;
+  }
+
+  // SSRF protection: same validation as downloadImageToArtifact
+  let parsedUrl: URL;
+  try { parsedUrl = new URL(url); } catch { throw new Error("video URL is not valid"); }
+  if (parsedUrl.protocol !== "https:") {
+    throw new Error(`video URL must use HTTPS, got ${parsedUrl.protocol}`);
+  }
+  const assessment = assessUrlDestination(url);
+  if (assessment && assessment.kind !== "public" && assessment.kind !== "hostname") {
+    throw new Error(`video URL targets ${assessment.detail}`);
+  }
+  const resolved = await resolvePublicAddresses(url, "video");
+  const pinned = pickPinnedAddress(resolved.addresses);
+  const resp = await pinnedHttpsGet(url, pinned, signal, { maxBytes: MAX_VIDEO_DOWNLOAD_BYTES });
+  if (!resp.ok) {
+    try { await resp.body?.cancel(); } catch { /* ignore */ }
+    throw new Error("video download failed: " + resp.status);
+  }
+
+  const dir = getArtifactsDir();
+  await mkdir(dir, { recursive: true, mode: 0o700 });
+
+  const reader = resp.body?.getReader();
+  if (!reader) throw new Error("video download returned no body");
+
+  // Buffer at least 12 bytes for magic-byte sniffing before opening the file.
+  // A single read() can return fewer bytes; accumulate until we have enough.
+  let dest: string | undefined;
+  let fh: { close(): Promise; writeFile(data: Uint8Array): Promise } | undefined;
+  try {
+    const sniffChunks: Uint8Array[] = [];
+    let sniffLen = 0;
+    while (sniffLen < 12) {
+      const { value, done } = await reader.read();
+      if (done) break;
+      if (!value) continue;
+      sniffChunks.push(value);
+      sniffLen += value.byteLength;
+    }
+    if (sniffLen === 0) {
+      throw new Error("video download returned empty body");
+    }
+    const sniffBuf = Buffer.concat(sniffChunks);
+    const ext = guessVideoExtFromMagic(new Uint8Array(sniffBuf));
+    const name = `vid-${timestampPrefix()}-${crypto.randomUUID()}.${ext}`;
+    dest = join(dir, name);
+    fh = await open(dest, "w", 0o600);
+    // Write all buffered chunks and set up accounting
+    let totalBytes = sniffBuf.byteLength;
+    if (budget && !chargeVideoBudget(budget, totalBytes)) {
+      throw new Error("video download exceeds per-turn budget");
+    }
+    if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) {
+      throw new Error("video download exceeds size cap");
+    }
+    await fh.writeFile(new Uint8Array(sniffBuf));
+    for (;;) {
+      const { value, done } = await reader.read();
+      if (done) break;
+      totalBytes += value.byteLength;
+      if (budget && !chargeVideoBudget(budget, value.byteLength)) {
+        throw new Error("video download exceeds per-turn budget");
+      }
+      if (totalBytes > MAX_VIDEO_DOWNLOAD_BYTES) {
+        throw new Error("video download exceeds size cap");
+      }
+      await fh.writeFile(value);
+    }
+    await fh.close();
+    try { await reader.cancel(); } catch { /* ignore */ }
+    reader.releaseLock();
+    return dest;
+  } catch (err) {
+    try { await reader.cancel(); } catch { /* ignore */ }
+    reader.releaseLock();
+    if (fh) {
+      try { await fh.close(); } catch { /* ignore */ }
+    }
+    if (dest) await unlink(dest).catch(() => {});
+    throw err;
+  }
+}
diff --git a/src/images/fulfill-video.ts b/src/images/fulfill-video.ts
new file mode 100644
index 000000000..41bda759a
--- /dev/null
+++ b/src/images/fulfill-video.ts
@@ -0,0 +1,163 @@
+import { pathToFileURL } from "node:url";
+import type { VideoBridgePlan, VideoCallResult } from "./types";
+import { submitVideoJob, pollVideoJob } from "./xai-video-client";
+import { downloadVideoToArtifact, createVideoBudget, type VideoBudget } from "./artifacts";
+
+/** Parsed arguments from the model's video_gen tool call. */
+export interface ParsedVideoArgs {
+  ok: true;
+  prompt: string;
+  duration?: number;
+  resolution?: string;
+  aspectRatio?: string;
+}
+
+const INITIAL_POLL_INTERVAL_MS = 5_000;
+const MAX_POLL_INTERVAL_MS = 15_000;
+const POLL_BACKOFF = 1.5;
+const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; // 5 min
+
+export type SleepFn = (ms: number, signal?: AbortSignal) => Promise;
+export type PollVideoJobFn = (
+  requestId: string,
+  auth: { baseUrl: string; token: string },
+  signal?: AbortSignal,
+) => ReturnType;
+
+export function sleep(ms: number, signal?: AbortSignal): Promise {
+  if (signal?.aborted) return Promise.reject(new Error("aborted"));
+  return new Promise((resolve, reject) => {
+    const onAbort = (): void => { clearTimeout(timer); reject(new Error("aborted")); };
+    const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
+    signal?.addEventListener("abort", onAbort, { once: true });
+  });
+}
+
+/**
+ * Parse the JSON arguments string from a video_gen tool call. Mirrors fulfillImageCall's
+ * defensive parsing: invalid JSON or missing prompt returns an error result.
+ */
+export function parseVideoCallArgs(raw: string):
+  | ParsedVideoArgs
+  | { ok: false; error: string } {
+  let args: unknown;
+  try {
+    args = JSON.parse(raw || "{}");
+  } catch {
+    return { ok: false, error: "invalid arguments JSON" };
+  }
+  if (typeof args !== "object" || args === null) {
+    return { ok: false, error: "invalid arguments JSON" };
+  }
+  const obj = args as Record;
+
+  const prompt =
+    typeof obj.prompt === "string" ? obj.prompt : typeof obj.input === "string" ? obj.input : "";
+  if (!prompt) {
+    return { ok: false, error: "missing prompt" };
+  }
+
+  const result: ParsedVideoArgs = { ok: true, prompt };
+
+  // duration: clamp to 1-15
+  if (typeof obj.duration === "number") {
+    result.duration = Math.max(1, Math.min(15, Math.floor(obj.duration)));
+  }
+
+  if (typeof obj.resolution === "string" && (obj.resolution === "480p" || obj.resolution === "720p")) {
+    result.resolution = obj.resolution;
+  }
+  if (typeof obj.aspect_ratio === "string") {
+    const VALID_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"];
+    if (VALID_RATIOS.includes(obj.aspect_ratio)) result.aspectRatio = obj.aspect_ratio;
+  }
+
+  return result;
+}
+
+/**
+ * Poll a video generation job, yielding heartbeats with elapsed-time messages so the
+ * caller can forward them to the SSE stream. Returns the final result when the job
+ * completes or fails/times out.
+ */
+export async function* pollVideoWithHeartbeats(
+  requestId: string,
+  auth: { baseUrl: string; token: string },
+  signal: AbortSignal,
+  timeoutMs: number = DEFAULT_VIDEO_TIMEOUT_MS,
+  /** Test seams — avoid mock.module / global setTimeout (both poison parallel Bun CI). */
+  sleepFn: SleepFn = sleep,
+  pollFn: PollVideoJobFn = pollVideoJob,
+): AsyncGenerator<
+  { type: "heartbeat"; message: string },
+  { ok: true; videoUrl: string } | { ok: false; error: string }
+> {
+  const start = Date.now();
+  let interval = INITIAL_POLL_INTERVAL_MS;
+  const timeoutStr = `video generation timed out after ${Math.floor(timeoutMs / 1000)}s`;
+
+  for (;;) {
+    if (Date.now() - start >= timeoutMs) {
+      return { ok: false, error: timeoutStr };
+    }
+
+    try {
+      const poll = await pollFn(requestId, auth, signal);
+      if (poll.status === "done") {
+        if (poll.videoUrl) {
+          return { ok: true, videoUrl: poll.videoUrl };
+        }
+        return { ok: false, error: "video generation completed but no video URL was returned" };
+      }
+      if (poll.status === "failed") {
+        return { ok: false, error: "video generation failed" };
+      }
+      if (poll.status === "expired") {
+        return { ok: false, error: "video generation expired" };
+      }
+      // still processing — continue with backoff
+    } catch (e) {
+      // Distinguish deadline-expiry from client-cancel.
+      // AbortSignal.timeout sets signal.reason to a TimeoutError.
+      const isDeadline = signal.aborted && e instanceof Error && e.name === "TimeoutError";
+      if (isDeadline) return { ok: false, error: timeoutStr };
+      if (signal.aborted) return { ok: false, error: "client closed request during video generation" };
+      const status = (e as Error & { status?: number }).status;
+      if (status && status >= 400 && status < 500 && status !== 429) {
+        return { ok: false, error: `video poll failed permanently: HTTP ${status}` };
+      }
+      console.warn(`[videos] poll error (will retry): ${e instanceof Error ? e.message : String(e)}`);
+    }
+
+    const elapsed = Math.floor((Date.now() - start) / 1000);
+    yield { type: "heartbeat", message: `Generating video... ${elapsed}s` };
+
+    try {
+      await sleepFn(interval, signal);
+    } catch {
+      const isDeadline = signal.aborted && signal.reason instanceof Error && signal.reason.name === "TimeoutError";
+      if (isDeadline) return { ok: false, error: timeoutStr };
+      return { ok: false, error: "client closed request during video generation" };
+    }
+
+    interval = Math.min(MAX_POLL_INTERVAL_MS, Math.floor(interval * POLL_BACKOFF));
+  }
+}
+
+/**
+ * Build a success VideoCallResult after the video has been downloaded to disk.
+ */
+export function buildVideoResult(path: string, prompt: string, model: string): VideoCallResult {
+  return {
+    ok: true,
+    model,
+    prompt,
+    path,
+    files: [path],
+    count: 1,
+    markdown: `[video](${pathToFileURL(path).href})`,
+  };
+}
+
+export type { VideoBudget };
+export { createVideoBudget };
diff --git a/src/images/index.ts b/src/images/index.ts
index fc1f2bff1..f9f0fd5b9 100644
--- a/src/images/index.ts
+++ b/src/images/index.ts
@@ -1,4 +1,4 @@
-export { planImageBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan";
+export { planImageBridge, planVideoBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan";
 export { runWithImageBridge, clampImageMaxRounds, DEFAULT_MAX_ROUNDS, MAX_ROUNDS_HARD_LIMIT } from "./loop";
-export type { ImageBridgePlan, ImageCallResult } from "./types";
-export { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, isImageGenName } from "./synthetic-tool";
+export type { ImageBridgePlan, ImageCallResult, VideoBridgePlan, VideoCallResult } from "./types";
+export { buildImageTool, buildVideoTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isImageGenName, isVideoGenName } from "./synthetic-tool";
diff --git a/src/images/loop.ts b/src/images/loop.ts
index e9240f74a..b98e5b9e7 100644
--- a/src/images/loop.ts
+++ b/src/images/loop.ts
@@ -1,16 +1,18 @@
 /**
- * Image bridge agentic loop — adapted from src/web-search/loop.ts but significantly simpler.
+ * Media bridge agentic loop — supports both image and video generation sidecars.
  *
  * The routed (non-OpenAI) model runs in a bounded loop. Each iteration is streamed and fully
- * buffered internally. If the model calls an image-generation tool, the bridge fulfills it via
- * the xAI sidecar, injects the result as a tool_result, and loops (bounded by maxRounds). When
- * the model produces a real tool call or the budget is exhausted, the passthrough events are
- * replayed to the bridge for final SSE output.
+ * buffered internally. If the model calls an image-generation or video-generation tool, the
+ * bridge fulfills it via the xAI sidecar, injects the result as a tool_result, and loops
+ * (bounded by maxRounds). When the model produces a real tool call or the budget is exhausted,
+ * the passthrough events are replayed to the bridge for final SSE output.
  *
  * Removed vs web-search: no sidecar backend selection, no forced-answer nudge, no failed-query
  * dedup, no describeImages/structuredOutput, no recordSidecarOutcome.
  */
 import type { AdapterRequest, ProviderAdapter } from "../adapters/base";
+import { existsSync } from "node:fs";
+import { pathToFileURL } from "node:url";
 import { createAdapterEventQueue } from "../adapters/run-turn-queue";
 import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage } from "../types";
 import { namespacedToolName } from "../types";
@@ -20,9 +22,11 @@ import { readBoundedResponseBody } from "../lib/bounded-body";
 import { fetchWithResetRetry } from "../lib/upstream-retry";
 import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "../web-search/progress-stream";
 import { fulfillImageCall } from "./fulfill";
-import { createImageBudget } from "./artifacts";
-import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool";
-import type { ImageBridgePlan } from "./types";
+import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult, createVideoBudget } from "./fulfill-video";
+import { submitVideoJob } from "./xai-video-client";
+import { downloadVideoToArtifact, createImageBudget, pruneArtifacts } from "./artifacts";
+import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "./synthetic-tool";
+import type { ImageBridgePlan, VideoBridgePlan } from "./types";
 
 const SSE_HEADERS = {
   "Content-Type": "text/event-stream",
@@ -36,8 +40,10 @@ const STALL_TIMEOUT_MS = 200_000;
 export const DEFAULT_MAX_ROUNDS = 3;
 /** Absolute ceiling so a hand-edited `images.maxRounds: 10000` cannot unbound paid xAI calls. */
 export const MAX_ROUNDS_HARD_LIMIT = 10;
-/** Cap paid xAI fulfillments per turn (parallel calls in one round count separately). */
+/** Cap paid xAI image fulfillments per turn (parallel calls in one round count separately). */
 export const MAX_IMAGE_CALLS_PER_TURN = 10;
+/** Cap paid xAI video fulfillments per turn (video is slower/costlier than image). */
+export const MAX_VIDEO_CALLS_PER_TURN = 3;
 
 /**
  * Clamp a configured maxRounds value to a safe integer in [0, MAX_ROUNDS_HARD_LIMIT].
@@ -48,23 +54,27 @@ export function clampImageMaxRounds(value: unknown): number {
   return Math.max(0, Math.min(MAX_ROUNDS_HARD_LIMIT, Math.floor(value)));
 }
 
-/** Drop image-specific tool_choice when image tools are stripped for a forced-final pass. */
-function stripImageToolChoice(
+/** Drop image/video-specific tool_choice when media tools are stripped for a forced-final pass. */
+function stripMediaToolChoice(
   options: OcxRequestOptions,
-  plan: ImageBridgePlan,
+  plan?: ImageBridgePlan,
+  videoPlan?: VideoBridgePlan,
 ): OcxRequestOptions {
   const tc = options.toolChoice;
   if (!tc || typeof tc !== "object") return options;
+  const isMediaTool = (name: string): boolean =>
+    name === IMAGE_GEN_TOOL_NAME ||
+    name === VIDEO_GEN_TOOL_NAME ||
+    (plan?.toolNames.has(name) ?? false) ||
+    (videoPlan?.toolNames.has(name) ?? false);
   if ("name" in tc && typeof tc.name === "string") {
-    if (tc.name === IMAGE_GEN_TOOL_NAME || plan.toolNames.has(tc.name)) {
+    if (isMediaTool(tc.name)) {
       return { ...options, toolChoice: "auto" };
     }
     return options;
   }
   if ("allowedTools" in tc && Array.isArray(tc.allowedTools)) {
-    const filtered = tc.allowedTools.filter(
-      (name) => name !== IMAGE_GEN_TOOL_NAME && !plan.toolNames.has(name),
-    );
+    const filtered = tc.allowedTools.filter(name => !isMediaTool(name));
     if (filtered.length === tc.allowedTools.length) return options;
     if (filtered.length === 0) return { ...options, toolChoice: "auto" };
     return { ...options, toolChoice: { ...tc, allowedTools: filtered } };
@@ -190,7 +200,10 @@ class LoopError extends Error {
 export interface ImageBridgeDeps {
   parsed: OcxParsedRequest;
   adapter: ProviderAdapter;
-  plan: ImageBridgePlan;
+  plan?: ImageBridgePlan;
+  videoPlan?: VideoBridgePlan;
+  /** Per-video generation timeout (ms) including polling. */
+  videoTimeoutMs?: number;
   /** Headers forwarded from the original request (e.g. Codex auth). Cloned per iteration. */
   forwardHeaders?: Headers;
   /** Called before each routed-model dispatch in the bridge loop, for attempt telemetry. */
@@ -227,7 +240,7 @@ export interface ImageBridgeDeps {
  * inject the answer as a tool_result, and loop (bounded by `maxRounds`).
  */
 export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {
-  const { parsed, plan, abortSignal } = deps;
+  const { parsed, plan, videoPlan, videoTimeoutMs, abortSignal } = deps;
   let adapter = deps.adapter;
   const maxRounds = clampImageMaxRounds(deps.maxRounds ?? DEFAULT_MAX_ROUNDS);
   const HARD_CAP = maxRounds + 1;
@@ -239,6 +252,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {
@@ -275,17 +289,24 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {
+  // Merge tool names from both plans for event scanning.
+  const mediaToolNames = new Set();
+  if (plan) for (const n of plan.toolNames) mediaToolNames.add(n);
+  if (videoPlan) for (const n of videoPlan.toolNames) mediaToolNames.add(n);
+  // Forced-final must strip every image/video-generation alias the plans know about — not only
+  // tools flagged `imageGeneration:true` or `videoGeneration:true`. Hosted `image_generation` /
+  // function aliases would otherwise remain callable; scanEventsForImageCall would strip the
+  // call while forceFinal blocks fulfillment, leaving the client an empty completion.
+  const toolsNoMedia = allTools.filter(t => {
     if (t.imageGeneration) return false;
-    if (plan.toolNames.has(t.name)) return false;
-    if (t.namespace && plan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
+    if (t.videoGeneration) return false;
+    if (plan && plan.toolNames.has(t.name)) return false;
+    if (plan && t.namespace && plan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
+    if (videoPlan && videoPlan.toolNames.has(t.name)) return false;
     return true;
   });
   const budget = createImageBudget();
+  const vBudget = createVideoBudget();
 
   // Link an internal AbortController to the turn signal so a client cancel of the SSE body aborts
   // in-flight model fetches AND the sidecar.
@@ -310,8 +331,8 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise>; args: Record }> = [];
           for (const call of split.calls) {
-            yield { type: "heartbeat" };
-            let result: Awaited>;
-            if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) {
-              result = {
-                ok: false,
-                model: plan.model,
-                prompt: "",
-                files: [],
-                count: 0,
-                error: `image call budget exhausted (max ${MAX_IMAGE_CALLS_PER_TURN} per turn)`,
-              };
+            const isVideoCall = videoPlan?.toolNames.has(call.name) === true;
+            if (isVideoCall) {
+              yield { type: "heartbeat" };
+              if (paidVideoCalls >= MAX_VIDEO_CALLS_PER_TURN) {
+                const vResult = {
+                  ok: false, model: videoPlan!.model, prompt: "", files: [], count: 0,
+                  error: `video call budget exhausted (max ${MAX_VIDEO_CALLS_PER_TURN} per turn)`,
+                };
+                let pArgs: Record = {};
+                try { const raw: unknown = JSON.parse(call.args || "{}"); if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) pArgs = raw as Record; } catch { /* malformed args */ }
+                fulfilled.push({ call, result: vResult, args: pArgs });
+                continue;
+              }
+              const vArgs = parseVideoCallArgs(call.args);
+              let vResult;
+              if (!vArgs.ok) {
+                vResult = { ok: false, model: videoPlan!.model, prompt: "", files: [], count: 0, error: vArgs.error };
+              } else {
+                paidVideoCalls += 1;
+                const videoTimeout = videoTimeoutMs ?? 300_000;
+                const deadlineSignal = AbortSignal.timeout(videoTimeout);
+                try {
+                  const videoDeadline = Date.now() + videoTimeout;
+                  // Bind submit to the shared deadline so submit + poll fit one budget.
+                  const linkedDeadline = signal
+                    ? AbortSignal.any([signal, deadlineSignal])
+                    : deadlineSignal;
+                  const { requestId } = await submitVideoJob(
+                    {
+                      prompt: vArgs.prompt, model: videoPlan!.model,
+                      ...(vArgs.duration != null ? { duration: vArgs.duration } : {}),
+                      ...(vArgs.resolution != null ? { resolution: vArgs.resolution } : {}),
+                      ...(vArgs.aspectRatio != null ? { aspectRatio: vArgs.aspectRatio } : {}),
+                    },
+                    videoPlan!.auth, linkedDeadline,
+                  );
+                  const remainingMs = videoDeadline - Date.now();
+                  if (remainingMs <= 0) {
+                    vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt, files: [], count: 0, error: `video generation timed out after ${Math.floor(videoTimeout / 1000)}s` };
+                  } else {
+                  const pollGen = pollVideoWithHeartbeats(requestId, videoPlan!.auth, linkedDeadline, remainingMs);
+                  let pollResult: { ok: true; videoUrl: string } | { ok: false; error: string };
+                  try {
+                    for (;;) {
+                      const { value, done } = await pollGen.next();
+                      if (done) { pollResult = value; break; }
+                      yield { type: "heartbeat" };
+                    }
+                  } finally {
+                    await pollGen.return({ ok: false, error: "cancelled" }).catch(() => {});
+                  }
+                  if (signal.aborted) throw new LoopError(499, "client closed request during video-bridge");
+                  if (pollResult.ok) {
+                    const dlPath = await downloadVideoToArtifact(pollResult.videoUrl, vBudget, signal);
+                    vResult = buildVideoResult(dlPath, vArgs.prompt, videoPlan!.model);
+                  } else {
+                    vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt, files: [], count: 0, error: pollResult.error };
+                  }
+                  }
+                } catch (e) {
+                  if (deadlineSignal.aborted && !signal.aborted) {
+                    vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt ?? "", files: [], count: 0, error: `video generation timed out after ${Math.floor(videoTimeout / 1000)}s` };
+                  } else if (signal.aborted) {
+                    throw new LoopError(499, "client closed request during video-bridge");
+                  } else {
+                    const error = e instanceof Error ? e.message : String(e);
+                    vResult = { ok: false, model: videoPlan!.model, prompt: vArgs.prompt ?? "", files: [], count: 0, error };
+                  }
+                }
+              }
+              if (signal.aborted) throw new LoopError(499, "client closed request during video-bridge");
+              let vParsedArgs: Record = {};
+              try { const raw: unknown = JSON.parse(call.args || "{}"); if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) vParsedArgs = raw as Record; } catch { /* malformed args */ }
+              fulfilled.push({ call, result: vResult, args: vParsedArgs });
             } else {
-              paidImageCalls += 1;
-              result = await fulfillImageCall(
-                { id: call.id, name: call.name, arguments: call.args },
-                plan, budget, signal,
-              );
+              yield { type: "heartbeat" };
+              if (!plan) {
+                fulfilled.push({ call, result: { ok: false, model: "", prompt: "", files: [], count: 0, error: "image bridge not configured" }, args: {} });
+                continue;
+              }
+              let result: Awaited>;
+              if (paidImageCalls >= MAX_IMAGE_CALLS_PER_TURN) {
+                result = {
+                  ok: false,
+                  model: plan.model,
+                  prompt: "",
+                  files: [],
+                  count: 0,
+                  error: `image call budget exhausted (max ${MAX_IMAGE_CALLS_PER_TURN} per turn)`,
+                };
+              } else {
+                paidImageCalls += 1;
+                result = await fulfillImageCall(
+                  { id: call.id, name: call.name, arguments: call.args },
+                  plan, budget, signal,
+                );
+              }
+              if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
+              let parsedArgs: Record = {};
+              try {
+                const raw: unknown = JSON.parse(call.args || "{}");
+                if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
+                  parsedArgs = raw as Record;
+                }
+              } catch { /* malformed args */ }
+              fulfilled.push({ call, result, args: parsedArgs });
             }
-            if (signal.aborted) throw new LoopError(499, "client closed request during image-bridge");
-            let parsedArgs: Record = {};
-            try {
-              const raw: unknown = JSON.parse(call.args || "{}");
-              if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
-                parsedArgs = raw as Record;
+          }
+          // Prune artifacts once after the entire batch so a tight keepCount
+          // cannot delete a video from an earlier call in this same iteration.
+          pruneArtifacts(videoPlan?.artifactsKeepCount ?? plan?.artifactsKeepCount);
+          // Drop results whose artifact files were pruned — never hand the model a dead path.
+          for (const f of fulfilled) {
+            if (!f.result.ok || !f.result.files || f.result.files.length === 0) continue;
+            const survivors = f.result.files.filter(p => existsSync(p));
+            if (survivors.length === f.result.files.length) continue; // nothing pruned
+            if (survivors.length === 0) {
+              f.result = {
+                ok: false, model: f.result.model, prompt: f.result.prompt ?? "",
+                files: [], count: 0,
+                error: "artifact was pruned before delivery (increase artifactsKeepCount)",
+              } as typeof f.result;
+            } else {
+              // Some files survived — refresh from survivors
+              f.result = { ...f.result, files: survivors, count: survivors.length };
+              const primary = survivors[0]!;
+              (f.result as { path?: string }).path = primary;
+              if ("markdown" in f.result && f.result.markdown) {
+                // Image markdown references the primary path; video uses pathToFileURL
+                if (f.result.markdown.startsWith("![")) {
+                  (f.result as { markdown: string }).markdown = `![image](${pathToFileURL(primary).href})`;
+                } else {
+                  (f.result as { markdown: string }).markdown = `[video](${pathToFileURL(primary).href})`;
+                }
               }
-            } catch { /* malformed args */ }
-            fulfilled.push({ call, result, args: parsedArgs });
+            }
           }
           const now = Date.now();
           messages.push({
diff --git a/src/images/plan.ts b/src/images/plan.ts
index ce59a67e5..b706b5756 100644
--- a/src/images/plan.ts
+++ b/src/images/plan.ts
@@ -1,8 +1,8 @@
 import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
-import type { ImageBridgePlan } from "./types";
+import type { ImageBridgePlan, VideoBridgePlan } from "./types";
 import { resolveEnvValue } from "../config";
 import { getProviderRegistryEntry } from "../providers/registry";
-import { IMAGE_GEN_TOOL_NAME } from "./synthetic-tool";
+import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isVideoGenName } from "./synthetic-tool";
 
 const DEFAULT_MODEL = "grok-imagine-image-quality";
 /** Absolute ceiling for `images.timeoutMs` (matches /v1/images relay budget). */
@@ -78,3 +78,56 @@ export async function planImageBridge(
     ...(artifactsKeepCount !== undefined ? { artifactsKeepCount } : {}),
   };
 }
+
+const DEFAULT_VIDEO_MODEL = "grok-imagine-video";
+
+/**
+ * Decide whether the video bridge should activate for this request. Unlike images, video
+ * generation has no hosted OpenAI tool type — the synthetic `video_gen` tool is unconditionally
+ * injected when `videoBridgeEnabled` is true. The bridge activates only when:
+ *   1. videoBridgeEnabled is explicitly true (opt-in)
+ *   2. the routed provider is NOT api.openai.com (native passthrough)
+ *   3. an xAI provider with a valid API key is available
+ */
+export async function planVideoBridge(
+  config: OcxConfig,
+  parsed: OcxParsedRequest,
+  routedProvider: OcxProviderConfig,
+): Promise {
+  if (config.images?.videoBridgeEnabled !== true) return undefined;
+  // Don't intercept for OpenAI native passthrough
+  const host = (() => { try { return new URL(routedProvider.baseUrl).hostname; } catch { return ""; } })();
+  if (host === "api.openai.com") return undefined;
+  const found = findXaiProvider(config);
+  if (!found) return undefined;
+  const token = resolveXaiImageApiKey(found.provider);
+  if (!token) return undefined;
+  // Pin the baseUrl to the registry entry, ignoring any config-level baseUrl override.
+  const registryEntry = getProviderRegistryEntry("xai");
+  const pinnedBaseUrl = (registryEntry?.baseUrl ?? "https://api.x.ai/v1").replace(/\/+$/, "");
+  const toolNames = new Set();
+  toolNames.add(VIDEO_GEN_TOOL_NAME);
+  // Collect any existing function tools whose name matches a video_gen alias
+  // so the loop can intercept and replace them (image-bridge parity).
+  for (const t of parsed.context?.tools ?? []) {
+    // Skip namespaced tools — a namespaced MCP video_gen must not be intercepted.
+    if (t.namespace) continue;
+    const fnName = typeof t.name === "string" ? t.name
+      : (t as unknown as { function?: { name?: string } }).function?.name;
+    if (typeof fnName === "string" && isVideoGenName(fnName)) {
+      toolNames.add(fnName);
+    }
+  }
+  const timeoutMs = clampImageTimeoutMs(config.images?.videoTimeoutMs);
+  const keepRaw = config.images?.artifactsKeepCount;
+  const artifactsKeepCount =
+    typeof keepRaw === "number" && Number.isFinite(keepRaw) ? Math.floor(keepRaw) : undefined;
+  return {
+    provider: found.provider,
+    auth: { baseUrl: pinnedBaseUrl, token },
+    model: config.images?.videoBridgeModel ?? DEFAULT_VIDEO_MODEL,
+    toolNames,
+    ...(timeoutMs !== undefined ? { timeoutMs } : {}),
+    ...(artifactsKeepCount !== undefined ? { artifactsKeepCount } : {}),
+  };
+}
diff --git a/src/images/synthetic-tool.ts b/src/images/synthetic-tool.ts
index 8ba1fbda4..84ac536ab 100644
--- a/src/images/synthetic-tool.ts
+++ b/src/images/synthetic-tool.ts
@@ -86,3 +86,48 @@ export function buildImageTool(): OcxTool {
     imageGeneration: true,
   };
 }
+
+/** The function name the chat model sees for video generation. */
+export const VIDEO_GEN_TOOL_NAME = "video_gen";
+
+const VIDEO_GEN_NAMES = new Set([
+  "video_gen",
+  "video_generation",
+  "videogen",
+  "generate_video",
+  "generatevideo",
+]);
+
+export function isVideoGenName(name: string): boolean {
+  return VIDEO_GEN_NAMES.has(name.toLowerCase());
+}
+
+/**
+ * The synthetic function tool exposed to a chat/anthropic model for video generation.
+ * Unlike image_generation, there is no hosted OpenAI tool type for video — this tool is
+ * unconditionally injected when the video bridge is enabled. `videoGeneration:true` flags it
+ * so the forced-final pass can drop it.
+ */
+export function buildVideoTool(): OcxTool {
+  return {
+    name: VIDEO_GEN_TOOL_NAME,
+    description:
+      "Generate a short video (1-15 seconds) from a text prompt. Returns an absolute local filesystem path. " +
+      "Use when the user asks to create, animate, or generate a video.",
+    parameters: {
+      type: "object",
+      properties: {
+        prompt: { type: "string", description: "Detailed video generation prompt. Required." },
+        duration: { type: "integer", minimum: 1, maximum: 15, description: "Video length in seconds. Default 6." },
+        resolution: { type: "string", enum: ["480p", "720p"], description: "Video resolution. Default 720p." },
+        aspect_ratio: {
+          type: "string",
+          enum: ["16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3"],
+          description: "Aspect ratio. Default 16:9.",
+        },
+      },
+      required: ["prompt"],
+    },
+    videoGeneration: true,
+  };
+}
diff --git a/src/images/types.ts b/src/images/types.ts
index e72177746..d9176aacb 100644
--- a/src/images/types.ts
+++ b/src/images/types.ts
@@ -24,3 +24,18 @@ export interface ImageCallResult {
   markdown?: string;
   error?: string;
 }
+
+/** Plan for the video bridge. Same auth/provider shape as image, without image-specific defaults. */
+export interface VideoBridgePlan {
+  provider: OcxProviderConfig;
+  auth: { baseUrl: string; token: string };
+  model: string;
+  toolNames: Set;
+  /** Per-call xAI deadline (ms) for submit + poll. */
+  timeoutMs?: number;
+  /** Max artifact files to retain (from config.images.artifactsKeepCount). Default 200. ≤0 disables prune. */
+  artifactsKeepCount?: number;
+}
+
+/** Result shape for video fulfillment — same as image. */
+export type VideoCallResult = ImageCallResult;
diff --git a/src/images/xai-video-client.ts b/src/images/xai-video-client.ts
new file mode 100644
index 000000000..1a9843341
--- /dev/null
+++ b/src/images/xai-video-client.ts
@@ -0,0 +1,163 @@
+/**
+ * xAI video generation client.
+ *
+ * Video generation is asynchronous: a POST to `/videos/generations` returns a `request_id`,
+ * which is then polled via GET `/videos/{request_id}` until the status is terminal.
+ * The submit call uses a 60 s timeout (it only returns an ID); the poll call uses 30 s.
+ * Non-2xx responses throw with the original status code so callers can distinguish
+ * rate-limit / auth failures from transient errors.
+ */
+
+export interface XaiVideoSubmitRequest {
+  prompt: string;
+  model?: string; // default "grok-imagine-video"
+  duration?: number; // 1-15 seconds
+  resolution?: string; // "480p" | "720p"
+  aspectRatio?: string; // "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3"
+}
+
+export interface XaiVideoSubmitResult {
+  requestId: string;
+}
+
+export interface XaiVideoPollResult {
+  status: "processing" | "done" | "failed" | "expired";
+  videoUrl?: string;
+  progress?: number;
+}
+
+const SUBMIT_TIMEOUT_MS = 60_000;
+const POLL_TIMEOUT_MS = 30_000;
+const DEFAULT_VIDEO_MODEL = "grok-imagine-video";
+
+/** Maximum response body size for the submit and poll calls (responses are small JSON). */
+const MAX_RESPONSE_BYTES = 1024 * 1024; // 1 MiB — these endpoints return small JSON
+
+/**
+ * Read an HTTP response body as text with a hard byte cap. Used for the non-streaming
+ * video submit/poll endpoints.
+ */
+async function readBoundedText(resp: Response): Promise {
+  const reader = resp.body?.getReader();
+  if (!reader) throw new Error("xAI video API returned no body");
+  const decoder = new TextDecoder();
+  let text = "";
+  let totalBytes = 0;
+  try {
+    for (;;) {
+      const { value, done } = await reader.read();
+      if (done) break;
+      totalBytes += value.byteLength;
+      if (totalBytes > MAX_RESPONSE_BYTES) throw new Error("xAI video API response exceeds size cap");
+      text += decoder.decode(value, { stream: true });
+    }
+    text += decoder.decode();
+  } finally {
+    try { await reader.cancel(); } catch { /* ignore */ }
+    reader.releaseLock();
+  }
+  return text;
+}
+
+/**
+ * Submit a video generation job to xAI. Returns the `request_id` used for polling.
+ */
+export async function submitVideoJob(
+  req: XaiVideoSubmitRequest,
+  auth: { baseUrl: string; token: string },
+  signal?: AbortSignal,
+): Promise {
+  const body: Record = {
+    model: req.model ?? DEFAULT_VIDEO_MODEL,
+    prompt: req.prompt,
+  };
+  if (typeof req.duration === "number") body.duration = req.duration;
+  if (typeof req.resolution === "string") body.resolution = req.resolution;
+  if (typeof req.aspectRatio === "string") body.aspect_ratio = req.aspectRatio;
+
+  const timeout = AbortSignal.timeout(SUBMIT_TIMEOUT_MS);
+  const linkedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
+
+  const resp = await fetch(`${auth.baseUrl}/videos/generations`, {
+    method: "POST",
+    headers: {
+      "Authorization": `Bearer ${auth.token}`,
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify(body),
+    signal: linkedSignal,
+  });
+
+  if (!resp.ok) {
+    try { await resp.body?.cancel(); } catch { /* ignore */ }
+    const err = new Error("xAI videos API returned " + resp.status) as Error & { status: number };
+    err.status = resp.status;
+    throw err;
+  }
+
+  const text = await readBoundedText(resp);
+  const json = JSON.parse(text) as { request_id?: string; id?: string };
+  const requestId = json.request_id ?? json.id;
+  if (typeof requestId !== "string") {
+    throw new Error("xAI videos API did not return a request_id");
+  }
+  return { requestId };
+}
+
+/**
+ * Poll the status of a video generation job. Call this repeatedly with backoff until
+ * `status` is `"done"` (video ready) or a terminal failure state.
+ */
+export async function pollVideoJob(
+  requestId: string,
+  auth: { baseUrl: string; token: string },
+  signal?: AbortSignal,
+): Promise {
+  const timeout = AbortSignal.timeout(POLL_TIMEOUT_MS);
+  const linkedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
+
+  const resp = await fetch(`${auth.baseUrl}/videos/${encodeURIComponent(requestId)}`, {
+    method: "GET",
+    headers: {
+      "Authorization": `Bearer ${auth.token}`,
+    },
+    signal: linkedSignal,
+  });
+
+  if (!resp.ok) {
+    try { await resp.body?.cancel(); } catch { /* ignore */ }
+    const err = new Error("xAI videos poll API returned " + resp.status) as Error & { status: number };
+    err.status = resp.status;
+    throw err;
+  }
+
+  const text = await readBoundedText(resp);
+  const json = JSON.parse(text) as {
+    status?: string;
+    state?: string;
+    video?: { url?: string };
+    videos?: Array<{ url?: string }>;
+    progress?: number;
+  };
+
+  // Normalize status — xAI uses "done"/"processing"/"failed"/"expired" but be lenient.
+  const rawStatus = (json.status ?? json.state ?? "").toLowerCase();
+  let status: XaiVideoPollResult["status"];
+  if (rawStatus === "done" || rawStatus === "completed" || rawStatus === "succeeded") {
+    status = "done";
+  } else if (rawStatus === "failed" || rawStatus === "error") {
+    status = "failed";
+  } else if (rawStatus === "expired" || rawStatus === "timeout") {
+    status = "expired";
+  } else {
+    status = "processing";
+  }
+
+  const videoUrl = json.video?.url ?? json.videos?.[0]?.url;
+
+  return {
+    status,
+    ...(typeof videoUrl === "string" ? { videoUrl } : {}),
+    ...(typeof json.progress === "number" ? { progress: json.progress } : {}),
+  };
+}
diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts
index b2731df68..c2fec9731 100644
--- a/src/lib/destination-policy.ts
+++ b/src/lib/destination-policy.ts
@@ -219,7 +219,7 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu
  * so callers can pin the connect peer and avoid a second, rebindable resolution.
  * DNS resolution failures are treated as unsafe (fail-closed).
  */
-export async function resolvePublicAddresses(url: string): Promise<{
+export async function resolvePublicAddresses(url: string, noun: string = "image"): Promise<{
   hostname: string;
   addresses: { address: string; family: number }[];
 }> {
@@ -227,12 +227,12 @@ export async function resolvePublicAddresses(url: string): Promise<{
   try {
     hostname = normalizeHostname(new URL(url.trim()).hostname);
   } catch {
-    throw new Error("image URL is not a valid URL");
+    throw new Error(`${noun} URL is not a valid URL`);
   }
-  if (!hostname) throw new Error("image URL has no hostname");
+  if (!hostname) throw new Error(`${noun} URL has no hostname`);
   const literalAssessment = assessDestination(url);
   if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") {
-    throw new Error(`image URL targets ${literalAssessment.detail}`);
+    throw new Error(`${noun} URL targets ${literalAssessment.detail}`);
   }
   // Literal public IPs: no DNS round-trip; pin the literal itself.
   const literalKind = isIP(hostname);
@@ -245,10 +245,10 @@ export async function resolvePublicAddresses(url: string): Promise<{
   } catch {
     // If DNS fails, we can't verify — fail-closed (unlike provider config-time validation,
     // this is a runtime fetch to an untrusted URL, so be conservative).
-    throw new Error(`image URL hostname ${hostname} could not be resolved`);
+    throw new Error(`${noun} URL hostname ${hostname} could not be resolved`);
   }
   if (addresses.length === 0) {
-    throw new Error(`image URL hostname ${hostname} could not be resolved`);
+    throw new Error(`${noun} URL hostname ${hostname} could not be resolved`);
   }
   const publicAddresses: { address: string; family: number }[] = [];
   for (const { address, family } of addresses) {
@@ -257,7 +257,7 @@ export async function resolvePublicAddresses(url: string): Promise<{
     const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0);
     const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
     if (!assessment || assessment.kind !== "public") {
-      throw new Error(`image URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`);
+      throw new Error(`${noun} URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`);
     }
     publicAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
   }
diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts
index 95b56dc62..819ce3427 100644
--- a/src/server/responses/core.ts
+++ b/src/server/responses/core.ts
@@ -52,7 +52,7 @@ import {
   rotateAnthropicAccountOn429,
 } from "../../oauth/anthropic-routing";
 import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
-import { buildImageTool, planImageBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME } from "../../images";
+import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
 import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
 import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
 import {
@@ -1756,48 +1756,74 @@ export async function handleResponses(
     ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
     : undefined;
   const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
+  const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
   const canRunWebSearch = !!wsPlan && !adapter.runTurn;
-  if (imgPlan && (!wsPlan || adapter.runTurn)) {
-    // The bridge forces stream:true internally and returns SSE. Non-streaming requests can't be
-    // served — reject explicitly rather than returning SSE to a client expecting JSON.
+  if ((imgPlan || vidPlan) && canRunWebSearch) {
+    // Web search takes priority when both are active — the media bridge cannot run
+    // alongside runWithWebSearch. Surface a runtime signal so the user knows their
+    // configured video/image bridge was skipped for this turn, rather than silently
+    // dropping a paid capability.
+    if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
+    if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
+  }
+  if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
+    // The image bridge detects a hosted image_generation tool and requires streaming.
+    // The video bridge activates from config and injects a tool — it also needs streaming
+    // (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
+    // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
     if (!parsed.stream) {
-      return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
-    }
-    // Replace any pre-existing image_gen alias instead of appending a duplicate wire name.
+      if (imgPlan) {
+        return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
+      }
+      // Video-only: skip bridge for non-streaming requests
+    } else {
+    // Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
     const priorTools = parsed.context.tools ?? [];
-    parsed.context.tools = [
-      ...priorTools.filter(t => {
-        if (t.imageGeneration) return false;
-        if (imgPlan.toolNames.has(t.name)) return false;
-        if (t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
-        return true;
-      }),
-      buildImageTool(),
-    ];
+    const bridgeTools = [...priorTools.filter(t => {
+      if (t.imageGeneration) return false;
+      if (t.videoGeneration) return false;
+      if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
+      if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
+      // Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
+      if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
+      return true;
+    })];
+    const existingNames = new Set(bridgeTools.map(t => t.name));
+    if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
+    if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
+    parsed.context.tools = bridgeTools;
     // Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
+    // Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
+    // image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
     const tc = parsed.options.toolChoice;
-    if (tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
+    if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
       const mapped = tc.allowedTools.map(name =>
-        name === "image_generation" || name === "image_gen" || imgPlan.toolNames.has(name)
+        name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
           ? IMAGE_GEN_TOOL_NAME
           : name,
       );
       parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
-    } else if (tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
+    } else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
       && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
       parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
     }
     const imgResponse = await runWithImageBridge({
       parsed, adapter,
-      plan: imgPlan,
+      ...(imgPlan ? { plan: imgPlan } : {}),
+      ...(vidPlan ? { videoPlan: vidPlan } : {}),
       forwardHeaders: selectedForwardHeaders,
       onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens),
       abortSignal: options.abortSignal,
-      maxRounds: clampImageMaxRounds(config.images?.maxRounds),
+      maxRounds: imgPlan && vidPlan
+        ? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
+        : imgPlan
+          ? clampImageMaxRounds(config.images?.maxRounds)
+          : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
       connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
       stallTimeoutSec: config.stallTimeoutSec,
       fetchImpl: providerFetch(route.provider),
       onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
+      ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
       onUsage: usage => {
         // Cursor may assign _cursorConversationId inside the image loop's first runTurn;
         // backfill so Logs can filter/total that opening request (parity with the normal
@@ -1843,6 +1869,7 @@ export async function handleResponses(
       });
     }
     return imgResponse;
+    } // end else (streaming bridge)
   }
 
   // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
diff --git a/src/types.ts b/src/types.ts
index 78d2e706f..98087b74a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -160,6 +160,8 @@ export interface OcxTool {
   webSearch?: boolean;
   /** Synthetic image_gen tool: the model's call is executed by the xAI image bridge sidecar, not relayed to Codex. */
   imageGeneration?: boolean;
+  /** Synthetic video_gen tool: executed by the xAI video bridge sidecar. */
+  videoGeneration?: boolean;
 }
 
 /**
@@ -782,6 +784,14 @@ export interface OcxImagesConfig {
   maxRounds?: number;
   /** Max files retained under artifacts/. Oldest deleted when exceeded. Default 200. */
   artifactsKeepCount?: number;
+  /** Master switch for the video bridge. Default false — must be explicitly opted in. */
+  videoBridgeEnabled?: boolean;
+  /** Model for xAI video generation. Default "grok-imagine-video". */
+  videoBridgeModel?: string;
+  /** Max video-gen rounds before forced-final. Default 2 (video is slower than image). */
+  videoMaxRounds?: number;
+  /** Per-video generation timeout (ms) including polling. Default 300000 (5 min). */
+  videoTimeoutMs?: number;
 }
 
 export interface OcxSearchConfig {
diff --git a/tests/videos/fulfill-video.test.ts b/tests/videos/fulfill-video.test.ts
new file mode 100644
index 000000000..5baf34faa
--- /dev/null
+++ b/tests/videos/fulfill-video.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, test } from "bun:test";
+import { parseVideoCallArgs, pollVideoWithHeartbeats, buildVideoResult } from "../../src/images/fulfill-video";
+import type { PollVideoJobFn } from "../../src/images/fulfill-video";
+
+const auth = { baseUrl: "https://api.x.ai/v1", token: "t" };
+const noopSleep = async (): Promise => {};
+
+describe("parseVideoCallArgs", () => {
+  test("parses valid args", () => {
+    const result = parseVideoCallArgs(JSON.stringify({ prompt: "hello", duration: 5, resolution: "720p", aspect_ratio: "16:9" }));
+    expect(result.ok).toBe(true);
+    if (result.ok) {
+      expect(result.prompt).toBe("hello");
+      expect(result.duration).toBe(5);
+      expect(result.resolution).toBe("720p");
+      expect(result.aspectRatio).toBe("16:9");
+    }
+  });
+
+  test("accepts input as alias for prompt", () => {
+    const result = parseVideoCallArgs(JSON.stringify({ input: "world" }));
+    expect(result.ok).toBe(true);
+    if (result.ok) expect(result.prompt).toBe("world");
+  });
+
+  test("fails on missing prompt", () => {
+    const result = parseVideoCallArgs(JSON.stringify({ duration: 5 }));
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toBe("missing prompt");
+  });
+
+  test("fails on invalid JSON", () => {
+    const result = parseVideoCallArgs("not json");
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toBe("invalid arguments JSON");
+  });
+
+  test("fails on null", () => {
+    const result = parseVideoCallArgs("null");
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toBe("invalid arguments JSON");
+  });
+
+  test("clamps duration to 1-15", () => {
+    const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", duration: 100 }));
+    expect(result.ok).toBe(true);
+    if (result.ok) expect(result.duration).toBe(15);
+  });
+
+  test("clamps duration minimum to 1", () => {
+    const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", duration: 0 }));
+    expect(result.ok).toBe(true);
+    if (result.ok) expect(result.duration).toBe(1);
+  });
+
+  test("rejects invalid resolution", () => {
+    const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", resolution: "1080p" }));
+    expect(result.ok).toBe(true);
+    if (result.ok) expect(result.resolution).toBeUndefined();
+  });
+
+  test("rejects invalid aspect_ratio", () => {
+    const result = parseVideoCallArgs(JSON.stringify({ prompt: "test", aspect_ratio: "5:4" }));
+    expect(result.ok).toBe(true);
+    if (result.ok) expect(result.aspectRatio).toBeUndefined();
+  });
+});
+
+describe("pollVideoWithHeartbeats", () => {
+  async function drain(
+    pollFn: PollVideoJobFn,
+    timeoutMs = 60_000,
+  ): Promise<{ heartbeats: string[]; result: { ok: true; videoUrl: string } | { ok: false; error: string } }> {
+    const ac = new AbortController();
+    const gen = pollVideoWithHeartbeats("r1", auth, ac.signal, timeoutMs, noopSleep, pollFn);
+    const heartbeats: string[] = [];
+    for (;;) {
+      const { value, done } = await gen.next();
+      if (done) return { heartbeats, result: value };
+      heartbeats.push(value.message);
+    }
+  }
+
+  test("returns done on first poll", async () => {
+    const { result } = await drain(async () => ({
+      status: "done",
+      videoUrl: "https://cdn.x.ai/v.mp4",
+    }));
+    expect(result.ok).toBe(true);
+    if (result.ok) expect(result.videoUrl).toBe("https://cdn.x.ai/v.mp4");
+  });
+
+  test("yields at least one heartbeat before returning", async () => {
+    let callCount = 0;
+    const { heartbeats, result } = await drain(async () => {
+      callCount++;
+      return callCount >= 2
+        ? { status: "done" as const, videoUrl: "https://x.ai/v.mp4" }
+        : { status: "processing" as const };
+    });
+    expect(heartbeats.length).toBeGreaterThanOrEqual(1);
+    expect(result.ok).toBe(true);
+  });
+
+  test("returns failed status", async () => {
+    const { result } = await drain(async () => ({ status: "failed" }));
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toBe("video generation failed");
+  });
+
+  test("returns timeout error when timeoutMs exceeded", async () => {
+    const { result } = await drain(async () => ({ status: "processing" }), 0);
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toContain("timed out");
+  });
+
+  test("returns error when done but no videoUrl", async () => {
+    const { result } = await drain(async () => ({ status: "done" }));
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toContain("no video URL");
+  });
+
+  test("stops retrying on permanent 4xx poll error", async () => {
+    let pollCount = 0;
+    const { result } = await drain(async () => {
+      pollCount++;
+      const err = new Error("xAI videos poll API returned 401") as Error & { status: number };
+      err.status = 401;
+      throw err;
+    });
+    expect(result.ok).toBe(false);
+    if (!result.ok) expect(result.error).toContain("permanently");
+    expect(pollCount).toBe(1);
+  });
+});
+
+describe("buildVideoResult", () => {
+  test("builds success result", () => {
+    const result = buildVideoResult("/tmp/vid-123.mp4", "dance", "grok-imagine-video");
+    expect(result.ok).toBe(true);
+    expect(result.path).toBe("/tmp/vid-123.mp4");
+    expect(result.prompt).toBe("dance");
+    expect(result.model).toBe("grok-imagine-video");
+    expect(result.files).toEqual(["/tmp/vid-123.mp4"]);
+    expect(result.count).toBe(1);
+    expect(result.markdown).toContain("vid-123.mp4");
+  });
+
+  test("uses file:// URL in markdown", () => {
+    const result = buildVideoResult("/tmp/vid-123.mp4", "dance", "grok-imagine-video");
+    expect(result.markdown).toContain("file://");
+  });
+});
diff --git a/tests/videos/plan-video.test.ts b/tests/videos/plan-video.test.ts
new file mode 100644
index 000000000..84b6127b0
--- /dev/null
+++ b/tests/videos/plan-video.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, test } from "bun:test";
+import { planVideoBridge } from "../../src/images/plan";
+import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types";
+import { VIDEO_GEN_TOOL_NAME } from "../../src/images/synthetic-tool";
+
+function makeConfig(overrides: Partial = {}): OcxConfig {
+  const xai: OcxProviderConfig = {
+    name: "xai",
+    baseUrl: "https://api.x.ai/v1",
+    authMode: "key",
+    apiKey: "xai-test-key",
+  };
+  return {
+    providers: { xai },
+    ...overrides,
+  } as unknown as OcxConfig;
+}
+
+function makeParsed(): OcxParsedRequest {
+  return { stream: true, context: { messages: [] } } as unknown as OcxParsedRequest;
+}
+
+function makeProvider(host: string): OcxProviderConfig {
+  return { baseUrl: `https://${host}`, authMode: "key", apiKey: "other-key" } as unknown as OcxProviderConfig;
+}
+
+describe("planVideoBridge", () => {
+  test("returns undefined when videoBridgeEnabled is not true", async () => {
+    const config = makeConfig({ images: { videoBridgeEnabled: false } } as unknown as OcxConfig);
+    const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com"));
+    expect(plan).toBeUndefined();
+  });
+
+  test("returns undefined when videoBridgeEnabled is missing", async () => {
+    const config = makeConfig({ images: {} } as unknown as OcxConfig);
+    const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com"));
+    expect(plan).toBeUndefined();
+  });
+
+  test("returns plan when enabled with valid xAI provider", async () => {
+    const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig);
+    const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com"));
+    expect(plan).toBeDefined();
+    expect(plan!.model).toBe("grok-imagine-video");
+    expect(plan!.auth.token).toBe("xai-test-key");
+    expect(plan!.auth.baseUrl).toBe("https://api.x.ai/v1");
+    expect(plan!.toolNames.has(VIDEO_GEN_TOOL_NAME)).toBe(true);
+  });
+
+  test("returns undefined for OpenAI native passthrough", async () => {
+    const config = makeConfig({ images: { videoBridgeEnabled: true } } as unknown as OcxConfig);
+    const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.openai.com"));
+    expect(plan).toBeUndefined();
+  });
+
+  test("returns undefined when no xAI provider available", async () => {
+    const config: OcxConfig = {
+      providers: { anthropic: makeProvider("api.anthropic.com") },
+      images: { videoBridgeEnabled: true },
+    } as unknown as OcxConfig;
+    const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com"));
+    expect(plan).toBeUndefined();
+  });
+
+  test("returns undefined when xAI provider uses oauth (no API key)", async () => {
+    const config: OcxConfig = {
+      providers: { xai: { baseUrl: "https://api.x.ai/v1", authMode: "oauth", apiKey: undefined } },
+      images: { videoBridgeEnabled: true },
+    } as unknown as OcxConfig;
+    const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com"));
+    expect(plan).toBeUndefined();
+  });
+
+  test("respects custom videoBridgeModel", async () => {
+    const config = makeConfig({ images: { videoBridgeEnabled: true, videoBridgeModel: "custom-video-model" } } as unknown as OcxConfig);
+    const plan = await planVideoBridge(config, makeParsed(), makeProvider("api.anthropic.com"));
+    expect(plan).toBeDefined();
+    expect(plan!.model).toBe("custom-video-model");
+  });
+});
diff --git a/tests/videos/xai-video-client.test.ts b/tests/videos/xai-video-client.test.ts
new file mode 100644
index 000000000..fd8d77348
--- /dev/null
+++ b/tests/videos/xai-video-client.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, test, mock, afterEach } from "bun:test";
+import { submitVideoJob, pollVideoJob } from "../../src/images/xai-video-client";
+
+const auth = { baseUrl: "https://api.x.ai/v1", token: "test-key" };
+
+const originalFetch = globalThis.fetch;
+afterEach(() => {
+  globalThis.fetch = originalFetch;
+  mock.restore();
+});
+
+function mockFetchResponse(body: unknown, status = 200): Response {
+  return new Response(JSON.stringify(body), {
+    status,
+    headers: { "Content-Type": "application/json" },
+  });
+}
+
+describe("submitVideoJob", () => {
+  test("returns request_id from response", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ request_id: "vid-123" })));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    const result = await submitVideoJob({ prompt: "a cat playing piano" }, auth);
+    expect(result.requestId).toBe("vid-123");
+  });
+
+  test("accepts id field as fallback", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ id: "vid-456" })));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    const result = await submitVideoJob({ prompt: "sunset" }, auth);
+    expect(result.requestId).toBe("vid-456");
+  });
+
+  test("sends correct POST body", async () => {
+    let capturedBody: string | undefined;
+    const fetchMock = mock((url: string, init: RequestInit) => {
+      capturedBody = init.body as string;
+      return Promise.resolve(mockFetchResponse({ request_id: "r1" }));
+    });
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    await submitVideoJob(
+      { prompt: "dance", model: "grok-imagine-video", duration: 5, resolution: "720p", aspectRatio: "16:9" },
+      auth,
+    );
+
+    const body = JSON.parse(capturedBody!);
+    expect(body.prompt).toBe("dance");
+    expect(body.model).toBe("grok-imagine-video");
+    expect(body.duration).toBe(5);
+    expect(body.resolution).toBe("720p");
+    expect(body.aspect_ratio).toBe("16:9");
+  });
+
+  test("throws on non-2xx response", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ error: "rate limited" }, 429)));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    await expect(submitVideoJob({ prompt: "test" }, auth)).rejects.toThrow("429");
+  });
+
+  test("throws when request_id is missing", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ foo: "bar" })));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    await expect(submitVideoJob({ prompt: "test" }, auth)).rejects.toThrow("request_id");
+  });
+});
+
+describe("pollVideoJob", () => {
+  test("returns done status with video URL", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({
+      status: "done",
+      video: { url: "https://cdn.x.ai/video.mp4" },
+    })));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    const result = await pollVideoJob("vid-123", auth);
+    expect(result.status).toBe("done");
+    expect(result.videoUrl).toBe("https://cdn.x.ai/video.mp4");
+  });
+
+  test("normalizes completed → done", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ status: "completed" })));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    const result = await pollVideoJob("vid-123", auth);
+    expect(result.status).toBe("done");
+  });
+
+  test("normalizes error → failed", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ state: "error" })));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    const result = await pollVideoJob("vid-123", auth);
+    expect(result.status).toBe("failed");
+  });
+
+  test("returns processing for unknown status", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({ status: "rendering" })));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    const result = await pollVideoJob("vid-123", auth);
+    expect(result.status).toBe("processing");
+  });
+
+  test("throws on non-2xx response", async () => {
+    const fetchMock = mock(() => Promise.resolve(mockFetchResponse({}, 401)));
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    await expect(pollVideoJob("vid-123", auth)).rejects.toThrow("401");
+  });
+
+  test("uses GET method on poll URL", async () => {
+    let capturedUrl: string | undefined;
+    let capturedMethod: string | undefined;
+    const fetchMock = mock((url: string, init: RequestInit) => {
+      capturedUrl = url;
+      capturedMethod = init.method;
+      return Promise.resolve(mockFetchResponse({ status: "processing" }));
+    });
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    await pollVideoJob("vid-789", auth);
+    expect(capturedUrl).toContain("/videos/vid-789");
+    expect(capturedMethod).toBe("GET");
+  });
+
+  test("encodes requestId in poll URL", async () => {
+    let capturedUrl: string | undefined;
+    const fetchMock = mock((url: string) => {
+      capturedUrl = url;
+      return Promise.resolve(mockFetchResponse({ status: "processing" }));
+    });
+    globalThis.fetch = fetchMock as typeof globalThis.fetch;
+
+    await pollVideoJob("req/with?special&chars", auth);
+    expect(capturedUrl).toContain(encodeURIComponent("req/with?special&chars"));
+    // Must NOT contain the raw special chars in the path
+    expect(capturedUrl).not.toMatch(/\/videos\/req\/with/);
+  });
+});

From 20cdc0d1bd7672bd4a57464f4a5f45f1876a8f54 Mon Sep 17 00:00:00 2001
From: Andrew Krock 
Date: Wed, 29 Jul 2026 00:55:58 -0400
Subject: [PATCH 05/16] fix: scope Spark cooldowns to its native quota (#599)

* Merge remote-tracking branch 'origin/dev' into fix/codex-spark-quota-scope-dev

* fix: retain affinity per Codex quota scope

* fix: address scoped affinity review feedback

* fix: restore dev version and harden compact test
---
 .../src/content/docs/guides/providers.md      |   5 +-
 src/codex/auth-context.ts                     |  75 ++-
 src/codex/routing.ts                          | 512 ++++++++++++++----
 src/server/responses/compact.ts               |  34 +-
 src/server/responses/core.ts                  |  17 +-
 structure/08_openai-provider-tiers.md         |   5 +
 tests/codex-auth-context.test.ts              | 168 ++++++
 tests/codex-pool-rotation.test.ts             |  56 ++
 tests/codex-routing.test.ts                   |  86 +++
 tests/responses-compaction-routing.test.ts    | 234 +++++++-
 tests/server-combo-failover-e2e.test.ts       |  47 ++
 11 files changed, 1109 insertions(+), 130 deletions(-)

diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 2e70c4dae..4615c0726 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -127,7 +127,10 @@ Terminal refresh failures mark the account as needing reauthentication instead o
 **Cooldowns (Codex pool).** Upstream `429` / quota responses set a hard cooldown from
 `Retry-After`, quota `reset` headers (capped), or a short default backoff. Accounts on an explicit
 `Retry-After` cooldown are not probed early; reset-derived cooldowns may receive a paced probe lease
-so recovery can be detected without flooding the provider.
+so recovery can be detected without flooding the provider. Reset-derived native-model cooldowns
+also preserve known independent quota groups: `gpt-5.3-codex-spark` does not prevent the same account
+from trying the shared GPT-5.6 Terra/Luna quota, while models in that shared group still protect one
+another. Explicit `Retry-After` and default cooldowns always remain account-wide.
 
 **Session affinity.** Codex thread→account affinity is process-local (in-memory only; not persisted
 across proxy restarts). On credential failures (`401` / `403`) the account is quarantined for
diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts
index d15c8c4b1..aacea92ee 100644
--- a/src/codex/auth-context.ts
+++ b/src/codex/auth-context.ts
@@ -9,13 +9,16 @@ import { isCodexAccountUsable } from "./account-usability";
 import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
 import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
 import {
-  getCodexAccountHealthSnapshot,
+  codexQuotaScopeForModel,
+  getCodexQuotaHealthSnapshot,
   releaseCodexQuotaProbeLease,
+  releaseCodexQuotaScopeProbeLease,
   tryAcquireCodexQuotaProbeLease,
+  tryAcquireCodexQuotaScopeProbeLease,
   pickAlternateCodexAccount,
   resolveCodexAccountForThreadDetailed,
 } from "./routing";
-import type { CodexCooldownSource } from "./routing";
+import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
 import { maskAccountId } from "../lib/privacy";
 import { formatErrorResponse } from "../bridge";
 import { getAccountQuota } from "./quota";
@@ -34,8 +37,12 @@ export type CodexAuthContext =
        * Set when this request was admitted through an active quota cooldown as
        * the account's single probe. Must be echoed into the upstream outcome so
        * only this request can clear the cooldown (#433).
-       */
+      */
       probeLeaseId?: string;
+      /** Native model quota group selected for this request, when known. */
+      quotaScope?: CodexQuotaScope;
+      /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
+      probeQuotaScope?: CodexQuotaScope;
     }
   | {
       // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
@@ -46,6 +53,8 @@ export type CodexAuthContext =
       chatgptAccountId: string;
       /** See `pool.probeLeaseId`. */
       probeLeaseId?: string;
+      quotaScope?: CodexQuotaScope;
+      probeQuotaScope?: CodexQuotaScope;
     };
 
 /** Probe lease carried by this context, when it holds one. */
@@ -53,13 +62,20 @@ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | u
   return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined;
 }
 
+/** Scope of a lease carried by this context, when it probes a model-specific quota. */
+export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQuotaScope | undefined {
+  return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
+}
+
 /**
  * Hand back a probe lease for a request that will not reach upstream. Safe to
  * call with a context that holds no lease.
  */
 export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
   const leaseId = codexProbeLeaseId(ctx);
-  if (ctx && leaseId) releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
+  if (!ctx || ctx.kind === "main" || !leaseId) return;
+  if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
+  else releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
 }
 
 export type OcxRuntimeProviderConfig = OcxProviderConfig & {
@@ -99,13 +115,20 @@ export class CodexAccountCooldownError extends Error {
   accountId: string;
   cooldownUntil: number;
   cooldownSource?: CodexCooldownSource;
+  quotaScope?: CodexQuotaScope;
 
-  constructor(accountId: string, cooldownUntil: number, cooldownSource?: CodexCooldownSource) {
+  constructor(
+    accountId: string,
+    cooldownUntil: number,
+    cooldownSource?: CodexCooldownSource,
+    quotaScope?: CodexQuotaScope,
+  ) {
     super("Selected Codex account is cooling down");
     this.name = "CodexAccountCooldownError";
     this.accountId = accountId;
     this.cooldownUntil = cooldownUntil;
     this.cooldownSource = cooldownSource;
+    this.quotaScope = quotaScope;
   }
 }
 
@@ -127,7 +150,12 @@ export function cooldownAccountLabel(accountId: string): string {
  */
 export function cooldownErrorMessage(err: CodexAccountCooldownError): string {
   const until = new Date(err.cooldownUntil).toISOString();
-  return `Selected Codex account (${cooldownAccountLabel(err.accountId)}) is cooling down until ${until}`
+  const scope = err.quotaScope === "spark"
+    ? "Spark quota"
+    : err.quotaScope === "shared"
+      ? "shared native quota"
+      : null;
+  return `Selected Codex account (${cooldownAccountLabel(err.accountId)})${scope ? ` ${scope} is` : " is"} cooling down until ${until}`
     + ` (source: ${err.cooldownSource ?? "default"}).`
     + ` Run 'ocx account list openai' to find the id, then`
     + ` 'ocx account clear-cooldown openai ' to lift it, or switch accounts with 'ocx account use openai '.`;
@@ -157,6 +185,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
 
 export interface ResolveCodexAuthContextOptions {
   excludeAccountId?: string;
+  /** Final native model selected for this request, used to select its quota group. */
+  modelId?: string;
 }
 
 export async function resolveCodexAuthContext(
@@ -171,16 +201,17 @@ export async function resolveCodexAuthContext(
   }
   reconcileMainCodexAccountRuntimeState();
   const threadId = headers.get("x-codex-parent-thread-id");
+  const quotaScope = codexQuotaScopeForModel(options.modelId);
   const resolution = options.excludeAccountId
     ? (() => {
-        const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!);
+        const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!, Date.now(), quotaScope);
         return accountId
           ? { status: "selected" as const, accountId }
           : { status: "none" as const };
       })()
-    : resolveCodexAccountForThreadDetailed(threadId, config);
+    : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope);
   if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
-  const accountId = resolution.status === "selected" ? resolution.accountId : null;
+  let accountId = resolution.status === "selected" ? resolution.accountId : null;
   if (!accountId) throw new CodexPoolAuthenticationError();
   // Lazy prime: if the selected account has no quota yet, the pool is likely
   // unprimed (dashboard never opened, or startup prime was blocked). Kick a
@@ -194,15 +225,21 @@ export async function resolveCodexAuthContext(
   }
   // Snapshot (not just the deadline) so a refused request can report WHY it is cooled:
   // a literal Retry-After reads very differently to a user than a reset-derived guess.
-  const cooldown = getCodexAccountHealthSnapshot(accountId);
+  const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
   const cooldownUntil = cooldown?.cooldownUntil;
   // A cooled-down account never sends traffic, so upstream recovery can never be
   // observed and the cooldown outlives the real limit. Admit one probe per
   // interval; its outcome decides whether the cooldown ends (#433).
   let probeLeaseId: string | undefined;
+  let probeQuotaScope: CodexQuotaScope | undefined;
   if (cooldownUntil) {
-    probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
-    if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource);
+    probeQuotaScope = cooldown?.quotaScope;
+    probeLeaseId = probeQuotaScope
+      ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
+      : tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
+    if (!probeLeaseId) {
+      throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
+    }
   }
 
   if (accountId === MAIN_CODEX_ACCOUNT_ID) {
@@ -210,7 +247,8 @@ export async function resolveCodexAuthContext(
     const token = getMainAccountToken();
     if (!token) {
       // Nothing will reach upstream, so give the probe back instead of burning it.
-      if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
+      if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
+      else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
       throw new CodexPoolAuthenticationError();
     }
     return {
@@ -218,7 +256,9 @@ export async function resolveCodexAuthContext(
       accountId,
       accessToken: token.accessToken,
       chatgptAccountId: token.chatgptAccountId,
+      ...(quotaScope ? { quotaScope } : {}),
       ...(probeLeaseId ? { probeLeaseId } : {}),
+      ...(probeQuotaScope ? { probeQuotaScope } : {}),
     };
   }
 
@@ -230,10 +270,13 @@ export async function resolveCodexAuthContext(
       generation: token.generation,
       accessToken: token.accessToken,
       chatgptAccountId: token.chatgptAccountId,
+      ...(quotaScope ? { quotaScope } : {}),
       ...(probeLeaseId ? { probeLeaseId } : {}),
+      ...(probeQuotaScope ? { probeQuotaScope } : {}),
     };
   } catch (cause) {
-    if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
+    if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
+    else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
     if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
       markAccountNeedsReauth(accountId);
     }
@@ -245,9 +288,9 @@ export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefine
   if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
   // A context holding the probe lease was deliberately admitted through the cooldown.
   if (ctx.probeLeaseId) return;
-  const cooldown = getCodexAccountHealthSnapshot(ctx.accountId);
+  const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope);
   if (cooldown?.cooldownUntil) {
-    throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource);
+    throw new CodexAccountCooldownError(ctx.accountId, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope);
   }
 }
 
diff --git a/src/codex/routing.ts b/src/codex/routing.ts
index f127b12de..a54eb9010 100644
--- a/src/codex/routing.ts
+++ b/src/codex/routing.ts
@@ -33,7 +33,6 @@ export type CodexThreadResolution =
   | { status: "none" }
   | { status: "expired"; accountId: string };
 
-const threadAccountMap = new Map();
 /**
  * Process-local cursor for automatic RR/fill-first (and quota-429 when not
  * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient
@@ -108,22 +107,76 @@ export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
 export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
 
 const upstreamHealth = new Map();
+/**
+ * Reset-derived 429s can describe a quota owned by one native model family,
+ * rather than the whole ChatGPT account. Keep those advisory cooldowns apart
+ * from account-wide Retry-After/default throttles and transient health.
+ */
+const quotaScopedHealth = new Map>();
 
 export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
 export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
 export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
+/**
+ * Native Codex quota groups known to be independent upstream. Keep the mapping
+ * deliberately conservative: unlisted models share the normal native group.
+ * Add a new explicit group here only when its independent upstream quota is
+ * confirmed, so shared limits never receive cross-model bypasses.
+ */
+export type CodexQuotaScope = "shared" | "spark";
+
+/**
+ * Requests without a resolved native model retain the historic one-account-per-
+ * thread behavior. Requests with a known quota scope get an independent
+ * affinity so a Spark failover cannot displace the same thread's Terra/Luna
+ * account (and vice versa).
+ */
+type ThreadAffinityScope = CodexQuotaScope | "legacy";
+const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const;
+const threadAccountMap = new Map>();
+
+const NATIVE_MODEL_QUOTA_SCOPES: Readonly> = {
+  "gpt-5.3-codex-spark": "spark",
+};
+
+// A thread can have one legacy binding plus one binding for each known scope.
+// This upper-bound guard avoids an exact map scan until it can be over capacity.
+const MAX_THREAD_AFFINITY_SCOPES = new Set([
+  LEGACY_THREAD_AFFINITY_SCOPE,
+  "shared",
+  ...Object.values(NATIVE_MODEL_QUOTA_SCOPES),
+]).size;
+
+export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
+  if (!modelId?.trim()) return undefined;
+  return NATIVE_MODEL_QUOTA_SCOPES[modelId.trim().toLowerCase()] ?? "shared";
+}
+
+/** Independent quota groups must not mutate the shared active-account cursor. */
+function isIndependentCodexQuotaScope(quotaScope?: CodexQuotaScope): boolean {
+  return quotaScope !== undefined && quotaScope !== "shared";
+}
+
+function codexPoolKeyForScope(quotaScope?: CodexQuotaScope): string {
+  return isIndependentCodexQuotaScope(quotaScope) ? `${POOL_KEY_CODEX}:${quotaScope}` : POOL_KEY_CODEX;
+}
+
 export type CodexUpstreamOutcomeMeta = {
   retryAfter?: string | null;
   resetAt?: unknown | unknown[];
   now?: number;
+  /** Native model selected for this request; used only for confirmed scoped quotas. */
+  modelId?: string;
   /** When set, clears affinity for this thread immediately on transient failure. */
   threadId?: string | null;
   /**
    * Probe lease held by this request, when it was admitted through an active
    * quota cooldown. Only the outcome carrying the current lease may clear the
    * cooldown (#433).
-   */
+  */
   probeLeaseId?: string;
+  /** Scope of `probeLeaseId` when it was granted against a model-scoped cooldown. */
+  probeQuotaScope?: CodexQuotaScope;
   /**
    * Already-chosen alternate for same-request 429 retry. When set, promotion
    * reuses this account instead of calling {@link pickAlternateCodexAccount}
@@ -142,18 +195,23 @@ export function clearThreadAccountMap(): void {
 }
 
 export function clearThreadAccountMapForAccount(accountId: string): void {
-  for (const [threadId, entry] of threadAccountMap) {
-    if (entry.accountId === accountId) threadAccountMap.delete(threadId);
+  for (const [threadId, affinities] of threadAccountMap) {
+    for (const [scope, entry] of affinities) {
+      if (entry.accountId === accountId) affinities.delete(scope);
+    }
+    if (affinities.size === 0) threadAccountMap.delete(threadId);
   }
 }
 
 export function clearCodexUpstreamHealth(): void {
   upstreamHealth.clear();
+  quotaScopedHealth.clear();
   runtimeActiveCodexAccountId = undefined;
 }
 
 export function clearCodexUpstreamHealthForAccount(accountId: string): void {
   upstreamHealth.delete(accountId);
+  quotaScopedHealth.delete(accountId);
 }
 
 export function getCodexUpstreamHealth(
@@ -162,6 +220,26 @@ export function getCodexUpstreamHealth(
   return upstreamHealth.get(accountId) ?? null;
 }
 
+function scopedHealthFor(accountId: string, scope: CodexQuotaScope): CodexUpstreamHealth | undefined {
+  return quotaScopedHealth.get(accountId)?.get(scope);
+}
+
+function setScopedHealth(accountId: string, scope: CodexQuotaScope, health: CodexUpstreamHealth): void {
+  let scopes = quotaScopedHealth.get(accountId);
+  if (!scopes) {
+    scopes = new Map();
+    quotaScopedHealth.set(accountId, scopes);
+  }
+  scopes.set(scope, health);
+}
+
+function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
+  const scopes = quotaScopedHealth.get(accountId);
+  if (!scopes) return;
+  scopes.delete(scope);
+  if (scopes.size === 0) quotaScopedHealth.delete(accountId);
+}
+
 export function computeCodexUsageScore(quota: {
   weeklyPercent?: number;
   monthlyPercent?: number;
@@ -276,7 +354,10 @@ export function tryAcquireCodexQuotaProbeLease(accountId: string, now = Date.now
 
 /** Side-effect-free check mirroring {@link tryAcquireCodexQuotaProbeLease} eligibility. */
 export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now()): boolean {
-  const health = upstreamHealth.get(accountId);
+  return canAcquireQuotaProbeLease(upstreamHealth.get(accountId), now);
+}
+
+function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: number): boolean {
   if (!health) return false;
   const cooldownUntil = health.cooldownUntil;
   if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
@@ -286,6 +367,24 @@ export function canAcquireCodexQuotaProbeLease(accountId: string, now = Date.now
   return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
 }
 
+/** Acquire the recovery probe for one confirmed model-specific quota group. */
+export function tryAcquireCodexQuotaScopeProbeLease(
+  accountId: string,
+  scope: CodexQuotaScope,
+  now = Date.now(),
+): string | null {
+  const health = scopedHealthFor(accountId, scope);
+  if (!canAcquireQuotaProbeLease(health, now)) return null;
+  const probeLeaseId = randomUUID();
+  setScopedHealth(accountId, scope, {
+    ...health!,
+    probeLeaseId,
+    probeLeaseGeneration: health!.cooldownGeneration ?? 0,
+    lastProbeAt: now,
+  });
+  return probeLeaseId;
+}
+
 /**
  * Hand a probe lease back without recording an upstream outcome. Used by paths
  * that take a lease and then fail before any request reaches upstream.
@@ -296,6 +395,18 @@ export function releaseCodexQuotaProbeLease(accountId: string, leaseId: string,
   upstreamHealth.set(accountId, withProbeLeaseReleased(health, now));
 }
 
+/** Release a model-specific quota probe when the request never reaches upstream. */
+export function releaseCodexQuotaScopeProbeLease(
+  accountId: string,
+  scope: CodexQuotaScope,
+  leaseId: string,
+  now = Date.now(),
+): void {
+  const health = scopedHealthFor(accountId, scope);
+  if (!health || health.probeLeaseId !== leaseId) return;
+  setScopedHealth(accountId, scope, withProbeLeaseReleased(health, now));
+}
+
 /**
  * True when this outcome belongs to the account's in-flight probe. The
  * undefined-id guard matters: without it an outcome carrying no lease would match
@@ -341,6 +452,11 @@ export function resetCodexRoutingForManualSelection(accountId: string): void {
   // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
   // config.activeCodexAccountId, which the caller persists before invoking this.
   seedPoolRotationAccount(POOL_KEY_CODEX, accountId);
+  for (const scope of new Set(Object.values(NATIVE_MODEL_QUOTA_SCOPES))) {
+    if (isIndependentCodexQuotaScope(scope)) {
+      seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
+    }
+  }
   const current = upstreamHealth.get(accountId);
   if (!current) return;
   const preserved = preservedCooldownFields(current);
@@ -367,6 +483,33 @@ export function getCodexAccountHealthSnapshot(accountId: string, now = Date.now(
   };
 }
 
+/**
+ * Read the cooldown relevant to a routed native model. Account-wide cooldowns
+ * (Retry-After/default) always win; reset-derived scoped state applies only to
+ * its confirmed quota group.
+ */
+export function getCodexQuotaHealthSnapshot(
+  accountId: string,
+  quotaScope: CodexQuotaScope | undefined,
+  now = Date.now(),
+): {
+  cooldownUntil?: number;
+  cooldownSource?: CodexCooldownSource;
+  quotaScope?: CodexQuotaScope;
+} | null {
+  const account = getCodexAccountHealthSnapshot(accountId, now);
+  if (account) return account;
+  if (!quotaScope) return null;
+  const scoped = scopedHealthFor(accountId, quotaScope);
+  const cooldownUntil = scoped?.cooldownUntil;
+  if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
+  return {
+    cooldownUntil,
+    ...(scoped?.cooldownSource ? { cooldownSource: scoped.cooldownSource } : {}),
+    quotaScope,
+  };
+}
+
 export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean {
   return getCodexAccountCooldownUntil(accountId, now) !== null;
 }
@@ -390,24 +533,41 @@ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): b
  * Returns false when the account carried no live cooldown (already expired or never set).
  */
 export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
-  const health = upstreamHealth.get(accountId);
-  if (!health) return false;
-  const cooldownUntil = health.cooldownUntil;
-  if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return false;
-  const {
-    cooldownUntil: _until,
-    cooldownSince: _since,
-    cooldownSource: _source,
-    probeLeaseId: _leaseId,
-    probeLeaseGeneration: _leaseGeneration,
-    ...rest
-  } = health;
-  upstreamHealth.set(accountId, {
-    ...rest,
-    cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
-    lastProbeAt: now,
-  });
-  return true;
+  const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
+    const cooldownUntil = health.cooldownUntil;
+    if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
+    const {
+      cooldownUntil: _until,
+      cooldownSince: _since,
+      cooldownSource: _source,
+      probeLeaseId: _leaseId,
+      probeLeaseGeneration: _leaseGeneration,
+      ...rest
+    } = health;
+    return {
+      ...rest,
+      cooldownGeneration: (health.cooldownGeneration ?? 0) + 1,
+      lastProbeAt: now,
+    };
+  };
+
+  let cleared = false;
+  const accountHealth = upstreamHealth.get(accountId);
+  if (accountHealth) {
+    const next = clear(accountHealth);
+    if (next) {
+      upstreamHealth.set(accountId, next);
+      cleared = true;
+    }
+  }
+  for (const [scope, health] of quotaScopedHealth.get(accountId) ?? []) {
+    const next = clear(health);
+    if (next) {
+      setScopedHealth(accountId, scope, next);
+      cleared = true;
+    }
+  }
+  return cleared;
 }
 
 export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
@@ -421,12 +581,48 @@ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()):
   return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
 }
 
-function isCodexAccountSelectable(config: OcxConfig, accountId: string, now: number): boolean {
-  return !isCodexAccountInCooldown(accountId, now)
+function isCodexAccountSelectable(
+  config: OcxConfig,
+  accountId: string,
+  now: number,
+  quotaScope?: CodexQuotaScope,
+): boolean {
+  return getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
     && !isCodexAccountSoftAvoided(accountId, now)
     && isCodexAccountUsable(config, accountId);
 }
 
+function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
+  return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
+}
+
+function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
+  return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
+}
+
+function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
+  const affinities = threadAccountMap.get(threadId);
+  if (!affinities) return;
+  affinities.delete(threadAffinityScope(quotaScope));
+  if (affinities.size === 0) threadAccountMap.delete(threadId);
+}
+
+/** Remove only the matching failed account's affinities for one thread. */
+function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
+  const affinities = threadAccountMap.get(threadId);
+  if (!affinities) return;
+  for (const [scope, entry] of affinities) {
+    if (entry.accountId === accountId) affinities.delete(scope);
+  }
+  if (affinities.size === 0) threadAccountMap.delete(threadId);
+}
+
+function threadAffinityEntryCount(): number {
+  let count = 0;
+  for (const affinities of threadAccountMap.values()) count += affinities.size;
+  return count;
+}
+
 function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
   return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS;
 }
@@ -437,45 +633,66 @@ function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
 }
 
 function pruneExpiredThreadAffinities(now: number): void {
-  for (const [threadId, entry] of threadAccountMap) {
-    if (isThreadAffinityExpired(entry, now)) threadAccountMap.delete(threadId);
+  for (const [threadId, affinities] of threadAccountMap) {
+    for (const [scope, entry] of affinities) {
+      if (isThreadAffinityExpired(entry, now)) affinities.delete(scope);
+    }
+    if (affinities.size === 0) threadAccountMap.delete(threadId);
   }
 }
 
 function pruneLruThreadAffinities(): void {
-  while (threadAccountMap.size > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
+  if (threadAccountMap.size * MAX_THREAD_AFFINITY_SCOPES <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
+  while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
     let oldestThreadId: string | null = null;
+    let oldestScope: ThreadAffinityScope | null = null;
     let oldestLastUsedAt = Number.POSITIVE_INFINITY;
-    for (const [threadId, entry] of threadAccountMap) {
-      if (entry.lastUsedAt < oldestLastUsedAt) {
-        oldestThreadId = threadId;
-        oldestLastUsedAt = entry.lastUsedAt;
+    for (const [threadId, affinities] of threadAccountMap) {
+      for (const [scope, entry] of affinities) {
+        if (entry.lastUsedAt < oldestLastUsedAt) {
+          oldestThreadId = threadId;
+          oldestScope = scope;
+          oldestLastUsedAt = entry.lastUsedAt;
+        }
       }
     }
-    if (!oldestThreadId) return;
-    threadAccountMap.delete(oldestThreadId);
+    if (!oldestThreadId || !oldestScope) return;
+    deleteThreadAffinity(oldestThreadId, oldestScope === LEGACY_THREAD_AFFINITY_SCOPE ? undefined : oldestScope);
   }
 }
 
-function bindThreadAffinity(threadId: string, accountId: string, now: number): void {
+function bindThreadAffinity(
+  threadId: string,
+  accountId: string,
+  now: number,
+  quotaScope?: CodexQuotaScope,
+): void {
   const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
   if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
   pruneExpiredThreadAffinities(now);
-  const previous = threadAccountMap.get(threadId);
-  threadAccountMap.set(threadId, {
+  const scope = threadAffinityScope(quotaScope);
+  const affinities = threadAccountMap.get(threadId) ?? new Map();
+  const previous = affinities.get(scope);
+  affinities.set(scope, {
     accountId,
     generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation,
     createdAt: previous?.createdAt ?? now,
     lastUsedAt: now,
     lastReevalAt: now,
   });
+  threadAccountMap.set(threadId, affinities);
   pruneLruThreadAffinities();
 }
 
-function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Date.now()): string[] {
+function getEligiblePoolAccounts(
+  config: OcxConfig,
+  excludeId?: string,
+  now = Date.now(),
+  quotaScope?: CodexQuotaScope,
+): string[] {
   const ids = (config.codexAccounts ?? [])
     .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id))
-    .filter(account => !isCodexAccountInCooldown(account.id, now))
+    .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
     .filter(account => !isCodexAccountSoftAvoided(account.id, now))
     .filter(account => isCodexAccountUsable(config, account.id))
     .map(account => account.id);
@@ -484,7 +701,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
   if (
     excludeId !== MAIN_CODEX_ACCOUNT_ID
     && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
-    && !isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID, now)
+    && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
     && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
     && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
   ) {
@@ -493,8 +710,12 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
   return ids;
 }
 
-function listEligibleCodexAccountIds(config: OcxConfig, now: number): string[] {
-  return getEligiblePoolAccounts(config, undefined, now);
+function listEligibleCodexAccountIds(
+  config: OcxConfig,
+  now: number,
+  quotaScope?: CodexQuotaScope,
+): string[] {
+  return getEligiblePoolAccounts(config, undefined, now, quotaScope);
 }
 
 function stickyLimitForConfig(config: OcxConfig): number {
@@ -514,8 +735,12 @@ function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string):
  * Fill-first: keep selectable active under threshold; otherwise advance to the next
  * eligible id in stable sorted order after the current active (wrapping).
  */
-function pickFillFirstCodexAccount(config: OcxConfig, now: number): string | null {
-  const eligible = listEligibleCodexAccountIds(config, now);
+function pickFillFirstCodexAccount(
+  config: OcxConfig,
+  now: number,
+  quotaScope?: CodexQuotaScope,
+): string | null {
+  const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
   if (eligible.length === 0) return null;
 
   const active = getEffectiveActiveCodexAccountId(config);
@@ -589,31 +814,33 @@ function pickUnboundStrategyAccount(
   threadId: string | null,
   now: number,
   commit: boolean,
+  quotaScope?: CodexQuotaScope,
 ): string | null {
   const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
   if (strategy === "quota") return null;
+  const poolKey = codexPoolKeyForScope(quotaScope);
 
   let picked: string | null = null;
   if (strategy === "round-robin") {
-    const eligible = listEligibleCodexAccountIds(config, now);
+    const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
     const limit = stickyLimitForConfig(config);
     if (!commit) {
-      return peekRoundRobinAccount(POOL_KEY_CODEX, eligible, limit);
+      return peekRoundRobinAccount(poolKey, eligible, limit);
     }
-    picked = pickRoundRobinAccount(POOL_KEY_CODEX, eligible, limit);
+    picked = pickRoundRobinAccount(poolKey, eligible, limit);
     if (!picked) return null;
-    rememberActiveCodexAccount(config, picked);
-    if (threadId) bindThreadAffinity(threadId, picked, now);
-    notePoolRotationSuccess(POOL_KEY_CODEX, picked, limit);
+    if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
+    if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
+    notePoolRotationSuccess(poolKey, picked, limit);
     return picked;
   }
 
   if (strategy === "fill-first") {
-    picked = pickFillFirstCodexAccount(config, now);
+    picked = pickFillFirstCodexAccount(config, now, quotaScope);
     if (!picked) return null;
     if (commit) {
-      rememberActiveCodexAccount(config, picked);
-      if (threadId) bindThreadAffinity(threadId, picked, now);
+      if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
+      if (threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
     }
     return picked;
   }
@@ -626,10 +853,16 @@ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string
   return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan;
 }
 
-function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: number, now: number): string {
+function pickLowerUsageAccount(
+  config: OcxConfig,
+  active: string,
+  activeUsage: number,
+  now: number,
+  quotaScope?: CodexQuotaScope,
+): string {
   let best = active;
   let bestUsage = activeUsage;
-  for (const id of getEligiblePoolAccounts(config, active, now)) {
+  for (const id of getEligiblePoolAccounts(config, active, now, quotaScope)) {
     const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
     if (usage < bestUsage) {
       best = id;
@@ -639,10 +872,15 @@ function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: n
   return best;
 }
 
-export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: string, now = Date.now()): string | null {
+export function pickLowestUsageCodexAccount(
+  config: OcxConfig,
+  excludeId?: string,
+  now = Date.now(),
+  quotaScope?: CodexQuotaScope,
+): string | null {
   let best: string | null = null;
   let bestUsage = Number.POSITIVE_INFINITY;
-  for (const id of getEligiblePoolAccounts(config, excludeId, now)) {
+  for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) {
     const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
     if (usage < bestUsage) {
       best = id;
@@ -661,17 +899,18 @@ export function pickAlternateCodexAccount(
   config: OcxConfig,
   excludeId: string,
   now = Date.now(),
+  quotaScope?: CodexQuotaScope,
 ): string | null {
   const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
   if (strategy === "round-robin") {
-    const eligible = listEligibleCodexAccountIds(config, now).filter(id => id !== excludeId);
-    return pickRoundRobinAccount(POOL_KEY_CODEX, eligible, stickyLimitForConfig(config));
+    const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
+    return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
   }
   if (strategy === "fill-first") {
-    const eligible = listEligibleCodexAccountIds(config, now).filter(id => id !== excludeId);
+    const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
     return pickNextFillFirstCodexAccount(config, excludeId, eligible, now);
   }
-  return pickLowestUsageCodexAccount(config, excludeId, now);
+  return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope);
 }
 
 /** Effective active: automatic runtime cursor, else operator/persisted selection. */
@@ -708,7 +947,12 @@ function isUnknownUsage(usage: number): boolean {
   return usage >= CODEX_UNKNOWN_USAGE_SCORE;
 }
 
-function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): string {
+function applyQuotaAutoSwitch(
+  config: OcxConfig,
+  active: string,
+  now: number,
+  quotaScope?: CodexQuotaScope,
+): string {
   const threshold = config.autoSwitchThreshold ?? 80;
   if (threshold <= 0) return active;
   const quota = getAccountQuota(active);
@@ -717,9 +961,9 @@ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): s
   // threshold. Wait for quota priming instead of rotating among guesses.
   if (isUnknownUsage(activeUsage)) return active;
   if (activeUsage < threshold) return active;
-  const best = pickLowerUsageAccount(config, active, activeUsage, now);
+  const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope);
   if (best !== active) {
-    setActiveCodexAccount(config, best);
+    if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
     return best;
   }
 
@@ -748,8 +992,9 @@ export function resolveCodexAccountForThread(
   threadId: string | null,
   config: OcxConfig,
   now = Date.now(),
+  quotaScope?: CodexQuotaScope,
 ): string | null {
-  const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now);
+  const resolution = resolveCodexAccountForThreadDetailed(threadId, config, now, quotaScope);
   return resolution.status === "selected" ? resolution.accountId : null;
 }
 
@@ -765,13 +1010,14 @@ export function previewCodexAccountForRequest(
   threadId: string | null,
   config: OcxConfig,
   now = Date.now(),
+  quotaScope?: CodexQuotaScope,
 ): string | null {
-  if (threadId && threadAccountMap.has(threadId)) {
-    const entry = threadAccountMap.get(threadId)!;
+  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
+  if (threadId && entry) {
     if (
       !isThreadAffinityExpired(entry, now)
       && isThreadAffinityGenerationLive(entry)
-      && isCodexAccountSelectable(config, entry.accountId, now)
+      && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
       && !shouldFailover(config, entry.accountId, now)
     ) {
       // Quota strategy only: non-quota strategies keep affinity for ongoing threads
@@ -785,7 +1031,7 @@ export function previewCodexAccountForRequest(
             getPoolAccountPlan(config, entry.accountId),
           );
           if (!isUnknownUsage(usage) && usage >= threshold) {
-            const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
+            const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
             if (best !== entry.accountId) return best;
           }
         }
@@ -795,15 +1041,15 @@ export function previewCodexAccountForRequest(
     // Stale/unusable affinity is ignored for preview (no map mutation).
   }
 
-  const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false);
+  const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false, quotaScope);
   if (strategyPick) return strategyPick;
 
   let active = getEffectiveActiveCodexAccountId(config) ?? null;
   if (!active) {
-    return pickLowestUsageCodexAccount(config, undefined, now);
+    return pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
   }
-  if (!isCodexAccountSelectable(config, active, now)) {
-    const fallback = pickLowestUsageCodexAccount(config, active, now);
+  if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
+    const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
     if (fallback) active = fallback;
     else if (hasConfiguredPoolAccount(config, active)) return active;
     else return null;
@@ -813,17 +1059,17 @@ export function previewCodexAccountForRequest(
   if (threshold > 0) {
     const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
     if (!isUnknownUsage(usage) && usage >= threshold) {
-      active = pickLowerUsageAccount(config, active, usage, now);
+      active = pickLowerUsageAccount(config, active, usage, now, quotaScope);
     }
   }
   if (shouldFailover(config, active, now)) {
-    const best = pickLowestUsageCodexAccount(config, active, now);
+    const best = pickLowestUsageCodexAccount(config, active, now, quotaScope);
     if (best) active = best;
   }
   if (!isCodexAccountUsable(config, active)) {
     return hasConfiguredPoolAccount(config, active) ? active : null;
   }
-  if (isCodexAccountInCooldown(active, now)) {
+  if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
     return hasConfiguredPoolAccount(config, active) ? active : null;
   }
   return active;
@@ -833,16 +1079,17 @@ export function resolveCodexAccountForThreadDetailed(
   threadId: string | null,
   config: OcxConfig,
   now = Date.now(),
+  quotaScope?: CodexQuotaScope,
 ): CodexThreadResolution {
-  if (threadId && threadAccountMap.has(threadId)) {
-    const entry = threadAccountMap.get(threadId)!;
+  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
+  if (threadId && entry) {
     if (isThreadAffinityExpired(entry, now)) {
-      threadAccountMap.delete(threadId);
+      deleteThreadAffinity(threadId, quotaScope);
       return { status: "expired", accountId: entry.accountId };
     }
     if (
       isThreadAffinityGenerationLive(entry)
-      && isCodexAccountSelectable(config, entry.accountId, now)
+      && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
       // Affined threads must leave a failing account once the streak trips failover
       // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
       && !shouldFailover(config, entry.accountId, now)
@@ -869,10 +1116,10 @@ export function resolveCodexAccountForThreadDetailed(
         if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
           entry.lastReevalAt = now;
           if (overThreshold) {
-            const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
+            const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
             if (best !== entry.accountId) {
-              setActiveCodexAccount(config, best);
-              bindThreadAffinity(threadId, best, now); // rebinds + resets clocks
+              if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
+              bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
               return { status: "selected", accountId: best };
             }
           }
@@ -880,23 +1127,23 @@ export function resolveCodexAccountForThreadDetailed(
       }
       return { status: "selected", accountId: entry.accountId };
     }
-    threadAccountMap.delete(threadId);
+    deleteThreadAffinity(threadId, quotaScope);
   }
 
-  const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true);
+  const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope);
   if (strategyPick) return { status: "selected", accountId: strategyPick };
 
   let active = getEffectiveActiveCodexAccountId(config);
   if (!active) {
-    const selected = pickLowestUsageCodexAccount(config, undefined, now);
+    const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
     if (!selected) return { status: "none" };
-    setActiveCodexAccount(config, selected);
+    if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
     active = selected;
   }
-  if (!isCodexAccountSelectable(config, active, now)) {
-    const fallback = pickLowestUsageCodexAccount(config, active, now);
+  if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
+    const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
     if (fallback) {
-      setActiveCodexAccount(config, fallback);
+      if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
       active = fallback;
     } else if (hasConfiguredPoolAccount(config, active)) {
       return { status: "selected", accountId: active };
@@ -904,15 +1151,15 @@ export function resolveCodexAccountForThreadDetailed(
       return { status: "none" };
     }
   }
-  active = applyQuotaAutoSwitch(config, active, now);
+  active = applyQuotaAutoSwitch(config, active, now, quotaScope);
   active = applyFailureFailover(config, active, now);
   if (!isCodexAccountUsable(config, active)) {
     return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
   }
-  if (isCodexAccountInCooldown(active, now)) {
+  if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
     return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
   }
-  if (threadId) bindThreadAffinity(threadId, active, now);
+  if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
   return { status: "selected", accountId: active };
 }
 
@@ -925,7 +1172,18 @@ export function recordCodexUpstreamOutcome(
   if (!accountId) return;
   const now = meta.now ?? Date.now();
   const outcomeClass = classifyCodexUpstreamOutcome(outcome);
+  const quotaScope = codexQuotaScopeForModel(meta.modelId);
   if (outcomeClass === "success") {
+    const scopedProbe = meta.probeQuotaScope
+      ? scopedHealthFor(accountId, meta.probeQuotaScope)
+      : undefined;
+    if (scopedProbe && meta.probeQuotaScope) {
+      if (scopedProbe.cooldownUntil && probeMayClearCooldown(scopedProbe, meta)) {
+        deleteScopedHealth(accountId, meta.probeQuotaScope);
+      } else if (ownsProbeLease(scopedProbe, meta)) {
+        setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
+      }
+    }
     const current = upstreamHealth.get(accountId);
     const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
     // A leased probe that is still on its own cooldown generation proves the
@@ -961,6 +1219,12 @@ export function recordCodexUpstreamOutcome(
     // A 4xx does not change account health, but it does conclude an in-flight
     // probe — otherwise the lease would never be handed back.
     const current = upstreamHealth.get(accountId);
+    const scopedProbe = meta.probeQuotaScope
+      ? scopedHealthFor(accountId, meta.probeQuotaScope)
+      : undefined;
+    if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
+      setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
+    }
     if (ownsProbeLease(current, meta)) {
       upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
     }
@@ -976,14 +1240,65 @@ export function recordCodexUpstreamOutcome(
       lastFailureStatus,
       lastFailureAt: now,
     });
+    quotaScopedHealth.delete(accountId);
     markAccountNeedsReauth(accountId);
     clearThreadAccountMapForAccount(accountId);
     return;
   }
 
   if (outcomeClass === "quota") {
-    const prior = upstreamHealth.get(accountId);
     const { until, source } = computeQuotaCooldown(meta);
+    // A reset timestamp is an advisory quota-window announcement. When the
+    // selected native model belongs to a confirmed independent group, preserve
+    // it there so a different group (Spark versus the shared native quota) can
+    // still reach upstream. Explicit Retry-After/default 429s remain account-wide.
+    if (source === "reset-derived" && quotaScope) {
+      const prior = scopedHealthFor(accountId, quotaScope);
+      const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
+      const ownsLease = meta.probeQuotaScope === quotaScope && ownsProbeLease(prior, meta);
+      setScopedHealth(accountId, quotaScope, {
+        consecutiveFailures: 0,
+        lastFailureStatus,
+        lastFailureAt: now,
+        cooldownUntil: until,
+        cooldownSince: now,
+        cooldownSource: source,
+        cooldownGeneration,
+        ...(ownsLease
+          ? { lastProbeAt: now }
+          : {
+            ...(prior?.probeLeaseId !== undefined ? { probeLeaseId: prior.probeLeaseId } : {}),
+            ...(prior?.probeLeaseGeneration !== undefined ? { probeLeaseGeneration: prior.probeLeaseGeneration } : {}),
+            ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
+        }),
+      });
+      // The shared native scope is the existing account-wide native behavior:
+      // threads must leave it and new requests should prefer an eligible account.
+      // Spark remains isolated so a same-account Terra/Luna combo fallback can run.
+      if (quotaScope === "shared") {
+        clearThreadAccountMapForAccount(accountId);
+        notePoolRotationFailure(POOL_KEY_CODEX, accountId);
+        if (getEffectiveActiveCodexAccountId(config) === accountId) {
+          // Same-request 429 retry already picked via excludeAccountId — reuse it so
+          // round-robin does not advance the ring a second time.
+          const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
+            ? meta.promoteAccountId
+            : null;
+          const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
+          if (fallback) promoteActiveCodexAccount(config, fallback);
+        }
+      }
+      return;
+    }
+
+    // A scoped probe that received an account-wide throttle is no longer live.
+    const scopedProbe = meta.probeQuotaScope
+      ? scopedHealthFor(accountId, meta.probeQuotaScope)
+      : undefined;
+    if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
+      setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
+    }
+    const prior = upstreamHealth.get(accountId);
     // Every cooldown write bumps the generation so a probe issued against the
     // previous cooldown can no longer clear this one (#433).
     const cooldownGeneration = (prior?.cooldownGeneration ?? 0) + 1;
@@ -1022,6 +1337,12 @@ export function recordCodexUpstreamOutcome(
 
   // transient (connect_error / timeout / 5xx)
   const current = upstreamHealth.get(accountId);
+  const scopedProbe = meta.probeQuotaScope
+    ? scopedHealthFor(accountId, meta.probeQuotaScope)
+    : undefined;
+  if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
+    setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
+  }
   // A transient failure concludes an owning probe; an unrelated 5xx must not
   // consume someone else's live lease or drop hard-cooldown bookkeeping (#433).
   const transientBase = ownsProbeLease(current, meta) ? withProbeLeaseReleased(current!, now) : current;
@@ -1055,8 +1376,7 @@ export function recordCodexUpstreamOutcome(
   // must not delete a newer healthy binding to account B (race: T→A, A fails,
   // T→B, late A failure must not delete B's mapping).
   if (failoverReady && meta.threadId) {
-    const bound = threadAccountMap.get(meta.threadId);
-    if (bound?.accountId === accountId) threadAccountMap.delete(meta.threadId);
+    deleteThreadAffinitiesForAccount(meta.threadId, accountId);
   }
   // Once the account is past the failover streak, clear every thread still pinned
   // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts
index a567c5d83..3d536afd1 100644
--- a/src/server/responses/compact.ts
+++ b/src/server/responses/compact.ts
@@ -51,6 +51,7 @@ import {
   isCodexAuthContextUsable,
   resolveCodexAuthContext,
   codexProbeLeaseId,
+  codexProbeQuotaScope,
   type CodexAuthContext,
 } from "../../codex/auth-context";
 import {
@@ -217,7 +218,7 @@ export async function handleResponsesCompact(
     const headers = new Headers({ "content-type": "application/json" });
     try {
       if (route.codexAccountMode) {
-        authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
+        authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: selectedModelId });
         const selected = headersForCodexAuthContext(req.headers, authCtx);
         compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
         for (const name of FORWARD_HEADERS) {
@@ -255,12 +256,17 @@ export async function handleResponsesCompact(
     const compactUrl = `${base}/responses/compact`;
     const compactThreadId = req.headers.get("x-codex-parent-thread-id");
     const connectMs = config.connectTimeoutMs ?? 200_000;
-    const recordCompactPoolOutcome = (outcome: CodexUpstreamOutcome, meta: { retryAfter?: string | null } = {}) => {
+    const recordCompactPoolOutcome = (
+      outcome: CodexUpstreamOutcome,
+      meta: { retryAfter?: string | null; resetAt?: unknown | unknown[] } = {},
+    ) => {
       if (!usesCodexForwardPoolAuth(authCtx, route.provider)) return;
       recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
         ...meta,
         threadId: compactThreadId,
+        modelId: selectedModelId,
         probeLeaseId: codexProbeLeaseId(authCtx),
+        probeQuotaScope: codexProbeQuotaScope(authCtx),
       });
     };
     let upstream: Response;
@@ -283,28 +289,30 @@ export async function handleResponsesCompact(
         { abortSignal: req.signal, label: safeHostLabel(compactUrl) },
       );
     } catch (err) {
-      if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
+      if (req.signal.aborted) {
+        recordCompactPoolOutcome(499);
+        return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
+      }
       const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
       recordCompactPoolOutcome(outcome);
       return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
     }
     const retryAfter = upstream.headers.get("retry-after");
+    const resetAt = [
+      upstream.headers.get("x-codex-primary-reset-at"),
+      upstream.headers.get("x-codex-secondary-reset-at"),
+      upstream.headers.get("x-codex-tertiary-reset-at"),
+    ].filter(Boolean);
     const buffered = await bufferCompactResponse(upstream, req.signal);
     // Record pool health only after the body is fully delivered (or definitively failed).
     // A premature 200 would clear soft-avoid while the client still sees a buffer 502.
     if (buffered.status === 499) {
+      recordCompactPoolOutcome(499);
       return buffered;
     }
-    if (upstream.ok && buffered.status >= 500) {
-      // The upstream account returned 200 — it is healthy. The buffering failure
-      // (oversized body exceeding COMPACT_RESPONSE_MAX_BYTES, or a rare mid-read
-      // reset on a small JSON payload) is a local proxy issue, not account flakiness.
-      // Record the upstream status so a deterministic payload-size limit does not
-      // soft-avoid a healthy account and rotate a thread for 30s.
-      recordCompactPoolOutcome(upstream.status, { retryAfter });
-    } else {
-      recordCompactPoolOutcome(upstream.status, { retryAfter });
-    }
+    // Always record the real upstream status: a local buffering failure after a
+    // 200 upstream response must not soft-avoid a healthy account or rotate a thread.
+    recordCompactPoolOutcome(upstream.status, { retryAfter, resetAt });
     return buffered;
   }
 
diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts
index 819ce3427..74ab53725 100644
--- a/src/server/responses/core.ts
+++ b/src/server/responses/core.ts
@@ -67,6 +67,7 @@ import {
   isCodexAuthContextUsable,
   resolveCodexAuthContext,
   codexProbeLeaseId,
+  codexProbeQuotaScope,
   releaseCodexAuthContextProbeLease,
   stripCodexRuntimeProviderFields,
   type CodexAuthContext,
@@ -320,7 +321,7 @@ async function retryCodexPoolOnAlternateAccount(
       req.headers,
       config,
       "pool",
-      { excludeAccountId: firstAuthCtx.accountId },
+      { excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId },
     );
   } catch (error) {
     if (
@@ -342,7 +343,9 @@ async function retryCodexPoolOnAlternateAccount(
     recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
       ...quotaMeta,
       threadId: req.headers.get("x-codex-parent-thread-id"),
+      modelId: route.modelId,
       probeLeaseId: codexProbeLeaseId(firstAuthCtx),
+      probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
       // Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
       ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
     });
@@ -403,6 +406,7 @@ export function codexForwardTerminalOutcomeRecorder(
   config: OcxConfig,
   authCtx: CodexAuthContext,
   provider: OcxProviderConfig,
+  modelId?: string,
   logCtx?: RequestLogContext,
   threadId?: string | null,
 ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
@@ -414,7 +418,9 @@ export function codexForwardTerminalOutcomeRecorder(
       // prior soft-avoid so a healthy account isn't stuck avoided.
       recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
         threadId,
+        modelId,
         probeLeaseId: codexProbeLeaseId(authCtx),
+        probeQuotaScope: codexProbeQuotaScope(authCtx),
       });
       return;
     }
@@ -432,7 +438,9 @@ export function codexForwardTerminalOutcomeRecorder(
       : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
     recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
       threadId,
+      modelId,
       probeLeaseId: codexProbeLeaseId(authCtx),
+      probeQuotaScope: codexProbeQuotaScope(authCtx),
     });
   };
 }
@@ -668,7 +676,7 @@ async function resolveResponsesCodexAuth(
     if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
     let authCtx: CodexAuthContext;
     if (route.codexAccountMode) {
-      authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
+      authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId });
       options.onCodexAuthContextResolved?.(authCtx);
     } else {
       authCtx = { kind: "main", accountId: null };
@@ -1428,7 +1436,9 @@ export async function handleResponses(
       if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
         recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
           threadId: req.headers.get("x-codex-parent-thread-id"),
+          modelId: route.modelId,
           probeLeaseId: codexProbeLeaseId(authCtx),
+          probeQuotaScope: codexProbeQuotaScope(authCtx),
         });
       }
       const msg = outcome === "timeout"
@@ -1514,6 +1524,7 @@ export async function handleResponses(
       config,
       authCtx,
       route.provider,
+      route.modelId,
       logCtx,
       req.headers.get("x-codex-parent-thread-id"),
     );
@@ -1553,7 +1564,9 @@ export async function handleResponses(
         recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
           ...quotaMeta,
           threadId: req.headers.get("x-codex-parent-thread-id"),
+          modelId: route.modelId,
           probeLeaseId: codexProbeLeaseId(authCtx),
+          probeQuotaScope: codexProbeQuotaScope(authCtx),
         });
       }
     }
diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md
index a0da13487..a0e403eb5 100644
--- a/structure/08_openai-provider-tiers.md
+++ b/structure/08_openai-provider-tiers.md
@@ -18,6 +18,11 @@ engine. Direct short-circuits that engine before pool state is read or mutated a
 current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API
 provider may not fall through to Codex-login credentials.
 
+An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset-derived native-model
+429 is advisory and remains within its confirmed quota group: `gpt-5.3-codex-spark` is separate from
+the shared native group (including GPT-5.6 Terra/Luna). This allows a same-account combo to test an
+independent quota without allowing fallbacks that share the exhausted quota.
+
 ```text
 gpt-5.6-sol                         # openai; Pool or Direct follows the provider option
 openai-apikey/gpt-5.6-sol           # OpenAI API key
diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts
index cd7e7d9bc..98c6dcfcc 100644
--- a/tests/codex-auth-context.test.ts
+++ b/tests/codex-auth-context.test.ts
@@ -257,6 +257,160 @@ describe("Codex auth context", () => {
     }
   });
 
+  test("reset-derived native cooldowns stay within their confirmed quota group", async () => {
+    const originalNow = Date.now;
+    const now = 1_800_000_000_000;
+    const cfg = config();
+    const headers = new Headers({ authorization: "Bearer main_token" });
+    saveCodexAccountCredential("pool-a", {
+      accessToken: "pool_token",
+      refreshToken: "pool_refresh",
+      expiresAt: now + 24 * 60 * 60_000,
+      chatgptAccountId: "pool_acc",
+    });
+    try {
+      Date.now = () => now;
+      const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1000);
+      recordCodexUpstreamOutcome(cfg, "pool-a", 429, {
+        now,
+        resetAt,
+        modelId: "gpt-5.3-codex-spark",
+      });
+
+      // Spark owns a separate quota, so Terra can use the same account.
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" }))
+        .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" });
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" }))
+        .rejects.toBeInstanceOf(CodexAccountCooldownError);
+
+      recordCodexUpstreamOutcome(cfg, "pool-a", 429, {
+        now,
+        resetAt,
+        modelId: "gpt-5.6-terra",
+      });
+
+      // Terra and Luna stay in the shared native quota group, while Spark keeps
+      // its independent cooldown instead of being overwritten by Terra's 429.
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" }))
+        .rejects.toBeInstanceOf(CodexAccountCooldownError);
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" }))
+        .rejects.toBeInstanceOf(CodexAccountCooldownError);
+
+      // An explicit retry directive is still account-wide, regardless of the
+      // originating model's otherwise independent quota group.
+      recordCodexUpstreamOutcome(cfg, "pool-a", 429, {
+        now,
+        retryAfter: "60",
+        modelId: "gpt-5.3-codex-spark",
+      });
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" }))
+        .rejects.toBeInstanceOf(CodexAccountCooldownError);
+    } finally {
+      Date.now = originalNow;
+    }
+  });
+
+  test("a scoped cooldown uses another account without moving the independent native scope", async () => {
+    const originalNow = Date.now;
+    const now = 1_800_000_000_000;
+    const cfg = config();
+    cfg.codexAccounts?.push({
+      id: "pool-b",
+      email: "pool-b@example.test",
+      isMain: false,
+      chatgptAccountId: "pool_b_acc",
+    });
+    const headers = new Headers({
+      authorization: "Bearer main_token",
+      "x-codex-parent-thread-id": "independent-scope-thread",
+    });
+    for (const accountId of ["pool-a", "pool-b"]) {
+      saveCodexAccountCredential(accountId, {
+        accessToken: `${accountId}-token`,
+        refreshToken: `${accountId}-refresh`,
+        expiresAt: now + 24 * 60 * 60_000,
+        chatgptAccountId: `${accountId}-acc`,
+      });
+    }
+    try {
+      Date.now = () => now;
+      // Establish the shared-scope binding first. The Spark fallback below must
+      // create a second binding rather than replacing this one.
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" }))
+        .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" });
+
+      recordCodexUpstreamOutcome(cfg, "pool-a", 429, {
+        now,
+        resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000),
+        modelId: "gpt-5.3-codex-spark",
+      });
+
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" }))
+        .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" });
+      expect(cfg.activeCodexAccountId).toBe("pool-a");
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-terra" }))
+        .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" });
+      // This second Spark request proves routing retained the peer choice for
+      // the Spark affinity instead of relying on an auth-layer substitution.
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" }))
+        .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" });
+    } finally {
+      Date.now = originalNow;
+    }
+  });
+
+  test("a successful Spark recovery probe leaves the shared native cooldown intact", async () => {
+    const originalNow = Date.now;
+    const now = 1_800_000_000_000;
+    const cfg = config();
+    const headers = new Headers({ authorization: "Bearer main_token" });
+    saveCodexAccountCredential("pool-a", {
+      accessToken: "pool_token",
+      refreshToken: "pool_refresh",
+      expiresAt: now + 24 * 60 * 60_000,
+      chatgptAccountId: "pool_acc",
+    });
+    try {
+      Date.now = () => now;
+      const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000);
+      recordCodexUpstreamOutcome(cfg, "pool-a", 429, {
+        now,
+        resetAt,
+        modelId: "gpt-5.3-codex-spark",
+      });
+      recordCodexUpstreamOutcome(cfg, "pool-a", 429, {
+        now,
+        resetAt,
+        modelId: "gpt-5.6-terra",
+      });
+
+      const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS;
+      Date.now = () => probeAt;
+      const sparkProbe = await resolveCodexAuthContext(headers, cfg, "pool", {
+        modelId: "gpt-5.3-codex-spark",
+      });
+      expect(sparkProbe).toMatchObject({
+        kind: "pool",
+        probeQuotaScope: "spark",
+      });
+
+      recordCodexUpstreamOutcome(cfg, "pool-a", 200, {
+        now: probeAt + 1,
+        modelId: "gpt-5.3-codex-spark",
+        probeLeaseId: (sparkProbe as { probeLeaseId?: string }).probeLeaseId,
+        probeQuotaScope: (sparkProbe as { probeQuotaScope?: "spark" }).probeQuotaScope,
+      });
+
+      Date.now = () => probeAt + 1;
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.3-codex-spark" }))
+        .resolves.toMatchObject({ kind: "pool", accountId: "pool-a" });
+      await expect(resolveCodexAuthContext(headers, cfg, "pool", { modelId: "gpt-5.6-luna" }))
+        .resolves.toMatchObject({ kind: "pool", probeQuotaScope: "shared" });
+    } finally {
+      Date.now = originalNow;
+    }
+  });
+
   test("expired thread affinity fails closed instead of falling back to main auth", async () => {
     const now = 1_800_000_000_000;
     saveCodexAccountCredential("pool-a", {
@@ -424,6 +578,20 @@ describe("cooldown error surface", () => {
     expect(message).toContain("account-…3c21");
   });
 
+  test("message identifies a model-scoped cooldown without implying an account-wide block", () => {
+    const err = new CodexAccountCooldownError(
+      "acct_9f3c21",
+      Date.parse("2026-07-26T10:00:00.000Z"),
+      "reset-derived",
+      "spark",
+    );
+
+    const message = cooldownErrorMessage(err);
+
+    expect(message).toContain("Spark quota is cooling down");
+    expect(message).not.toContain("Selected Codex account (account-…3c21) is cooling down");
+  });
+
   test("the main login renders as the alias users actually type", () => {
     const err = new CodexAccountCooldownError(MAIN_CODEX_ACCOUNT_ID, Date.now() + 60_000);
 
diff --git a/tests/codex-pool-rotation.test.ts b/tests/codex-pool-rotation.test.ts
index 16a7f32ea..f16f84dfb 100644
--- a/tests/codex-pool-rotation.test.ts
+++ b/tests/codex-pool-rotation.test.ts
@@ -156,6 +156,30 @@ describe("accountPoolStrategy new-session routing", () => {
     expect(new Set(picks).size).toBe(3);
   });
 
+  test("an independent native scope does not advance the shared round-robin cursor", () => {
+    const config = makeThreeAccountConfig({
+      accountPoolStrategy: "round-robin",
+      accountPoolStickyLimit: 1,
+    });
+    const now = 1_800_000_000_000;
+    updateAccountQuota("a", 10);
+    updateAccountQuota("b", 10);
+    updateAccountQuota("c", 10);
+
+    expect(resolveCodexAccountForThread(null, config, now, "shared")).toBe("a");
+    recordCodexUpstreamOutcome(config, "a", 429, {
+      now: now + 1,
+      resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000),
+      modelId: "gpt-5.3-codex-spark",
+    });
+
+    // Spark skips A in its own ring. The next shared request still takes B,
+    // as if the Spark selection had never advanced the shared ring.
+    expect(resolveCodexAccountForThread(null, config, now + 2, "spark")).toBe("b");
+    expect(getEffectiveActiveCodexAccountId(config)).toBe("a");
+    expect(resolveCodexAccountForThread(null, config, now + 3, "shared")).toBe("b");
+  });
+
   test("affinity still wins over round-robin", () => {
     const config = makeThreeAccountConfig({ accountPoolStrategy: "round-robin" });
     updateAccountQuota("a", 10);
@@ -395,6 +419,38 @@ describe("accountPoolStrategy new-session routing", () => {
     expect(pickAlternateCodexAccount(config, "a")).toBe("b");
   });
 
+  test("scoped reset 429s retain strategy while excluding only the affected native quota", () => {
+    const config = makeThreeAccountConfig({
+      accountPoolStrategy: "fill-first",
+      activeCodexAccountId: "a",
+      autoSwitchThreshold: 80,
+    });
+    updateAccountQuota("a", 10);
+    updateAccountQuota("b", 50);
+    updateAccountQuota("c", 5);
+    const now = Date.now();
+    const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000);
+
+    recordCodexUpstreamOutcome(config, "b", 429, {
+      now,
+      resetAt,
+      modelId: "gpt-5.3-codex-spark",
+    });
+
+    // Fill-first would normally advance a → b, but b is unavailable only to Spark.
+    expect(pickAlternateCodexAccount(config, "a", now + 1, "spark")).toBe("c");
+    expect(pickAlternateCodexAccount(config, "a", now + 1, "shared")).toBe("b");
+
+    recordCodexUpstreamOutcome(config, "a", 429, {
+      now,
+      resetAt,
+      modelId: "gpt-5.6-terra",
+      promoteAccountId: "b",
+    });
+    expect(getEffectiveActiveCodexAccountId(config)).toBe("b");
+    expect(config.activeCodexAccountId).toBe("a");
+  });
+
   test("RR 429 promotes via ring, not lowest usage", () => {
     const config = makeThreeAccountConfig({
       accountPoolStrategy: "round-robin",
diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts
index ce1e14cbb..8e11154ee 100644
--- a/tests/codex-routing.test.ts
+++ b/tests/codex-routing.test.ts
@@ -16,6 +16,8 @@ import {
   clearThreadAccountMapForAccount,
   computeCodexUsageScore,
   getCodexAccountCooldownUntil,
+  getEffectiveActiveCodexAccountId,
+  getCodexQuotaHealthSnapshot,
   getCodexAccountSoftAvoidUntil,
   getCodexUpstreamHealth,
   isCodexAccountInCooldown,
@@ -314,6 +316,47 @@ describe("codex routing", () => {
     expect(resolveCodexAccountForThread("quota-next", config)).toBe("b");
   });
 
+  test("shared native reset cooldown clears affinity and rotates the active account", () => {
+    const config = makeConfig();
+    const now = 1_800_000_000_000;
+    updateAccountQuota("a", 10);
+    updateAccountQuota("b", 20);
+    expect(resolveCodexAccountForThread("shared-quota-existing", config, now)).toBe("a");
+
+    recordCodexUpstreamOutcome(config, "a", 429, {
+      now,
+      resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000),
+      modelId: "gpt-5.6-terra",
+    });
+
+    expect(config.activeCodexAccountId).toBe("b");
+    expect(resolveCodexAccountForThread("shared-quota-existing", config, now + 1)).toBe("b");
+  });
+
+  test("independent native quota scopes keep separate thread affinities", () => {
+    const config = makeConfig();
+    const now = 1_800_000_000_000;
+    updateAccountQuota("a", 10);
+    updateAccountQuota("b", 20);
+
+    // A known shared-model request binds A for this thread.
+    expect(resolveCodexAccountForThread("scoped-thread", config, now, "shared")).toBe("a");
+
+    recordCodexUpstreamOutcome(config, "a", 429, {
+      now: now + 1,
+      resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000),
+      modelId: "gpt-5.3-codex-spark",
+    });
+
+    // Spark sees its scoped cooldown and binds B without moving the global
+    // active account or the same thread's shared-scope affinity.
+    expect(resolveCodexAccountForThread("scoped-thread", config, now + 2, "spark")).toBe("b");
+    expect(config.activeCodexAccountId).toBe("a");
+    expect(getEffectiveActiveCodexAccountId(config)).toBe("a");
+    expect(resolveCodexAccountForThread("scoped-thread", config, now + 3, "shared")).toBe("a");
+    expect(resolveCodexAccountForThread("scoped-thread", config, now + 4, "spark")).toBe("b");
+  });
+
   test("2xx responses clear transient failures without clearing an unexpired cooldown", () => {
     const config = makeConfig();
     const now = 1_800_000_000_000;
@@ -469,6 +512,28 @@ describe("codex routing", () => {
     expect(health?.lastFailureStatus).toBe(429);
   });
 
+  test("clearCodexAccountCooldown lifts every live native-model cooldown", () => {
+    const config = makeConfig();
+    const now = 1_800_000_000_000;
+    const resetAt = Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000);
+    recordCodexUpstreamOutcome(config, "a", 429, {
+      now,
+      resetAt,
+      modelId: "gpt-5.3-codex-spark",
+    });
+    recordCodexUpstreamOutcome(config, "a", 429, {
+      now,
+      resetAt,
+      modelId: "gpt-5.6-terra",
+    });
+
+    expect(getCodexQuotaHealthSnapshot("a", "spark", now + 1)).not.toBeNull();
+    expect(getCodexQuotaHealthSnapshot("a", "shared", now + 1)).not.toBeNull();
+    expect(clearCodexAccountCooldown("a", now + 1)).toBe(true);
+    expect(getCodexQuotaHealthSnapshot("a", "spark", now + 1)).toBeNull();
+    expect(getCodexQuotaHealthSnapshot("a", "shared", now + 1)).toBeNull();
+  });
+
   test("clearing is a no-op without a live cooldown", () => {
     const config = makeConfig();
     const now = 1_800_000_000_000;
@@ -771,6 +836,27 @@ describe("codex routing", () => {
     expect(resolveCodexAccountForThread("lru-0", config, now + CODEX_THREAD_AFFINITY_MAX_ENTRIES + 2)).toBe("b");
   });
 
+  test("thread affinity LRU cap includes legacy and native quota scopes", () => {
+    const config = makeConfig();
+    const now = 1_800_000_000_000;
+    const threads = Math.floor(CODEX_THREAD_AFFINITY_MAX_ENTRIES / 3) + 1;
+
+    for (let i = 0; i < threads; i++) {
+      const threadId = `scoped-lru-${i}`;
+      expect(resolveCodexAccountForThread(threadId, config, now + i * 3)).toBe("a");
+      expect(resolveCodexAccountForThread(threadId, config, now + i * 3 + 1, "shared")).toBe("a");
+      expect(resolveCodexAccountForThread(threadId, config, now + i * 3 + 2, "spark")).toBe("a");
+    }
+
+    // The oldest legacy entry was evicted, while the same thread's later
+    // shared and Spark entries remain independently affined to A.
+    config.activeCodexAccountId = "b";
+    const after = now + threads * 3;
+    expect(resolveCodexAccountForThread("scoped-lru-0", config, after, "shared")).toBe("a");
+    expect(resolveCodexAccountForThread("scoped-lru-0", config, after + 1, "spark")).toBe("a");
+    expect(resolveCodexAccountForThread("scoped-lru-0", config, after + 2)).toBe("b");
+  });
+
   test("generation mismatch invalidates a mapped thread before reuse", () => {
     const config = makeConfig();
     updateAccountQuota("a", 10);
diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts
index 4a6da3c0e..cb50a15fd 100644
--- a/tests/responses-compaction-routing.test.ts
+++ b/tests/responses-compaction-routing.test.ts
@@ -5,7 +5,20 @@
  * fatals on a compaction turn that came back as an ordinary message.
  */
 import { afterEach, describe, expect, test } from "bun:test";
-import { handleResponses } from "../src/server/responses";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { handleResponses, handleResponsesCompact } from "../src/server/responses";
+import { saveCodexAccountCredential } from "../src/codex/account-store";
+import {
+  CODEX_QUOTA_PROBE_INTERVAL_MS,
+  clearCodexUpstreamHealth,
+  recordCodexUpstreamOutcome,
+} from "../src/codex/routing";
+import {
+  releaseCodexAuthContextProbeLease,
+  resolveCodexAuthContext,
+} from "../src/codex/auth-context";
 import { supportsNativeResponsesCompactEndpoint } from "../src/providers/openai-tiers";
 import type { OcxConfig, OcxProviderConfig } from "../src/types";
 
@@ -30,11 +43,33 @@ function keyProviderConfig(overrides: Partial = {}): OcxConfi
   } as unknown as OcxConfig;
 }
 
-function compactionRequest(body: Record): Request {
+function nativePoolConfig(): OcxConfig {
+  return {
+    defaultProvider: "openai",
+    activeCodexAccountId: "pool-a",
+    providers: {
+      openai: {
+        adapter: "openai-responses",
+        baseUrl: "https://chatgpt.com/backend-api/codex",
+        authMode: "forward",
+        codexAccountMode: "pool",
+      },
+    },
+    codexAccounts: [{
+      id: "pool-a",
+      email: "pool@example.test",
+      isMain: false,
+      chatgptAccountId: "pool_acc",
+    }],
+  } as OcxConfig;
+}
+
+function compactionRequest(body: Record, signal?: AbortSignal): Request {
   return new Request("http://localhost/v1/responses", {
     method: "POST",
     headers: { "content-type": "application/json" },
     body: JSON.stringify(body),
+    signal,
   });
 }
 
@@ -112,6 +147,201 @@ describe("supportsNativeResponsesCompactEndpoint (#422)", () => {
   });
 });
 
+describe("native Codex pool compaction", () => {
+  test("keeps a Spark reset cooldown separate from a Terra compact request (#590)", async () => {
+    const testDir = mkdtempSync(join(tmpdir(), "ocx-compact-scope-"));
+    const previousOpencodexHome = process.env.OPENCODEX_HOME;
+    const previousCodexHome = process.env.CODEX_HOME;
+    const config = nativePoolConfig();
+    const resetAt = Math.floor((Date.now() + 4 * 24 * 60 * 60_000) / 1_000);
+    let sparkPhase = true;
+    try {
+      process.env.OPENCODEX_HOME = testDir;
+      process.env.CODEX_HOME = testDir;
+      clearCodexUpstreamHealth();
+      saveCodexAccountCredential("pool-a", {
+        accessToken: "pool-access-token",
+        refreshToken: "pool-refresh-token",
+        expiresAt: Date.now() + 300_000,
+        chatgptAccountId: "pool_acc",
+      });
+      globalThis.fetch = (async () => {
+        if (sparkPhase) {
+          return Response.json({ error: { message: "Spark quota exhausted" } }, {
+            status: 429,
+            headers: { "x-codex-primary-reset-at": String(resetAt) },
+          });
+        }
+        return jsonResponse(completedPayload("Terra compact response"));
+      }) as typeof fetch;
+      const spark = await handleResponsesCompact(
+        compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" })),
+        config,
+        { model: "", provider: "" },
+      );
+      expect(spark.status).toBe(429);
+
+      sparkPhase = false;
+      const cooledSpark = await handleResponsesCompact(
+        compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" })),
+        config,
+        { model: "", provider: "" },
+      );
+      expect(cooledSpark.status).toBe(429);
+
+      const terra = await handleResponsesCompact(
+        compactionRequest(baseCompactionBody({ model: "gpt-5.6-terra" })),
+        config,
+        { model: "", provider: "" },
+      );
+      expect(terra.status).toBe(200);
+    } finally {
+      globalThis.fetch = originalFetch;
+      clearCodexUpstreamHealth();
+      rmSync(testDir, { recursive: true, force: true });
+      if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
+      else process.env.OPENCODEX_HOME = previousOpencodexHome;
+      if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
+      else process.env.CODEX_HOME = previousCodexHome;
+    }
+  });
+
+  test("a cancelled Spark recovery probe releases its compact lease (#590)", async () => {
+    const testDir = mkdtempSync(join(tmpdir(), "ocx-compact-probe-"));
+    const previousOpencodexHome = process.env.OPENCODEX_HOME;
+    const previousCodexHome = process.env.CODEX_HOME;
+    const originalNow = Date.now;
+    const now = 1_800_000_000_000;
+    const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS;
+    const config = nativePoolConfig();
+    const abort = new AbortController();
+    let markReadStarted!: () => void;
+    let releaseBody!: () => void;
+    const readStarted = new Promise(resolve => { markReadStarted = resolve; });
+    const bodyReleased = new Promise(resolve => { releaseBody = resolve; });
+    try {
+      process.env.OPENCODEX_HOME = testDir;
+      process.env.CODEX_HOME = testDir;
+      Date.now = () => now;
+      clearCodexUpstreamHealth();
+      saveCodexAccountCredential("pool-a", {
+        accessToken: "pool-access-token",
+        refreshToken: "pool-refresh-token",
+        expiresAt: now + 30 * 60_000,
+        chatgptAccountId: "pool_acc",
+      });
+      recordCodexUpstreamOutcome(config, "pool-a", 429, {
+        now,
+        resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000),
+        modelId: "gpt-5.3-codex-spark",
+      });
+      Date.now = () => probeAt;
+      globalThis.fetch = (async () => new Response(new ReadableStream({
+        async pull(controller) {
+          markReadStarted();
+          await bodyReleased;
+          controller.enqueue(new TextEncoder().encode("{\"partial\":"));
+          controller.close();
+        },
+      }), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch;
+      const pending = handleResponsesCompact(
+        compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" }), abort.signal),
+        config,
+        { model: "", provider: "" },
+      );
+      await readStarted;
+      abort.abort();
+      releaseBody();
+      const cancelled = await pending;
+      expect(cancelled.status).toBe(499);
+
+      Date.now = () => probeAt + CODEX_QUOTA_PROBE_INTERVAL_MS;
+      const nextProbe = await resolveCodexAuthContext(
+        new Headers({ authorization: "Bearer main-token" }),
+        config,
+        "pool",
+        { modelId: "gpt-5.3-codex-spark" },
+      );
+      expect(nextProbe).toMatchObject({ probeQuotaScope: "spark" });
+      releaseCodexAuthContextProbeLease(nextProbe);
+    } finally {
+      Date.now = originalNow;
+      globalThis.fetch = originalFetch;
+      clearCodexUpstreamHealth();
+      rmSync(testDir, { recursive: true, force: true });
+      if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
+      else process.env.OPENCODEX_HOME = previousOpencodexHome;
+      if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
+      else process.env.CODEX_HOME = previousCodexHome;
+    }
+  });
+
+  test("a Spark recovery probe releases its compact lease when connect is cancelled (#590)", async () => {
+    const testDir = mkdtempSync(join(tmpdir(), "ocx-compact-connect-probe-"));
+    const previousOpencodexHome = process.env.OPENCODEX_HOME;
+    const previousCodexHome = process.env.CODEX_HOME;
+    const originalNow = Date.now;
+    const now = 1_800_000_000_000;
+    const probeAt = now + CODEX_QUOTA_PROBE_INTERVAL_MS;
+    const config = nativePoolConfig();
+    const abort = new AbortController();
+    let markFetchStarted!: () => void;
+    const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; });
+    try {
+      process.env.OPENCODEX_HOME = testDir;
+      process.env.CODEX_HOME = testDir;
+      Date.now = () => now;
+      clearCodexUpstreamHealth();
+      saveCodexAccountCredential("pool-a", {
+        accessToken: "pool-access-token",
+        refreshToken: "pool-refresh-token",
+        expiresAt: now + 30 * 60_000,
+        chatgptAccountId: "pool_acc",
+      });
+      recordCodexUpstreamOutcome(config, "pool-a", 429, {
+        now,
+        resetAt: Math.floor((now + 4 * 24 * 60 * 60_000) / 1_000),
+        modelId: "gpt-5.3-codex-spark",
+      });
+      Date.now = () => probeAt;
+      globalThis.fetch = ((_url: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => {
+        const signal = init?.signal;
+        if (!signal) throw new Error("expected compact request abort signal");
+        signal.addEventListener("abort", () => reject(signal.reason), { once: true });
+        markFetchStarted();
+      })) as typeof fetch;
+      const pending = handleResponsesCompact(
+        compactionRequest(baseCompactionBody({ model: "gpt-5.3-codex-spark" }), abort.signal),
+        config,
+        { model: "", provider: "" },
+      );
+      await fetchStarted;
+      abort.abort();
+      const cancelled = await pending;
+      expect(cancelled.status).toBe(499);
+
+      Date.now = () => probeAt + CODEX_QUOTA_PROBE_INTERVAL_MS;
+      const nextProbe = await resolveCodexAuthContext(
+        new Headers({ authorization: "Bearer main-token" }),
+        config,
+        "pool",
+        { modelId: "gpt-5.3-codex-spark" },
+      );
+      expect(nextProbe).toMatchObject({ probeQuotaScope: "spark" });
+      releaseCodexAuthContextProbeLease(nextProbe);
+    } finally {
+      Date.now = originalNow;
+      globalThis.fetch = originalFetch;
+      clearCodexUpstreamHealth();
+      rmSync(testDir, { recursive: true, force: true });
+      if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
+      else process.env.OPENCODEX_HOME = previousOpencodexHome;
+      if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
+      else process.env.CODEX_HOME = previousCodexHome;
+    }
+  });
+});
+
 describe("routed compaction for key-mode openai-responses (#422)", () => {
   test("rewrites the wire: no trigger, no tools, summarizer prompt present", async () => {
     const bodies: Array> = [];
diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts
index c464ed4a0..cd7fe3659 100644
--- a/tests/server-combo-failover-e2e.test.ts
+++ b/tests/server-combo-failover-e2e.test.ts
@@ -850,6 +850,53 @@ describe("server combo failover 030 activation matrix", () => {
     expect(getCodexUpstreamHealth(rawAccountId)?.cooldownSource).toBe("retry-after");
   });
 
+  test("Spark reset cooldown fails over to the shared native quota on the same account (#590)", async () => {
+    const rawAccountId = "spark-scope-account";
+    const config = comboConfig({
+      openai: {
+        adapter: "openai-responses",
+        baseUrl: "https://chatgpt.com/backend-api/codex",
+        authMode: "forward",
+        codexAccountMode: "pool",
+      },
+    }, [
+      { provider: "openai", model: "gpt-5.3-codex-spark" },
+      { provider: "openai", model: "gpt-5.6-terra" },
+    ]);
+    config.codexAccounts = [{
+      id: rawAccountId,
+      email: "pool@example.test",
+      isMain: false,
+      logLabel: "pspark1",
+    }];
+    config.activeCodexAccountId = rawAccountId;
+    config.autoSwitchThreshold = 0;
+    saveCodexAccountCredential(rawAccountId, {
+      accessToken: "pool-access-token",
+      refreshToken: "pool-refresh-token",
+      expiresAt: Date.now() + 300_000,
+      chatgptAccountId: "acct-pool-spark",
+    });
+
+    const resetAt = Math.floor((Date.now() + 4 * 24 * 60 * 60_000) / 1000);
+    let upstreamCalls = 0;
+    customTransientResponse = async () => {
+      upstreamCalls += 1;
+      if (upstreamCalls === 1) {
+        return Response.json({ error: { message: "Spark quota exhausted" } }, {
+          status: 429,
+          headers: { "x-codex-primary-reset-at": String(resetAt) },
+        });
+      }
+      return Response.json(responsesSuccess("Terra fallback", "gpt-5.6-terra"));
+    };
+
+    const response = await post(config);
+    expect(response.status).toBe(200);
+    expect(upstreamCalls).toBe(2);
+    expect(await response.json()).toMatchObject({ model: "gpt-5.6-terra" });
+  });
+
   test("keeps a failed estimate on A without overwriting B reported usage", async () => {
     customUsageEstimate = model => model === "m1" ? 41 : undefined;
     customFetchResponse = async request => {

From f492f7d4381e0b5cfd2ff6b6e342f0f7f11d626d Mon Sep 17 00:00:00 2001
From: Charly M <35889038+ACJF00@users.noreply.github.com>
Date: Wed, 29 Jul 2026 06:59:06 +0200
Subject: [PATCH 06/16] feat(gui): show reset credit expiration time (#613)

* feat(gui): show reset credit expiration time

* fix(gui): respect selected locale for credit dates
---
 .../components/codex-account-pool-helpers.tsx | 12 +++----
 .../components/codex-account-pool-utils.ts    | 13 +++++--
 .../components/codex-account-reset-modal.tsx  |  8 ++---
 gui/src/intl-formatters.ts                    | 34 ++++++++++++++++---
 gui/tests/intl-formatters.test.ts             | 24 +++++++++++++
 5 files changed, 74 insertions(+), 17 deletions(-)
 create mode 100644 gui/tests/intl-formatters.test.ts

diff --git a/gui/src/components/codex-account-pool-helpers.tsx b/gui/src/components/codex-account-pool-helpers.tsx
index 56c439b48..9c67d1ce8 100644
--- a/gui/src/components/codex-account-pool-helpers.tsx
+++ b/gui/src/components/codex-account-pool-helpers.tsx
@@ -1,10 +1,10 @@
-import type { TFn } from "../i18n/shared";
+import type { Locale, TFn } from "../i18n/shared";
 import { IconTicket } from "../icons";
 import type { CodexAccountEntry } from "./codex-account-pool-types";
-import { daysUntil, formatCreditDate } from "./codex-account-pool-utils";
+import { daysUntil, formatCreditDate, formatCreditDateTime } from "./codex-account-pool-utils";
 
-export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, t }: {
-  index: number; grantedAt: string; expiresAt: string; isNext: boolean; t: TFn;
+export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, locale, t }: {
+  index: number; grantedAt: string; expiresAt: string; isNext: boolean; locale: Locale; t: TFn;
 }) {
   const days = daysUntil(expiresAt);
   const urgent = days <= 7;
@@ -18,8 +18,8 @@ export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, t }: {
         {isNext && {t("codexAuth.creditNextBadge")}}
       
       
- {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt) })} - {t("codexAuth.creditExpires", { date: formatCreditDate(expiresAt), days: String(days) })} + {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt, locale) })} + {t("codexAuth.creditExpires", { date: formatCreditDateTime(expiresAt, locale), days: String(days) })}
); diff --git a/gui/src/components/codex-account-pool-utils.ts b/gui/src/components/codex-account-pool-utils.ts index 52d091eba..4462199cc 100644 --- a/gui/src/components/codex-account-pool-utils.ts +++ b/gui/src/components/codex-account-pool-utils.ts @@ -1,7 +1,14 @@ -import { formatCreditDate as formatCreditDateIntl } from "../intl-formatters"; +import { + formatCreditDate as formatCreditDateIntl, + formatCreditDateTime as formatCreditDateTimeIntl, +} from "../intl-formatters"; -export function formatCreditDate(iso: string): string { - return formatCreditDateIntl(iso); +export function formatCreditDate(iso: string, locale?: string): string { + return formatCreditDateIntl(iso, locale); +} + +export function formatCreditDateTime(iso: string, locale?: string): string { + return formatCreditDateTimeIntl(iso, locale); } export function daysUntil(iso: string): number { diff --git a/gui/src/components/codex-account-reset-modal.tsx b/gui/src/components/codex-account-reset-modal.tsx index 102c3e9ba..cc518029b 100644 --- a/gui/src/components/codex-account-reset-modal.tsx +++ b/gui/src/components/codex-account-reset-modal.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { useT } from "../i18n/shared"; +import { useI18n } from "../i18n/shared"; import { IconAlert, IconTicket } from "../icons"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import { CodexCreditItem } from "./codex-account-pool-helpers"; @@ -26,7 +26,7 @@ export function CodexAccountResetModal({ onCancelConfirm: () => void; onRedeem: () => void; }) { - const t = useT(); + const { locale, t } = useI18n(); const dialogRef = useRef(null); useEffect(() => { @@ -61,7 +61,7 @@ export function CodexAccountResetModal({ {creditDetails && creditDetails.length > 0 && (
{creditDetails.map((c, i) => ( - + ))}
)} @@ -87,7 +87,7 @@ export function CodexAccountResetModal({

{t("codexAuth.confirmResetDesc", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

{creditDetails && creditDetails[0] && (

- {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at) })} + {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at, locale) })}

)}

{t("codexAuth.irreversible")}

diff --git a/gui/src/intl-formatters.ts b/gui/src/intl-formatters.ts index 4693170c7..e096931b7 100644 --- a/gui/src/intl-formatters.ts +++ b/gui/src/intl-formatters.ts @@ -19,16 +19,42 @@ export function cachedNumberFormat( return fmt; } -const CREDIT_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { +const CREDIT_DATE_OPTIONS: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric", -}); +}; -export function formatCreditDate(iso: string): string { +const CREDIT_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}; + +const dateFormatters = new Map(); + +function cachedDateFormatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = cacheKey(locale, options); + let fmt = dateFormatters.get(key); + if (!fmt) { + fmt = new Intl.DateTimeFormat(locale, options); + dateFormatters.set(key, fmt); + } + return fmt; +} + +export function formatCreditDate(iso: string, locale?: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "\u2014"; + return cachedDateFormatter(locale, CREDIT_DATE_OPTIONS).format(date); +} + +export function formatCreditDateTime(iso: string, locale?: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return "\u2014"; - return CREDIT_DATE_FORMAT.format(date); + return cachedDateFormatter(locale, CREDIT_DATE_TIME_OPTIONS).format(date); } /** Format a USD cost estimate for display. Returns "—" when unavailable. */ diff --git a/gui/tests/intl-formatters.test.ts b/gui/tests/intl-formatters.test.ts new file mode 100644 index 000000000..fa38faace --- /dev/null +++ b/gui/tests/intl-formatters.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { formatCreditDate, formatCreditDateTime } from "../src/intl-formatters"; + +describe("credit date formatting", () => { + test("keeps the compact date format for grant dates", () => { + const iso = "2026-07-31T12:34:56Z"; + const time = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(new Date(iso)); + + expect(formatCreditDate(iso, "de-DE")).not.toContain(time); + }); + + test("includes the local time for expiration dates", () => { + const iso = "2026-07-31T12:34:56Z"; + + const time = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(new Date(iso)); + + expect(formatCreditDateTime(iso, "de-DE")).toContain(time); + expect(formatCreditDateTime(iso, "de-DE")).not.toBe("—"); + }); + + test("handles invalid dates consistently", () => { + expect(formatCreditDateTime("invalid")).toBe("—"); + }); +}); From 868a9c4351ded2be7bb9e3beb57941f320f7e899 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:15:29 +0200 Subject: [PATCH 07/16] fix(adapters): neutralize Codex CLI 0.145 identity wording (#638) * fix(adapters): neutralize Codex CLI 0.145 identity wording Closes #622 * fix(adapters): consume full GPT-5.x.y identity versions --- src/adapters/identity.ts | 14 ++++++++++++-- tests/identity-neutralize.test.ts | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/adapters/identity.ts b/src/adapters/identity.ts index b156afe8e..655b6a28b 100644 --- a/src/adapters/identity.ts +++ b/src/adapters/identity.ts @@ -14,9 +14,19 @@ * to be a specific first-party client. */ -/** The exact identity line Codex injects for every model. */ +/** Historical exact identity line Codex injected for every model. */ export const CODEX_GPT5_IDENTITY_LINE = "You are Codex, a coding agent based on GPT-5."; +/** Codex CLI 0.145.0+ wording (#622) — still GPT-5 identity, slightly different phrasing. */ +export const CODEX_GPT5_IDENTITY_LINE_AGENT = "You are Codex, an agent based on GPT-5."; + +/** + * Known Codex GPT-5 identity sentences. Narrow: only "coding agent" / "an agent" + GPT-5(.x)? + * Avoid a broad `You are Codex.*` rewrite that could touch unrelated content. + */ +const CODEX_GPT5_IDENTITY_RE = + /You are Codex, (?:a coding agent|an agent) based on GPT-5(?:\.[0-9]+)*\./g; + /** Proxy-neutral replacement: no "opencodex proxy" mention, just the GPT-5/OpenAI disclaimer. */ export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be GPT-5 or to be made by OpenAI."; @@ -27,7 +37,7 @@ export const NEUTRAL_IDENTITY_LINE = "You are a coding agent. Do not claim to be * the leak can't reappear in one adapter while being fixed in another. */ export function neutralizeIdentity(systemText: string): string { - return systemText.replace(CODEX_GPT5_IDENTITY_LINE, NEUTRAL_IDENTITY_LINE); + return systemText.replace(CODEX_GPT5_IDENTITY_RE, NEUTRAL_IDENTITY_LINE); } /** The catalog (static, on-disk) replacement for `base_instructions`. Same neutral wording. */ diff --git a/tests/identity-neutralize.test.ts b/tests/identity-neutralize.test.ts index e0c09867d..2ea559022 100644 --- a/tests/identity-neutralize.test.ts +++ b/tests/identity-neutralize.test.ts @@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { CODEX_GPT5_IDENTITY_LINE, NEUTRAL_IDENTITY_LINE, neutralizeIdentity } from "../src/adapters/identity"; +import { + CODEX_GPT5_IDENTITY_LINE, + CODEX_GPT5_IDENTITY_LINE_AGENT, + NEUTRAL_IDENTITY_LINE, + neutralizeIdentity, +} from "../src/adapters/identity"; import { createGoogleAdapter } from "../src/adapters/google"; import { createKiroAdapter } from "../src/adapters/kiro"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; @@ -23,6 +28,12 @@ describe("identity neutralization — central helper", () => { expect(neutralizeIdentity(SYS)).toBe(NEUTRAL_IDENTITY_LINE); }); + test("replaces the Codex CLI 0.145 'an agent' identity variant (#622)", () => { + expect(neutralizeIdentity(CODEX_GPT5_IDENTITY_LINE_AGENT)).toBe(NEUTRAL_IDENTITY_LINE); + expect(neutralizeIdentity("You are Codex, an agent based on GPT-5.4.")).toBe(NEUTRAL_IDENTITY_LINE); + expect(neutralizeIdentity("You are Codex, an agent based on GPT-5.4.1.")).toBe(NEUTRAL_IDENTITY_LINE); + }); + test("never emits the opencodex proxy identity", () => { const out = neutralizeIdentity(`${SYS}\n\nmore context`); expect(out).not.toMatch(/opencodex proxy/i); @@ -32,6 +43,9 @@ describe("identity neutralization — central helper", () => { test("leaves text without the GPT-5 line unchanged", () => { expect(neutralizeIdentity("plain system text")).toBe("plain system text"); + expect(neutralizeIdentity("You are Codex, helpful for reviewing PRs.")).toBe( + "You are Codex, helpful for reviewing PRs.", + ); }); test("neutral line still forbids GPT-5 / OpenAI self-reporting", () => { From c9bed7c5a6025eaf8a80b4f19f332575bfed8902 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sun, 26 Jul 2026 00:04:02 -0400 Subject: [PATCH 08/16] feat(codex): add account namespace foundation --- .../docs/ja/reference/configuration.md | 10 + .../docs/ko/reference/configuration.md | 10 + .../content/docs/reference/configuration.md | 10 + .../docs/ru/reference/configuration.md | 11 + .../docs/zh-cn/reference/configuration.md | 9 + gui/src/components/use-add-provider-oauth.ts | 6 +- .../add-provider-oauth-url-leak.test.tsx | 54 ++- src/cli/account-auth.ts | 2 +- src/cli/provider.ts | 7 + src/codex/account-id.ts | 34 ++ src/codex/account-lifecycle.ts | 3 +- src/codex/account-namespace-match.ts | 63 +++ src/codex/account-namespaces.ts | 149 ++++++ src/codex/account-usability.ts | 7 +- src/codex/auth-api.ts | 107 ++++- src/codex/auth-collision.ts | 3 +- src/codex/main-account.ts | 10 +- src/codex/routing.ts | 14 +- src/config.ts | 258 ++++++++++- src/oauth/index.ts | 92 +++- src/oauth/login-cli.ts | 12 + src/providers/alibaba-region-migration.ts | 19 +- src/server/management/combo-routes.ts | 7 + src/server/management/oauth-account-routes.ts | 15 +- src/server/management/provider-routes.ts | 5 + src/types.ts | 6 + tests/alibaba-region-migration.test.ts | 52 +++ tests/alibaba-region-startup.test.ts | 28 ++ tests/cli-account.test.ts | 27 +- tests/cli-provider.test.ts | 32 ++ tests/codex-account-namespaces.test.ts | 349 ++++++++++++++ tests/codex-auth-api.test.ts | 405 +++++++++++++++- tests/combo-management-api.test.ts | 21 + tests/config-user-edits.test.ts | 37 ++ tests/config.test.ts | 183 ++++++++ tests/kiro-review-regressions.test.ts | 8 +- tests/management-provider-validation.test.ts | 38 ++ tests/oauth-public-surface.test.ts | 436 +++++++++++++++++- tests/server-auth.test.ts | 2 + 39 files changed, 2466 insertions(+), 75 deletions(-) create mode 100644 src/codex/account-id.ts create mode 100644 src/codex/account-namespace-match.ts create mode 100644 src/codex/account-namespaces.ts create mode 100644 tests/codex-account-namespaces.test.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 2cf4d114a..10e6c10c9 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -51,6 +51,7 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool の新しいセッション rotation 戦略。**新しいセッションのみ**に適用され、既存 thread id は affinity を維持します。`quota`(既定)— アクティブアカウントが `autoSwitchThreshold` を超えたら既知 usage 最小を選択。`round-robin` — 適格アカウント間を smooth weighted で均等分散。`fill-first` — cooldown、使用不可、または(設定時)`autoSwitchThreshold` までアクティブアカウントを使い切り(未知 usage は強制切替しない)、安定ソート順で次へ。 | @@ -63,6 +64,15 @@ namespaced selected id を bare id に変えます。 | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 選択型の proactive OAuth 更新と Codex アカウント warmup ポリシー。フィールドは下で説明します。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS で追加で許可する正確な origin。loopback origin は常に許可します。 | +`codexAccountNamespaces` のキーは公開 selector です。長さは 1〜64 文字、先頭と末尾は ASCII +英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 +値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す +`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 +namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id +や他の selector target も selector と再利用できません。raw account id と email は +非公開のままにし、selector を公開名として使ってください。この foundation layer では map は inert で、 +model picker entry の作成、session の固定、Pool / Direct routing の変更は行いません。 + `maxConcurrentThreadsPerSession` は `config.json` キーではなく `PUT /api/v2` で使う camel-case フィールドです。`ocx v2 threads ` は対応する `max_concurrent_threads_per_session` 値を Codex の `$CODEX_HOME/config.toml` 内 `[features.multi_agent_v2]` に保存します。その table ができるように v2 を先にオンにしてください。 diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index f91e3d116..dc55a2de9 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -52,6 +52,7 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool의 새 세션 rotation 전략. **새 세션에만** 적용되며 기존 thread id는 affinity를 유지합니다. `quota`(기본) — 활성 계정이 `autoSwitchThreshold`를 넘으면 알려진 usage가 가장 낮은 계정 선택. `round-robin` — 적격 계정 간 smooth weighted 균등 분배. `fill-first` — cooldown, 사용 불가 또는(설정 시) `autoSwitchThreshold`까지 활성 계정을 소진(알 수 없는 usage는 강제 전환하지 않음)한 뒤 안정 정렬 순으로 다음 계정. | @@ -64,6 +65,15 @@ namespaced selected id를 bare id로 바꿉니다. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | 선택형 proactive OAuth 갱신 및 Codex 계정 warmup 정책. 필드는 아래에 설명합니다. | | `corsAllowOrigins?` | `string[]` | `[]` | CORS에서 추가로 허용할 정확한 origin. loopback origin은 항상 허용합니다. | +`codexAccountNamespaces` 키는 공개 selector입니다. 길이는 1~64자이고 시작과 끝은 ASCII 영숫자여야 +하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. +값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. +provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 +combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector +target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 +유지하고 selector를 공개 이름으로 사용하세요. 이 foundation layer에서 map은 inert하며 model picker +entry 생성, session 고정, Pool / Direct routing 변경을 수행하지 않습니다. + `maxConcurrentThreadsPerSession`은 `config.json` 키가 아니라 `PUT /api/v2`에서 쓰는 camel-case 필드입니다. `ocx v2 threads `은 대응하는 `max_concurrent_threads_per_session` 값을 Codex의 `$CODEX_HOME/config.toml` 안 `[features.multi_agent_v2]`에 저장합니다. 해당 table이 생기도록 v2를 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 01e2aa6f3..4a4fb90b2 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -57,6 +57,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session rotation strategy for the Codex pool. Applies to **new sessions only**; existing thread ids keep affinity. `quota` — today's default: pick the lowest known usage when the active account crosses `autoSwitchThreshold`. `round-robin` — even spread across eligible accounts via smooth weighted selection. `fill-first` — keep the active account until it cools down, becomes unusable, or crosses `autoSwitchThreshold` when set (unknown usage does not force a switch), then advance to the next eligible account in stable sorted order. | @@ -70,6 +71,15 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `tokenGuardian?` | `OcxTokenGuardianConfig` | off | Optional proactive OAuth refresh and Codex-account warmup policy; fields are listed below. | | `corsAllowOrigins?` | `string[]` | `[]` | Additional exact origins allowed by CORS. Loopback origins are always allowed. | +`codexAccountNamespaces` keys are public selectors: 1–64 characters, starting and ending with an +ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside; reserved JavaScript object +names are rejected. Each value is either a valid pool-account id (never the internal `__main__`) or +`"@main"` for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are +checked case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, +and configured pool ids or selector targets also cannot reuse a selector. Keep raw +account ids and emails private—the selector is the public name. In this foundation layer the map is +inert: it does not create model-picker entries, pin sessions, or alter Pool or Direct routing. + `maxConcurrentThreadsPerSession` is the camel-case field used by `PUT /api/v2`, not a `config.json` key. `ocx v2 threads ` persists the corresponding `max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index c6b674e49..97db567f8 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -56,6 +56,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия ротации новых сессий для пула Codex. Применяется **только к новым сессиям**; существующие id потоков сохраняют affinity. `quota` (по умолчанию) — выбор наименьшего известного usage, когда активный аккаунт превышает `autoSwitchThreshold`. `round-robin` — равномерное распределение между подходящими аккаунтами через smooth weighted selection. `fill-first` — использовать активный аккаунт до cooldown, недоступности или (если задано) `autoSwitchThreshold` (неизвестный usage не принуждает к переключению), затем переход к следующему подходящему аккаунту в стабильном отсортированном порядке. | @@ -68,6 +69,16 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `tokenGuardian?` | `OcxTokenGuardianConfig` | выкл. | Необязательная политика проактивного обновления OAuth и прогрева аккаунтов Codex; поля перечислены ниже. | | `corsAllowOrigins?` | `string[]` | `[]` | Дополнительные точные origin, разрешённые CORS. Loopback-origin разрешены всегда. | +Ключи `codexAccountNamespaces` — публичные селекторы длиной 1–64 символа. Они должны начинаться и +заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные +имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) +либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` +проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. +Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте +raw id аккаунтов и email приватными, а селектор используйте как публичное имя. +В этом foundation layer map инертна: она не создаёт записи model picker, не закрепляет сессии и не меняет +маршрутизацию Pool / Direct. + `maxConcurrentThreadsPerSession` — это camelCase-поле, используемое `PUT /api/v2`, а не ключ `config.json`. `ocx v2 threads ` сохраняет соответствующее значение `max_concurrent_threads_per_session` в `[features.multi_agent_v2]` в diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index da12eab6d..a14bfe139 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Codex pool 的新 session 轮换策略。仅适用于**新 session**;已有 thread id 保留 affinity。`quota`(默认):活跃账号超过 `autoSwitchThreshold` 时选已知 usage 最低者。`round-robin`:在合格账号间平滑加权均分。`fill-first`:持续使用活跃账号,直到 cooldown、不可用或(如已设置)超过 `autoSwitchThreshold`(未知 usage 不会强制切换),再按稳定排序进入下一个合格账号。 | @@ -62,6 +63,14 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `tokenGuardian?` | `OcxTokenGuardianConfig` | 关闭 | 可选的 proactive OAuth 刷新和 Codex account warmup 策略;字段见下文。 | | `corsAllowOrigins?` | `string[]` | `[]` | CORS 额外允许的精确 origin。loopback origin 始终允许。 | +`codexAccountNamespaces` 的 key 是公开 selector:长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, +中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 +pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 +保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 +其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id +与 email 应保持私密,selector 才是公开名称。在此 foundation layer 中, +该映射是 inert 的:不会创建 model picker entry、固定 session,也不会改变 Pool / Direct routing。 + `maxConcurrentThreadsPerSession` 是 `PUT /api/v2` 使用的 camel-case 字段,不是 `config.json` key。 `ocx v2 threads ` 会把对应的 `max_concurrent_threads_per_session` 值写入 Codex `$CODEX_HOME/config.toml` 的 `[features.multi_agent_v2]` 下;请先启用 v2,确保该 table 存在。 diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index 95e6b394a..dfcceb4b3 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -2,6 +2,8 @@ import { useCallback } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; +export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + export function useAddProviderOAuth({ apiBase, t, @@ -52,17 +54,17 @@ export function useAddProviderOAuth({ if (data.url) { setOauthUrl(data.url, providerId); setOauthMsg(t("modal.waitingLogin")); } else { setOauthMsg(data.instructions || t("modal.loggingIn")); } for (let i = 0; i < 100; i++) { - await new Promise(r => setTimeout(r, 2000)); + await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; if (!aliveRef.current) return; - if (s?.loggedIn) { onAdded(providerId); return; } if (s?.error) { setOauthMsgTone("warn"); setOauthMsg(t("modal.loginError", { error: s.error })); return; } + if (s?.loggedIn) { onAdded(providerId); return; } } setOauthMsgTone("warn"); setOauthMsg(t("modal.loginTimeout")); diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 9e0a04ecc..8265f6407 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -1,9 +1,10 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import AddProviderModal from "../src/components/AddProviderModal"; +import { OAUTH_LOGIN_POLL_INTERVAL_MS } from "../src/components/use-add-provider-oauth"; /** * The add-provider OAuth pane renders the authorization URL so a user whose @@ -23,6 +24,7 @@ let host: HTMLElement; let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let pendingLogins: Array<(url: string) => void> = []; +let oauthStatus: { loggedIn: boolean; error?: string } = { loggedIn: false }; const PRESETS = [ { id: "claude", label: "Claude", adapter: "anthropic", baseUrl: "https://api.anthropic.com", auth: "oauth", oauthProvider: "claude" }, @@ -43,6 +45,7 @@ beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; pendingLogins = []; + oauthStatus = { loggedIn: false }; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -56,7 +59,7 @@ beforeEach(() => { pendingLogins.push((authUrl: string) => resolve(Response.json({ url: authUrl }))); }); } - if (url.pathname === "/api/oauth/status") return Response.json({ loggedIn: false }); + if (url.pathname === "/api/oauth/status") return Response.json(oauthStatus); return Response.json({}); }, }); @@ -78,13 +81,13 @@ afterEach(async () => { await win.happyDOM?.close?.(); }); -async function mountModal() { +async function mountModal(onAdded: (name: string) => void = () => {}) { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( - {}} onAdded={() => {}} /> + {}} onAdded={onAdded} /> , ); }); @@ -167,3 +170,46 @@ test("a late URL for an abandoned provider cannot overwrite the one already show expect(host.querySelector(".login-url-block-text")?.textContent).toBe(B_URL); expect(host.textContent).not.toContain(A_URL); }); + +test("a login error wins over a retained OAuth credential", async () => { + const added: string[] = []; + oauthStatus = { + loggedIn: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const realSetTimeout = globalThis.setTimeout; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === OAUTH_LOGIN_POLL_INTERVAL_MS) { + queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + } + return realSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + + try { + await mountModal(name => added.push(name)); + await act(async () => { + clickByText("Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + clickByText("Log in with Claude"); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(pendingLogins).toHaveLength(1); + await act(async () => { + pendingLogins.shift()!(A_URL); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(added).toEqual([]); + expect(host.textContent).toContain("provider entry was not written"); + } finally { + timeoutSpy.mockRestore(); + } +}); diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index ca2968435..79de3e67b 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -137,11 +137,11 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { for (let attempt = 0; attempt < 100; attempt++) { await Bun.sleep(2_000); const state = await runtimeRequest>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, {}, deps); + if (state.error) throw new CliUsageError(String(state.error)); if (state.loggedIn === true) { printData(state, wantsJson, [`Logged in to ${provider}.`]); return; } - if (state.error) throw new CliUsageError(String(state.error)); } throw new CliUsageError("login timed out"); } diff --git a/src/cli/provider.ts b/src/cli/provider.ts index dd218be95..410c80dd1 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -15,6 +15,7 @@ import { providerConfigSeed } from "../providers/derive"; import type { OcxProviderConfig } from "../types"; import { findLiveProxy } from "../server/proxy-liveness"; import { syncModelsToCodex } from "../codex/sync"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; // --------------------------------------------------------------------------- // Arg helpers @@ -153,6 +154,12 @@ async function handleAdd(args: string[]): Promise { const config = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } + if (hasOwnProvider(config.providers, name) && !force) { console.error(`Provider "${name}" already exists. Use --force to overwrite.`); process.exit(1); diff --git a/src/codex/account-id.ts b/src/codex/account-id.ts new file mode 100644 index 000000000..5753cd056 --- /dev/null +++ b/src/codex/account-id.ts @@ -0,0 +1,34 @@ +/** Canonical persisted Codex pool-account id format. */ +export const CODEX_ACCOUNT_ID_RE = /^[A-Za-z0-9._-]{1,64}$/; + +// Account credentials are persisted in JSON object maps. These names have +// special meaning on ordinary JavaScript objects and must never be admitted as +// user-controlled keys. +const RESERVED_CODEX_ACCOUNT_IDS = new Set(["__proto__", "prototype", "constructor"]); + +/** + * Stable internal id under which the Codex Desktop login participates in account rotation. + * It is reserved and must never be used as a public namespace target or pool-account id. + */ +export const MAIN_CODEX_ACCOUNT_ID = "__main__"; + +export function isValidCodexAccountId(accountId: unknown): accountId is string { + return typeof accountId === "string" + && accountId !== MAIN_CODEX_ACCOUNT_ID + && CODEX_ACCOUNT_ID_RE.test(accountId) + && !RESERVED_CODEX_ACCOUNT_IDS.has(accountId.toLowerCase()); +} + +type CodexAccountIdentityRow = { id: string; isMain: boolean }; + +/** Legacy invalid rows remain loadable for cleanup, but never participate in routing. */ +export function isSelectableCodexPoolAccount(account: CodexAccountIdentityRow): boolean { + return !account.isMain && isValidCodexAccountId(account.id); +} + +/** A pre-validation pool row that collides with the internal Desktop-account sentinel. */ +export function hasLegacyMainCodexPoolAccount( + accounts: readonly CodexAccountIdentityRow[] | undefined, +): boolean { + return accounts?.some(account => !account.isMain && account.id === MAIN_CODEX_ACCOUNT_ID) ?? false; +} diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 9186ea85b..b6078bf05 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -45,7 +45,8 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void { export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { removeCodexAccountCredential(accountId); - runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); + runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) + .filter(account => account.isMain || account.id !== accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-namespace-match.ts b/src/codex/account-namespace-match.ts new file mode 100644 index 000000000..af56555ee --- /dev/null +++ b/src/codex/account-namespace-match.ts @@ -0,0 +1,63 @@ +import { isValidCodexAccountId } from "./account-id"; + +/** Config-only sentinel for the Codex Desktop account; outside the pool-account id grammar. */ +export const MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET = "@main"; + +export const CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR = + "combo alias must not use a configured Codex account namespace"; + +const CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR = + "account id must not collide with a configured Codex account namespace"; + +/** Provider ids are compared case-insensitively at namespace admission boundaries. */ +export function codexProviderNamespaceKey(value: string): string { + return value.toLowerCase(); +} + +export function hasCodexAccountNamespace( + namespaces: unknown, + namespace: string, +): boolean { + return !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.hasOwn(namespaces, namespace); +} + +export function codexAccountNamespaceProviderCollisionError( + namespaces: unknown, + providerName: string, +): string | undefined { + const normalizedProvider = codexProviderNamespaceKey(providerName); + const collides = !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.keys(namespaces) + .some(namespace => codexProviderNamespaceKey(namespace) === normalizedProvider); + return collides + ? "provider name must not collide with a configured Codex account namespace" + : undefined; +} + +export function codexAccountIdNamespaceCollisionError( + namespaces: unknown, + accountId: string, +): string | undefined { + return hasCodexAccountNamespace(namespaces, accountId) + ? CODEX_ACCOUNT_ID_NAMESPACE_COLLISION_ERROR + : undefined; +} + +export function codexAccountNamespaceForModel( + namespaces: unknown, + modelId: string, +): string | undefined { + const slash = modelId.indexOf("/"); + if (slash <= 0) return undefined; + const namespace = modelId.slice(0, slash); + return hasCodexAccountNamespace(namespaces, namespace) ? namespace : undefined; +} + +export function isValidCodexAccountNamespaceTarget(accountId: unknown): accountId is string { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || isValidCodexAccountId(accountId); +} diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts new file mode 100644 index 000000000..6c41850b5 --- /dev/null +++ b/src/codex/account-namespaces.ts @@ -0,0 +1,149 @@ +import type { CodexAccount, OcxConfig } from "../types"; +import { COMBO_NAMESPACE } from "../combos/types"; +import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + createCodexAccountLogLabel, + fallbackCodexAccountLogLabel, +} from "./account-label"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { + codexAccountIdNamespaceCollisionError, + codexProviderNamespaceKey, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./account-namespace-match"; + +export { isValidCodexAccountNamespaceTarget } from "./account-namespace-match"; + +const RESERVED_NAMESPACE_KEYS = new Set([ + "__proto__", + "prototype", + "constructor", + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, +].map(codexProviderNamespaceKey)); +const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16; + +function comboAliasNamespaces(config: Pick): string[] { + return Object.values(config.combos ?? {}).flatMap((combo) => { + const alias = typeof combo?.alias === "string" ? combo.alias.trim() : ""; + const slash = alias.indexOf("/"); + return slash > 0 ? [alias.slice(0, slash)] : []; + }); +} + +function privateAccountSelectorCandidates(accountIds: Iterable): Set { + const candidates = new Set(); + for (const accountId of accountIds) { + candidates.add(accountId); + candidates.add(fallbackCodexAccountLogLabel(accountId)); + } + return candidates; +} + +function defaultPublicAccountSelector( + account: Pick, + allPrivateCandidates: ReadonlySet, +): string { + const privateCandidates = new Set(allPrivateCandidates); + privateCandidates.add(account.id); + privateCandidates.add(fallbackCodexAccountLogLabel(account.id)); + if (CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "") + && !privateCandidates.has(account.logLabel!)) return account.logLabel!; + + // Legacy or hand-edited rows may predate persisted random log labels. Allocate a fresh public + // selector rather than exposing the stable, id-derived fallback used only to redact old logs. + for (let attempt = 0; attempt < PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS; attempt += 1) { + const selector = createCodexAccountLogLabel(privateCandidates); + if (!privateCandidates.has(selector)) return selector; + } + throw new Error("Unable to allocate a unique Codex account selector"); +} + +function claimNamespace(requested: string, used: Set): string { + let namespace = requested; + let suffix = 2; + while (used.has(namespace)) namespace = `${requested}-${suffix++}`; + used.add(namespace); + return namespace; +} + +function occupiedNamespaces(config: Pick): Set { + return new Set([ + ...Object.keys(config.providers).map(codexProviderNamespaceKey), + ...comboAliasNamespaces(config), + ...RESERVED_NAMESPACE_KEYS, + ]); +} + +/** Build an initial account-selector map without deriving public selectors from aliases or ids. */ +export function defaultCodexAccountNamespaces( + config: Pick, +): Record { + const namespaces: Record = {}; + const used = occupiedNamespaces(config); + const accounts = config.codexAccounts ?? []; + const privateCandidates = privateAccountSelectorCandidates( + accounts + .filter(account => account.id !== MAIN_CODEX_ACCOUNT_ID) + .map(account => account.id), + ); + for (const candidate of privateCandidates) used.add(candidate); + + namespaces[claimNamespace("main", used)] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; + for (const account of accounts) { + if (account.isMain || !isValidCodexAccountId(account.id)) continue; + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + } + return namespaces; +} + +/** + * Add one account to a generated map without renaming or replacing explicit existing entries. + * The account-creation layer must reject a new id that already equals an existing selector key. + * A true result means the map was mutated in place; callers must persist the updated config. + */ +export function appendDefaultCodexAccountNamespace( + config: Pick, + account: Pick, +): boolean { + const namespaces = config.codexAccountNamespaces; + if (account.isMain + || !isValidCodexAccountId(account.id) + || !namespaces + || Object.keys(namespaces).length === 0 + || codexAccountIdNamespaceCollisionError(namespaces, account.id) + || Object.values(namespaces).includes(account.id)) return false; + + const used = occupiedNamespaces(config); + for (const namespace of Object.keys(namespaces)) used.add(namespace); + const privateCandidates = privateAccountSelectorCandidates([ + ...(config.codexAccounts ?? []) + .filter(existing => existing.id !== MAIN_CODEX_ACCOUNT_ID) + .map(existing => existing.id), + ...Object.values(namespaces), + account.id, + ]); + for (const candidate of privateCandidates) used.add(candidate); + const namespace = claimNamespace(defaultPublicAccountSelector(account, privateCandidates), used); + namespaces[namespace] = account.id; + return true; +} + +export function isMainCodexAccountTarget(accountId: string): boolean { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID; +} + +function normalizeCodexAccountNamespaceTarget(accountId: string): string { + return isMainCodexAccountTarget(accountId) + ? MAIN_CODEX_ACCOUNT_ID + : accountId; +} + +export function codexAccountNamespaceEntries( + config: Pick, +): Array<[string, string]> { + return Object.entries(config.codexAccountNamespaces ?? {}) + .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); +} diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index 9d34d465b..66141c86c 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -1,14 +1,19 @@ import { getCodexAccountCredential } from "./account-store"; import { isAccountNeedsReauth } from "./account-runtime-state"; import { MAIN_CODEX_ACCOUNT_ID, isMainAccountTokenLive } from "./main-account"; +import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; export function isCodexAccountUsable(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + // A legacy pool row with the sentinel makes an active `__main__` ambiguous. + // Fail closed until the authenticated compatibility-delete path removes it. + if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return false; // Main account: credential is the read-only ~/.codex/auth.json token (Option A). return isMainAccountTokenLive() && !isAccountNeedsReauth(accountId); } - const exists = (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + const exists = (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); if (!exists) return false; if (isAccountNeedsReauth(accountId)) return false; return !!getCodexAccountCredential(accountId); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 73da7ec1f..540f24f55 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -61,6 +61,13 @@ import { type OAuthAccountHealth, type OAuthHealthLabel, } from "../oauth/health"; +import { + CODEX_ACCOUNT_ID_RE, + hasLegacyMainCodexPoolAccount, + isSelectableCodexPoolAccount, + isValidCodexAccountId, +} from "./account-id"; +import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match"; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -69,14 +76,32 @@ function jsonResponse(data: unknown, status = 200): Response { }); } -const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/; const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; const codexAuthLoginState = new Map(); function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null { - if (!ACCOUNT_ID_RE.test(accountId)) return null; - return (config.codexAccounts ?? []).find(account => account.id === accountId && !account.isMain) ?? null; + if (!isValidCodexAccountId(accountId)) return null; + return (config.codexAccounts ?? []) + .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null; +} + +function codexAccountPersistenceConflict( + config: OcxConfig, + accountId: string, + mode: "create" | "reauth", +): string | undefined { + if (mode === "reauth") { + return configuredPoolAccount(config, accountId) + ? undefined + : "Pool account was removed while login was in progress. Add it again as a new account."; + } + const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId); + if (namespaceCollision) return namespaceCollision; + return (config.codexAccounts ?? []).some(account => account.id === accountId) + || Boolean(getCodexAccountCredential(accountId)) + ? `Account id already exists: ${accountId}` + : undefined; } function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean { @@ -127,11 +152,14 @@ async function resolveResetCreditAuth( | { ok: false; response: Response } > { if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) }; + } const tokens = readCodexTokens(); if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) }; return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id }; } - if (!ACCOUNT_ID_RE.test(accountId)) { + if (!isValidCodexAccountId(accountId)) { return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) }; } if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -447,7 +475,7 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P reconcileMainCodexAccountRuntimeState(); primeInFlight = (async () => { const runtimeConfig = getRuntimeConfig(config); - const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const stale = pool.filter(a => { const q = getAccountQuota(a.id); return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL; @@ -479,7 +507,7 @@ export function clearCodexQuotaPrimeState(): void { export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise { const runtimeConfig = getRuntimeConfig(config); - const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain); + const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const mainInfo = await fetchMainAccountInfo(forceRefresh); const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => { const cred = getCodexAccountCredential(a.id); @@ -526,17 +554,15 @@ export async function handleCodexAuthAPI( if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) { return jsonResponse({ error: "Missing required fields" }, 400); } - if (!ACCOUNT_ID_RE.test(body.id)) { + if (!isValidCodexAccountId(body.id)) { return jsonResponse({ error: "Invalid account id format" }, 400); } if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) { return jsonResponse({ error: "Input too large" }, 400); } const runtimeConfig = getRuntimeConfig(config); - const accounts = runtimeConfig.codexAccounts ?? []; - if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) { - return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400); - } + const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create"); + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); // 1.1: Duplicate check is scoped by personal vs workspace plan bucket. const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId; const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan); @@ -548,6 +574,9 @@ export async function handleCodexAuthAPI( const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000; const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId); if (!warmup.ok) return warmup.response; + const latestConfig = getRuntimeConfig(config); + const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create"); + if (commitConflict) return jsonResponse({ error: commitConflict }, 400); saveCodexAccountCredential(body.id, { accessToken: body.accessToken, refreshToken: body.refreshToken, @@ -556,9 +585,10 @@ export async function handleCodexAuthAPI( }); markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); + const accounts = latestConfig.codexAccounts ?? []; accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts)); - runtimeConfig.codexAccounts = accounts; - saveRuntimeConfig(config, runtimeConfig); + latestConfig.codexAccounts = accounts; + saveRuntimeConfig(config, latestConfig); return jsonResponse({ ok: true }); } @@ -566,6 +596,11 @@ export async function handleCodexAuthAPI( const id = url.searchParams.get("id"); if (!id) return jsonResponse({ error: "Missing id" }, 400); const runtimeConfig = getRuntimeConfig(config); + const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id) + && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id); + if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true }); @@ -575,8 +610,8 @@ export async function handleCodexAuthAPI( const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; const alias = typeof body.alias === "string" ? body.alias.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400); + if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400); if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) { return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400); } @@ -600,7 +635,9 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") { const body = await req.json().catch(() => ({})) as { id?: unknown }; const id = typeof body.id === "string" ? body.id.trim() : ""; - if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400); + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) }); } @@ -608,8 +645,13 @@ export async function handleCodexAuthAPI( let body: { accountId: string | null }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } const runtimeConfig = getRuntimeConfig(config); + if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); + } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId); + if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); + const exists = (runtimeConfig.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; @@ -782,15 +824,15 @@ export async function handleCodexAuthAPI( const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean }; const requestedAccountId = body.id?.trim(); const reauth = body.reauth === true; - if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) { + if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) { return jsonResponse({ error: "Invalid account id format" }, 400); } const accountId = requestedAccountId || `chatgpt-${Date.now()}`; const runtimeConfig = getRuntimeConfig(config); - const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || Boolean(getCodexAccountCredential(accountId)); - if (exists && !reauth) { - return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400); - } + const preflightConflict = !reauth + ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create") + : undefined; + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); if (reauth) { if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400); if (!configuredPoolAccount(runtimeConfig, accountId)) { @@ -908,6 +950,24 @@ export async function handleCodexAuthAPI( break; } + const latestConfig = getRuntimeConfig(config); + const accounts = latestConfig.codexAccounts ?? []; + const existingIdx = accounts.findIndex(account => account.id === accountId); + const commitConflict = codexAccountPersistenceConflict( + latestConfig, + accountId, + reauth ? "reauth" : "create", + ); + if (commitConflict) { + codexAuthLoginState.set(flowId, { + status: "error", + error: commitConflict, + doneAt: Date.now(), + }); + completed = true; + break; + } + saveCodexAccountCredential(accountId, { accessToken: cred.access, refreshToken: cred.refresh, @@ -920,15 +980,12 @@ export async function handleCodexAuthAPI( setAccountQuotaFromParsed(accountId, quota); } - const latestConfig = getRuntimeConfig(config); - const accounts = latestConfig.codexAccounts ?? []; - const existingIdx = accounts.findIndex(a => a.id === accountId); if (existingIdx >= 0) { // Keep the pool id stable; refresh display metadata after a successful login/reauth. accounts[existingIdx] = withCodexAccountLogLabel({ ...accounts[existingIdx], email, - plan, + plan: plan ?? accounts[existingIdx].plan, isMain: false, }, accounts); latestConfig.codexAccounts = accounts; diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index e737dc0d9..a7d10e7c9 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -4,6 +4,7 @@ import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; import { resolveCodexHomeDir } from "./home"; import { extractAccountId } from "../oauth/chatgpt"; +import { isSelectableCodexPoolAccount } from "./account-id"; export interface CodexTokens { access_token: string; @@ -94,7 +95,7 @@ export function checkAccountIdCollision( const candidateWorkspace = isWorkspacePlan(plan); for (const account of loadConfig().codexAccounts ?? []) { if (excludeAccountId && account.id === excludeAccountId) continue; - if (account.isMain) continue; + if (!isSelectableCodexPoolAccount(account)) continue; if (isWorkspacePlan(account.plan) !== candidateWorkspace) continue; const cred = getCodexAccountCredential(account.id); const poolEmail = normalizedEmail(account.email); diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 6e98e9a76..495ad3930 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,14 +1,8 @@ import { readCodexTokens } from "./auth-collision"; import { decodeJwtPayload } from "../oauth/chatgpt"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; -/** - * Stable id under which the "main" Codex account (the Codex CLI login stored in - * ~/.codex/auth.json) participates in opencodex's account rotation. The main account is - * NOT imported into the managed credential store (Option A): its token is read-only from - * auth.json, so opencodex never refreshes it — an expired token surfaces as a reauth - * notice (re-login via the Codex CLI) rather than a background refresh. - */ -export const MAIN_CODEX_ACCOUNT_ID = "__main__"; +export { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; /** * Main account plan (e.g. "plus", "go", "free", "team"), populated from the WHAM usage diff --git a/src/codex/routing.ts b/src/codex/routing.ts index a54eb9010..b359d07d1 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -16,6 +16,7 @@ import { } from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; +import { isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; type ThreadAffinityEntry = { @@ -187,7 +188,8 @@ export type CodexUpstreamOutcomeMeta = { function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean { if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId); - return (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId); + return (config.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId); } export function clearThreadAccountMap(): void { @@ -691,7 +693,9 @@ function getEligiblePoolAccounts( quotaScope?: CodexQuotaScope, ): string[] { const ids = (config.codexAccounts ?? []) - .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id)) + .filter(account => isSelectableCodexPoolAccount(account) + && account.id !== excludeId + && !isAccountNeedsReauth(account.id)) .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) .filter(account => isCodexAccountUsable(config, account.id)) @@ -850,7 +854,8 @@ function pickUnboundStrategyAccount( export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); - return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan; + return (config.codexAccounts ?? []) + .find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan; } function pickLowerUsageAccount( @@ -1392,6 +1397,7 @@ export function formatCodexProviderForLog(providerName: string, accountId: strin // same physical account as the "main" passthrough (null accountId). Log both under the base provider // name so usage/tokens aggregate into a single row instead of splitting into `chatgpt` + `chatgpt-main`. if (accountId === MAIN_CODEX_ACCOUNT_ID) return providerName; - const account = (config.codexAccounts ?? []).find(a => !a.isMain && a.id === accountId); + const account = (config.codexAccounts ?? []) + .find(candidate => isSelectableCodexPoolAccount(candidate) && candidate.id === accountId); return account ? `${providerName}-${codexAccountLogLabel(account)}` : providerName; } diff --git a/src/config.ts b/src/config.ts index 1168e0e4e..c3ca1a4c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,14 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync import { homedir } from "node:os"; import { join, resolve } from "node:path"; import * as z from "zod/v4"; -import { comboConfigIssues } from "./combos/types"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, + codexProviderNamespaceKey, + isValidCodexAccountNamespaceTarget, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "./codex/account-namespace-match"; +import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; @@ -15,7 +22,7 @@ import { type OcxConfig, type OcxProviderConfig, } from "./types"; -import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; @@ -525,6 +532,52 @@ export function modelAdapterRecordConfigError( return null; } +const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = + "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; +const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = + "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR = + "account selector targets must be @main or valid Codex pool-account ids"; +const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR = + "account selectors must not collide with configured Codex pool-account ids or account selector targets"; + +function configuredCodexPoolAccountIds(value: unknown): Set { + const accountIds = new Set(); + if (!Array.isArray(value)) return accountIds; + for (const account of value) { + if (!account || typeof account !== "object" || Array.isArray(account)) continue; + const { id, isMain } = account as { id?: unknown; isMain?: unknown }; + if (typeof id === "string" && isMain !== true) accountIds.add(id); + } + return accountIds; +} + +const codexAccountNamespacesSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR }, +).superRefine((accountNamespaces, ctx) => { + // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [namespace, accountId] of Object.entries(accountNamespaces)) { + if (!isValidProviderName(namespace)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR, + }); + } + if (!isValidCodexAccountNamespaceTarget(accountId)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR, + }); + } + } +}).pipe(z.record(z.string(), z.string())); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), providers: z.record(z.string(), providerConfigSchema), @@ -541,6 +594,7 @@ const configSchema = z.object({ injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), + codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole @@ -562,6 +616,36 @@ const configSchema = z.object({ }); } } + + const accountNamespaces = config.codexAccountNamespaces; + if (accountNamespaces) { + const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts); + const configuredProviderNamespaces = new Set([ + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, + ...Object.keys(config.providers), + ].map(codexProviderNamespaceKey)); + const namespaceTargets = new Set( + Object.values(accountNamespaces) + .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET), + ); + for (const namespace of Object.keys(accountNamespaces)) { + if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account selectors must not collide with configured provider or combo namespaces", + }); + } + if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, + }); + } + } + } for (const name of Object.keys(config.providers)) { if (!isValidProviderName(name)) { ctx.addIssue({ @@ -731,6 +815,16 @@ const configSchema = z.object({ ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" }); } else { for (const [id, raw] of Object.entries(combos as Record)) { + const alias = raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as { alias?: unknown }).alias + : undefined; + if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) { + ctx.addIssue({ + code: "custom", + path: ["combos", id, "alias"], + message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + }); + } // Pass the full map so cross-combo rules (alias uniqueness) apply at load time // too, not just via the management API; each combo is excluded from its own check. for (const issue of comboConfigIssues(id, raw, config.providers, { @@ -1038,6 +1132,16 @@ export function websocketsEnabled(config: Pick): boolea */ const claudeCodeBaseline = new WeakMap(); +/** + * The live config retains the address of the socket Bun actually opened, while + * this map retains the operator's desired address for the next process start. + * Keeping them separate prevents an unrelated live save from restoring a stale + * externally exposed bind after OAuth adopted a newer loopback disk config. + */ +type PersistedServerBinding = Pick; + +const persistedLiveServerBinding = new WeakMap(); + /** * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on * first save — arming lazily would lose exactly the hand edit made before that first @@ -1074,6 +1178,116 @@ function deepEqual(a: unknown, b: unknown): boolean { return true; } +const MISSING_CONFIG_VALUE = Symbol("missing-config-value"); +type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE; + +function isPlainConfigRecord(value: ConfigMergeValue): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function ownConfigValue(record: Record, key: string): ConfigMergeValue { + return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE; +} + +function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue { + return value === MISSING_CONFIG_VALUE ? value : structuredClone(value); +} + +function reconcileConfigRecord( + live: Record, + baseline: Record, + persisted: Record, + skippedKeys?: ReadonlySet, +): void { + const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]); + for (const key of keys) { + if (skippedKeys?.has(key)) continue; + const merged = reconcileConfigValue( + ownConfigValue(baseline, key), + ownConfigValue(live, key), + ownConfigValue(persisted, key), + ); + if (merged === MISSING_CONFIG_VALUE) delete live[key]; + else live[key] = merged; + } +} + +function reconcileConfigValue( + baseline: ConfigMergeValue, + live: ConfigMergeValue, + persisted: ConfigMergeValue, +): ConfigMergeValue { + const liveChanged = !deepEqual(live, baseline); + const persistedChanged = !deepEqual(persisted, baseline); + + if (!liveChanged) { + if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) { + live.splice(0, live.length, ...structuredClone(persisted)); + return live; + } + if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + return live; + } + return cloneConfigValue(persisted); + } + + if (!persistedChanged) return live; + + if (isPlainConfigRecord(live) + && isPlainConfigRecord(persisted) + && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + } + // Same-leaf conflicts prefer the pending live management mutation. + return live; +} + +/** + * Reconcile an async OAuth disk commit into the shared live config without erasing + * management mutations that have not saved yet. The baseline is a normalized disk + * snapshot from immediately before login; disjoint object edits merge recursively, + * while same-leaf conflicts prefer live state. + */ +export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback") { + throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`); + } + const persisted = diagnostics.config; + const claudeGuardArmed = claudeCodeBaseline.has(config); + const pendingLiveClaudeMutation = claudeGuardArmed + && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config)); + + persistedLiveServerBinding.set(config, { + port: persisted.port, + ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), + }); + + reconcileConfigRecord( + config as unknown as Record, + persistedBaseline as unknown as Record, + persisted as unknown as Record, + new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), + ); + + if (claudeGuardArmed && !pendingLiveClaudeMutation) { + if (persisted.claudeCode === undefined) delete config.claudeCode; + else config.claudeCode = structuredClone(persisted.claudeCode); + claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); + } +} + /** The literal file, with no schema merge or default injection. */ function readRawConfigJson(): Record | undefined { try { @@ -1089,6 +1303,28 @@ function readRawConfigJson(): Record | undefined { } } +/** + * Read only schema-valid binding fields from the literal file. Missing fields mean + * their schema defaults; malformed fields keep the last known persisted value. + */ +function readPersistedServerBinding( + raw: Record, + baseline: PersistedServerBinding, +): PersistedServerBinding { + const port = raw.port === undefined + ? 10100 + : (typeof raw.port === "number" + && Number.isInteger(raw.port) + && raw.port >= 0 + && raw.port <= 65535 + ? raw.port + : baseline.port); + const hostname = raw.hostname === undefined + ? undefined + : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname); + return { port, ...(hostname !== undefined ? { hostname } : {}) }; +} + /** * The save entry point for every writer holding a LIVE server config. * @@ -1103,8 +1339,11 @@ function readRawConfigJson(): Record | undefined { * guarantee. */ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { + const bindingBaseline = persistedLiveServerBinding.get(config); + const onDisk = claudeCodeBaseline.has(config) || bindingBaseline + ? readRawConfigJson() + : undefined; if (claudeCodeBaseline.has(config)) { - const onDisk = readRawConfigJson(); if (onDisk !== undefined) { const baseline = claudeCodeBaseline.get(config); const diskChanged = !deepEqual(onDisk.claudeCode, baseline); @@ -1114,7 +1353,18 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { } } } - saveConfig(config); + const persistedBinding = bindingBaseline && onDisk + ? readPersistedServerBinding(onDisk, bindingBaseline) + : bindingBaseline; + if (persistedBinding) { + const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port }; + if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; + else persistedConfig.hostname = persistedBinding.hostname; + saveConfig(persistedConfig); + persistedLiveServerBinding.set(config, persistedBinding); + } else { + saveConfig(config); + } if (claudeCodeBaseline.has(config)) { claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index c6dca4bc0..5ebaa320c 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -38,6 +38,7 @@ export { type OAuthHealthLabel, } from "./health"; export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { @@ -60,6 +61,11 @@ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=ver export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string } +export interface LoginFlowLifecycle { + /** Runs after background credential/config persistence settles, before status becomes done. */ + onSettled?: () => void | Promise; +} + interface OAuthProviderDef { login(ctrl: OAuthController, opts?: LoginOpts): Promise; refresh( @@ -617,6 +623,8 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (provider === "chatgpt") return; const def = OAUTH_PROVIDERS[provider]; if (!def) return; + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; const next: OcxProviderConfig = { ...def.providerConfig }; if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { @@ -681,6 +689,16 @@ export async function runLogin( ): Promise { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); + const loadLatestConfig = deps.loadConfig ?? loadConfig; + const saveLatestConfig = deps.saveConfig ?? saveConfig; + if (provider !== "chatgpt") { + const preflightConfig = loadLatestConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError( + preflightConfig.codexAccountNamespaces, + provider, + ); + if (namespaceCollision) throw new Error(namespaceCollision); + } // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object // for settlement even when source normalization below creates a derived credential object. const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true; @@ -691,6 +709,12 @@ export async function runLogin( const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; try { + // Validate the provider row before credential persistence. A namespace claimed during the + // credential write is handled again below before the latest row is re-upserted. + if (provider !== "chatgpt") { + const preCommitConfig = loadLatestConfig(); + upsertOAuthProvider(preCommitConfig, provider); + } if (opts?.reauthAccountId) { const existing = getAccountCredential(provider, opts.reauthAccountId); if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`); @@ -712,9 +736,21 @@ export async function runLogin( }); } if (provider !== "chatgpt") { - const config = (deps.loadConfig ?? loadConfig)(); - upsertOAuthProvider(config, provider); - (deps.saveConfig ?? saveConfig)(config); + // Re-run against post-credential state so same-provider API-key additions, removals, + // and active-key switches survive. A late namespace claim wins over provider creation. + const latestConfig = loadLatestConfig(); + const lateCollision = codexAccountNamespaceProviderCollisionError( + latestConfig.codexAccountNamespaces, + provider, + ); + if (lateCollision) { + throw new Error( + `${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. ` + + "Rename the account selector, then re-run the login.", + ); + } + upsertOAuthProvider(latestConfig, provider); + saveLatestConfig(latestConfig); } } catch (error) { const errors: unknown[] = [error]; @@ -894,7 +930,11 @@ export function cancelLoginFlow(provider: string): boolean { return true; } -export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> { +export async function startLoginFlow( + provider: string, + opts?: LoginOpts, + lifecycle?: LoginFlowLifecycle, +): Promise<{ url: string; instructions?: string; deviceCode?: string }> { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); const existing = loginState.get(provider); @@ -917,22 +957,44 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState), signal: abort.signal, }; - // Background: runLogin persists the credential + upserts the provider entry to disk config. - runLogin(provider, ctrl, opts) - .then(() => { + const settle = async (error?: unknown): Promise => { + let finalError = error; + try { + await lifecycle?.onSettled?.(); + } catch (settleError) { + // A successful credential/config commit is not fully live until its owner reconciles the + // runtime config. For an already-failed login, keep the original recovery error. + if (finalError === undefined) finalError = settleError; + } + if (finalError === undefined) { loginAbort.delete(provider); clearManualCodeSlot(provider); loginState.set(provider, { done: true }); // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth — // resolve so the GUI call returns instead of hanging. if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." }); - }) - .catch((e: unknown) => { - loginAbort.delete(provider); - clearManualCodeSlot(provider); - const msg = e instanceof Error ? e.message : String(e); - loginState.set(provider, { done: true, error: msg }); - if (!urlResolved) reject(e); - }); + return; + } + + const e = finalError; + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }; + // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook + // lets a long-lived server config adopt that settled state before clients observe done=true. + void runLogin(provider, ctrl, opts).then( + () => settle(), + (e: unknown) => settle(e), + ).catch((e: unknown) => { + // settle catches lifecycle failures, so this is only a defensive promise-boundary guard. + loginAbort.delete(provider); + clearManualCodeSlot(provider); + const msg = e instanceof Error ? e.message : String(e); + loginState.set(provider, { done: true, error: msg }); + if (!urlResolved) reject(e); + }); }); } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index cf225b9ce..d65288415 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -5,6 +5,7 @@ import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index"; import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers"; import type { OcxProviderConfig } from "../types"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; export function runningProxyUpdateHeaders(): Headers { const headers = new Headers({ "Content-Type": "application/json" }); @@ -106,6 +107,12 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s async function handleKeyLogin(name: string): Promise { const def = KEY_LOGIN_PROVIDERS[name]; + const preflightConfig = loadConfig(); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name); + if (namespaceCollision) { + console.error(`Error: ${namespaceCollision}.`); + process.exit(1); + } console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`); openUrl(def.dashboardUrl); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -135,6 +142,11 @@ async function handleKeyLogin(name: string): Promise { } const provider = providerConfigFromKeyLoginProvider(def, key, baseUrl); const config = loadConfig(); + const commitCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name); + if (commitCollision) { + console.error(`Error: ${commitCollision}.`); + process.exit(1); + } config.providers[name] = provider; saveConfig(config); await notifyRunningProxy(name, provider); diff --git a/src/providers/alibaba-region-migration.ts b/src/providers/alibaba-region-migration.ts index 629d6fe56..8d488f246 100644 --- a/src/providers/alibaba-region-migration.ts +++ b/src/providers/alibaba-region-migration.ts @@ -2,6 +2,7 @@ import { ALIBABA_INTL_BASE_URL_CHOICES } from "./base-url-choices"; import { providerConfigSeed } from "./derive"; import { rewriteProviderReferences } from "./provider-id-rewrite"; import { PROVIDER_REGISTRY } from "./registry"; +import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; import type { OcxConfig, OcxProviderConfig } from "../types"; const BEIJING_ID = "alibaba-token-plan"; @@ -91,9 +92,10 @@ function buildIntlRow(source: OcxProviderConfig): OcxProviderConfig { * those contracts across. That override fix shipped once, was backed out twice, * and was refused again when PR #459 closed. * - * Refuses to act when the destination already holds a value — a provider row or a - * key the reference rewrite would land on — because merging two settings is a - * user decision, not a migration's. + * Refuses to act when the destination is reserved by an account namespace or + * already holds a value — a provider row or a key the reference rewrite would + * land on — because choosing which setting wins is a user decision, not a + * migration's. */ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionMigrationProjection { const beijing = config.providers[BEIJING_ID]; @@ -101,6 +103,17 @@ export function projectAlibabaRegionMigration(config: OcxConfig): AlibabaRegionM if (!beijing || !savedBaseUrl || !isInternationalEndpoint(savedBaseUrl)) { return { config, changed: false, warnings: [] }; } + if (codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, INTL_ID)) { + return { + config, + changed: false, + warnings: [ + `provider "${BEIJING_ID}" needs to move to "${INTL_ID}", but that destination is reserved ` + + `by a configured Codex account namespace. Nothing was changed. Rename the account ` + + `selector or move the provider manually, then restart.`, + ], + }; + } if (config.providers[INTL_ID]) { return { config, diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index f7942e87a..57c51e5bb 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -29,6 +29,10 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, +} from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; @@ -123,6 +127,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; @@ -86,6 +88,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); + if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); const accountId = body.accountId?.trim(); const reauth = body.reauth === true || Boolean(accountId); try { @@ -96,12 +100,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< return jsonResponse({ error: "Unknown account for reauth" }, 404); } } + // Use persisted state, not the live object, as the merge base: another management + // request may already have mutated live config and yielded before its save. + const persistedBaseline = readConfigDiagnostics().config; // addAccount / reauth forces a fresh browser identity (skips local-CLI token import). const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, { forceLogin: body.addAccount === true || reauth, ...(accountId ? { reauthAccountId: accountId } : {}), + }, { + // startLoginFlow returns the authorization URL before background persistence completes. + // Three-way reconcile settled disk changes so a failed login cannot leave a provider + // live-only and an in-flight management mutation cannot be erased before it saves. + onSettled: () => reconcileLiveConfigFromDisk(config, persistedBaseline), }); - upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart if (authUrl && !deviceCode) { // Open the browser server-side (the proxy runs on the user's machine) — the GUI's // window.open is popup-blocked because it runs after an await, not a direct click. diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 53d17ff10..f4b0fed2b 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -29,6 +29,7 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { getProviderDiscoveryStatus } from "../../codex/model-cache"; @@ -99,6 +100,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/tests/alibaba-region-migration.test.ts b/tests/alibaba-region-migration.test.ts index 54db6fae9..3b56fb8be 100644 --- a/tests/alibaba-region-migration.test.ts +++ b/tests/alibaba-region-migration.test.ts @@ -20,6 +20,13 @@ function migratableConfig(): OcxConfig { } as unknown as OcxConfig; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("moves a Beijing entry holding an international endpoint", () => { const config = migratableConfig(); // Beijing catalog fields, as `ocx provider add` would have persisted them. @@ -94,6 +101,51 @@ test("refuses to merge when the intl entry exists, and says why", () => { expect(projection.warnings[0]).toContain("already exists"); }); +test("refuses to replace an account namespace with the intl provider", () => { + const config = namespaceCollidingConfig(); + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); + expect(projection.warnings[0]).toContain("Rename the account selector"); +}); + +test("refuses a mixed-case account namespace that owns the intl provider id", () => { + const config = migratableConfig(); + config.codexAccountNamespaces = { "ALIBABA-TOKEN-PLAN-INTL": "pool-a" }; + const before = structuredClone(config); + + const projection = projectAlibabaRegionMigration(config); + + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toHaveLength(1); + expect(projection.warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + +test("a namespace-blocked migration remains valid across reload", () => { + const projection = projectAlibabaRegionMigration(namespaceCollidingConfig()); + expect(projection.changed).toBe(false); + + const home = mkdtempSync(join(tmpdir(), "ocx-alibaba-namespace-")); + const prev = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { + saveConfig(projection.config); + const reloaded = loadConfig(); + expect(reloaded.providers["alibaba-token-plan"]).toBeDefined(); + expect(reloaded.providers["alibaba-token-plan-intl"]).toBeUndefined(); + expect(reloaded.codexAccountNamespaces).toEqual({ "alibaba-token-plan-intl": "pool-a" }); + } finally { + if (prev === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } +}); + test("aborts without changing anything when a destination key is occupied", () => { const config = migratableConfig(); config.providerContextCaps = { "alibaba-token-plan": 500_000, "alibaba-token-plan-intl": 900_000 }; diff --git a/tests/alibaba-region-startup.test.ts b/tests/alibaba-region-startup.test.ts index e366c0d92..eda003d50 100644 --- a/tests/alibaba-region-startup.test.ts +++ b/tests/alibaba-region-startup.test.ts @@ -22,6 +22,13 @@ function collidingConfig(): OcxConfig { return config; } +function namespaceCollidingConfig(): OcxConfig { + return { + ...migratableConfig(), + codexAccountNamespaces: { "alibaba-token-plan-intl": "pool-a" }, + }; +} + test("backs up strictly before saving, exactly once, when the projection changed", () => { const order: string[] = []; const saved: OcxConfig[] = []; @@ -57,6 +64,27 @@ test("a no-op never backs up or saves, but a collision still warns", () => { expect(warnings[0]).toContain("[alibaba-region-migration]"); }); +test("an account namespace collision warns without backing up or saving", () => { + const order: string[] = []; + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + try { + const config = namespaceCollidingConfig(); + const result = runAlibabaRegionStartupMigration(config, { + project: projectAlibabaRegionMigration, + backup: () => { order.push("backup"); }, + save: () => { order.push("save"); }, + }); + expect(result).toBe(config); + } finally { + console.warn = originalWarn; + } + expect(order).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("reserved by a configured Codex account namespace"); +}); + test("a backup failure prevents the migration from saving", () => { // The fail-closed posture: no rollback point, no credential rewrite. The throw // propagates out of startServer, the same stance the OpenAI tier migration takes. diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 751e8b249..b692ddb85 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { PassThrough, Readable } from "node:stream"; import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../src/cli/account"; import type { AccountStdin } from "../src/cli/account-api"; @@ -43,6 +43,7 @@ let lastDeletedType: "codex" | "oauth" | "api-key" | null = null; let codexAccounts: Array> = []; let oauthAccounts: Array> = []; let oauthActiveId: string | null = "acct_1"; +let oauthLoginStatus: Record = { loggedIn: false }; let keyEntries: Array> = []; let keyActiveId: string | null = "key_1"; let logs: string[] = []; @@ -285,6 +286,10 @@ async function mockManagementApi(req: Request): Promise { return json({ ok: true, accepted: true }); } + if (req.method === "GET" && url.pathname === "/api/oauth/status") { + return json(oauthLoginStatus); + } + return json({ error: `unhandled mock endpoint: ${req.method} ${url.pathname}` }, 404); } @@ -348,6 +353,7 @@ beforeEach(() => { { id: "acct_2" }, ]; oauthActiveId = "acct_1"; + oauthLoginStatus = { loggedIn: false }; keyEntries = [{ id: "key_1", label: "personal", @@ -1271,5 +1277,24 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(result.code).toBe(0); expect(requests.some(request => request.path === "/api/oauth/login/code")).toBe(false); }); + + }); + + test("39: a login error wins over a retained OAuth credential", async () => { + oauthLoginStatus = { + loggedIn: true, + done: true, + error: "The credential was saved, but the provider entry was not written.", + }; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => {}); + try { + const result = await run(["login", "anthropic"]); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("provider entry was not written"); + expect(result.stdout).not.toContain("Logged in to anthropic"); + } finally { + sleepSpy.mockRestore(); + } }); }); diff --git a/tests/cli-provider.test.ts b/tests/cli-provider.test.ts index 2e24a2d8b..cf52454df 100644 --- a/tests/cli-provider.test.ts +++ b/tests/cli-provider.test.ts @@ -97,6 +97,38 @@ describe("ocx provider", () => { } }); + test("provider add rejects a configured Codex account namespace without mutating config", () => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { deepseek: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["provider", "add", "deepseek", "--api-key", "sk-test"], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test.each(["xai", "deepseek"])("login %s rejects a configured Codex account namespace before prompting", provider => { + const { dir, configPath } = freshConfig({ + codexAccountNamespaces: { [provider]: "side-account-id" }, + }); + try { + const before = readFileSync(configPath, "utf8"); + const result = runCli(["login", provider], { OPENCODEX_HOME: dir }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not collide with a configured Codex account namespace"); + expect(readFileSync(configPath, "utf8")).toBe(before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("provider add custom provider requires --adapter and --base-url", () => { const { dir } = freshConfig(); try { diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts new file mode 100644 index 000000000..5083f7c99 --- /dev/null +++ b/tests/codex-account-namespaces.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as accountLabels from "../src/codex/account-label"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + fallbackCodexAccountLogLabel, +} from "../src/codex/account-label"; +import { + codexAccountIdNamespaceCollisionError, + codexAccountNamespaceProviderCollisionError, + codexAccountNamespaceForModel, + hasCodexAccountNamespace, +} from "../src/codex/account-namespace-match"; +import { + appendDefaultCodexAccountNamespace, + codexAccountNamespaceEntries, + defaultCodexAccountNamespaces, + isMainCodexAccountTarget, + isValidCodexAccountNamespaceTarget, +} from "../src/codex/account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; + +describe("Codex account namespace foundations", () => { + test("uses persisted random labels without deriving selectors from aliases or emails", () => { + const privateAlias = "jun@example.test"; + const privateEmail = "account@example.test"; + const privateId = "stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: privateEmail, + alias: privateAlias, + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ main: "@main", p111111: privateId }); + const publicSelectors = Object.keys(namespaces).join(","); + expect(publicSelectors).not.toContain(privateAlias); + expect(publicSelectors).not.toContain(privateEmail); + expect(publicSelectors).not.toContain(privateId); + }); + + test("gives legacy accounts an independent random selector", () => { + const privateId = "legacy-stored-account-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ + id: privateId, + email: "legacy@example.test", + alias: "Legacy Display Name", + isMain: false, + }], + }); + const selector = Object.entries(namespaces) + .find(([, accountId]) => accountId === privateId)?.[0]; + + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(fallbackCodexAccountLogLabel(privateId)); + expect(selector).not.toContain(privateId); + }); + + test("fails after bounded attempts when no private-safe selector can be allocated", () => { + const labelSpy = spyOn(accountLabels, "createCodexAccountLogLabel").mockReturnValue("p111111"); + try { + expect(() => defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [{ id: "p111111", logLabel: "legacy", isMain: false }], + })).toThrow("Unable to allocate a unique Codex account selector"); + expect(labelSpy).toHaveBeenCalledTimes(16); + } finally { + labelSpy.mockRestore(); + } + }); + + test("never reuses a private account id or its deterministic fallback as a selector", () => { + const pShapedId = "p222222"; + const fallbackId = "legacy-private-account-id"; + const deterministicFallback = fallbackCodexAccountLogLabel(fallbackId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: pShapedId, logLabel: pShapedId, isMain: false }, + { id: fallbackId, logLabel: deterministicFallback, isMain: false }, + ], + }); + + const selectorFor = (accountId: string) => Object.entries(namespaces) + .find(([, target]) => target === accountId)?.[0]; + expect(selectorFor(pShapedId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(pShapedId)).not.toBe(pShapedId); + expect(selectorFor(fallbackId)).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selectorFor(fallbackId)).not.toBe(deterministicFallback); + }); + + test("does not expose another account id or deterministic fallback as a selector", () => { + const pShapedPrivateId = "p444444"; + const fallbackOwnerId = "other-private-account-id"; + const otherFallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "first-account", logLabel: pShapedPrivateId, isMain: false }, + { id: pShapedPrivateId, logLabel: "p555555", isMain: false }, + { id: "third-account", logLabel: otherFallback, isMain: false }, + { id: fallbackOwnerId, logLabel: "p666666", isMain: false }, + ], + }); + + const publicSelectors = Object.keys(namespaces); + expect(publicSelectors).not.toContain(pShapedPrivateId); + expect(publicSelectors).not.toContain(otherFallback); + }); + + test("suffix allocation cannot land on another private account id", () => { + const privateSuffix = "p888888-2"; + const namespaces = defaultCodexAccountNamespaces({ + providers: { + p888888: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: "first-account", logLabel: "p888888", isMain: false }, + { id: privateSuffix, logLabel: "p999999", isMain: false }, + ], + }); + + expect(Object.keys(namespaces)).not.toContain(privateSuffix); + expect(Object.entries(namespaces).find(([, target]) => target === "first-account")?.[0]) + .toBe("p888888-3"); + }); + + test("avoids provider and combo prefixes when allocating defaults", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: { p111111: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } }, + combos: { + primary: { + alias: "main/gpt-5.5", + targets: [{ provider: "p111111", model: "gpt-5.5" }], + }, + }, + codexAccounts: [{ + id: "stored-account-id", + email: "account@example.test", + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" }); + }); + + test("avoids provider names case-insensitively when allocating defaults", () => { + expect(defaultCodexAccountNamespaces({ + providers: { + Main: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + })).toEqual({ "main-2": "@main" }); + }); + + test("appends a safe selector without rewriting an explicit map", () => { + const codexAccountNamespaces = { chosen: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + const account = { + id: "new-account-id", + email: "private@example.test", + alias: "Private Display Alias", + logLabel: "p222222", + isMain: false, + }; + + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(true); + expect(config.codexAccountNamespaces).toEqual({ + chosen: "existing-account-id", + mainAccount: "@main", + p222222: "new-account-id", + }); + expect(appendDefaultCodexAccountNamespace(config, account)).toBe(false); + }); + + test("refuses to append an account id already owned by a selector key", () => { + const codexAccountNamespaces = { work: "existing-account-id", mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "work", + logLabel: "p232323", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ + work: "existing-account-id", + mainAccount: "@main", + }); + }); + + test("refuses to append the Desktop main account", () => { + const codexAccountNamespaces = { mainAccount: "@main" }; + const config = { providers: {}, codexAccountNamespaces }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "desktop-id", + logLabel: "p242424", + isMain: true, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("never generates targets for legacy account ids rejected by the namespace schema", () => { + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "constructor", logLabel: "p242425", isMain: false }, + { id: "valid-account", logLabel: "p242426", isMain: false }, + ], + })).toEqual({ main: "@main", p242426: "valid-account" }); + + const config = { + providers: {}, + codexAccountNamespaces: { mainAccount: "@main" }, + }; + expect(appendDefaultCodexAccountNamespace(config, { + id: "Constructor", + logLabel: "p242427", + isMain: false, + })).toBe(false); + expect(config.codexAccountNamespaces).toEqual({ mainAccount: "@main" }); + }); + + test("append avoids every existing private target and deterministic fallback", () => { + const existingTarget = "p777777"; + const fallbackOwnerId = "existing-private-account-id"; + const fallback = fallbackCodexAccountLogLabel(fallbackOwnerId); + + for (const logLabel of [existingTarget, fallback]) { + const config = { + providers: {}, + codexAccountNamespaces: { + existing: existingTarget, + fallbackOwner: fallbackOwnerId, + mainAccount: "@main", + }, + }; + const newAccountId = `new-account-${logLabel}`; + expect(appendDefaultCodexAccountNamespace(config, { + id: newAccountId, + logLabel, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === newAccountId)?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(logLabel); + } + }); + + test("append protects pool accounts omitted from an intentionally incomplete map", () => { + const unmappedPrivateId = "p121212"; + const config = { + providers: { + p343434: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: unmappedPrivateId, logLabel: "p565656", isMain: false }, + { id: "p343434-2", logLabel: "p787878", isMain: false }, + ], + codexAccountNamespaces: { mainAccount: "@main" }, + }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "new-account-id", + logLabel: unmappedPrivateId, + isMain: false, + })).toBe(true); + const selector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "new-account-id")?.[0]; + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(unmappedPrivateId); + + expect(appendDefaultCodexAccountNamespace(config, { + id: "second-new-account-id", + logLabel: "p343434", + isMain: false, + })).toBe(true); + const suffixedSelector = Object.entries(config.codexAccountNamespaces) + .find(([, target]) => target === "second-new-account-id")?.[0]; + expect(suffixedSelector).toBe("p343434-3"); + }); + + test("keeps empty maps inert and distinguishes a pool id named main from the Desktop account", () => { + const empty = { providers: {}, codexAccountNamespaces: {} as Record }; + expect(appendDefaultCodexAccountNamespace(empty, { + id: "side-account-id", + email: "side@example.test", + logLabel: "p333333", + isMain: false, + })).toBe(false); + expect(empty.codexAccountNamespaces).toEqual({}); + + expect(defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "main", email: "first@example.test", logLabel: "p454545", isMain: false }, + { id: MAIN_CODEX_ACCOUNT_ID, email: "second@example.test", isMain: false }, + { id: "desktop-row", email: "third@example.test", logLabel: "p464646", isMain: true }, + ], + })).toEqual({ "main-2": "@main", p454545: "main" }); + }); + + test("matches route and account namespaces exactly but provider namespaces case-insensitively", () => { + const inherited = Object.create({ inherited: "account-id" }) as Record; + inherited.side = "side-account-id"; + + expect(hasCodexAccountNamespace(inherited, "side")).toBe(true); + expect(hasCodexAccountNamespace(inherited, "inherited")).toBe(false); + expect(codexAccountNamespaceForModel(inherited, "side/gpt-5.5")).toBe("side"); + expect(codexAccountNamespaceForModel(inherited, "Side/gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceForModel(inherited, "gpt-5.5")).toBeUndefined(); + expect(codexAccountNamespaceProviderCollisionError(inherited, "side")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "SIDE")) + .toBe("provider name must not collide with a configured Codex account namespace"); + expect(codexAccountNamespaceProviderCollisionError(inherited, "inherited")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "side")) + .toBe("account id must not collide with a configured Codex account namespace"); + expect(codexAccountIdNamespaceCollisionError(inherited, "Side")).toBeUndefined(); + expect(codexAccountIdNamespaceCollisionError(inherited, "inherited")).toBeUndefined(); + }); + + test("normalizes only the explicit main sentinel and keeps a pool id named main literal", () => { + expect(isMainCodexAccountTarget("@main")).toBe(true); + expect(isMainCodexAccountTarget("main")).toBe(false); + expect(isMainCodexAccountTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(isValidCodexAccountNamespaceTarget("@main")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(isValidCodexAccountNamespaceTarget("side-account_2.test")).toBe(true); + expect(isValidCodexAccountNamespaceTarget(" account ")).toBe(false); + expect(isValidCodexAccountNamespaceTarget("account/id")).toBe(false); + for (const reserved of ["__proto__", "prototype", "constructor", "Constructor"]) { + expect(isValidCodexAccountNamespaceTarget(reserved)).toBe(false); + } + expect(codexAccountNamespaceEntries({ + codexAccountNamespaces: { primary: "@main", poolNamedMain: "main", side: "side-id" }, + })).toEqual([ + ["primary", MAIN_CODEX_ACCOUNT_ID], + ["poolNamedMain", "main"], + ["side", "side-id"], + ]); + }); +}); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 585d6eb6c..12d38b36d 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -9,7 +9,12 @@ import { markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, maskEmail, } from "../src/codex/auth-api"; -import { getCodexAccountCredential, readCodexAccountRecord, saveCodexAccountCredential } from "../src/codex/account-store"; +import { + getCodexAccountCredential, + listCodexAccountIds, + readCodexAccountRecord, + saveCodexAccountCredential, +} from "../src/codex/account-store"; import { getCodexUpstreamHealth, recordCodexUpstreamOutcome, @@ -24,6 +29,7 @@ import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { + deleteCodexAccount, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests, } from "../src/codex/account-lifecycle"; @@ -80,6 +86,84 @@ function mockCodexWarmupSuccess(): { calls: () => number } { return { calls: () => calls }; } +async function completeMockCodexOAuth(options: { + config: OcxConfig; + requestBody: { id: string; reauth?: boolean }; + oauthAccountId: string; + email: string; + onWarmup: () => void; + usageResponse?: () => Response; +}): Promise<{ startStatus: number; state: { status: string; error?: string } }> { + const oauth = await import("../src/oauth"); + const oauthStore = await import("../src/oauth/store"); + const openUrlMod = await import("../src/lib/open-url"); + await oauthStore.saveCredential("chatgpt", { + access: `access-${options.requestBody.id}`, + refresh: `refresh-${options.requestBody.id}`, + expires: Date.now() + 5 * 60_000, + email: options.email, + accountId: options.oauthAccountId, + }); + const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue({ url: "https://example.test/oauth" }); + const statusSpy = spyOn(oauth, "getLoginStatus").mockReturnValue({ + done: true, + loggedIn: true, + } as ReturnType); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); + // Mirrors the login-status poll delay in auth-api.ts; other timers are intentionally dropped. + const CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS) queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const target = String(input); + if (target === "https://chatgpt.com/backend-api/wham/usage") { + return options.usageResponse?.() + ?? new Response(JSON.stringify({ email: options.email, plan_type: "pro" }), { status: 200 }); + } + if (target === "https://chatgpt.com/backend-api/codex/responses") { + options.onWarmup(); + return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return previousFetch(input); + }) as typeof fetch; + + try { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(options.requestBody), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), options.config); + const started = await resp!.json() as { flowId: string }; + for (let attempt = 0; attempt < 500; attempt += 1) { + const statusReq = new Request( + `http://localhost/api/codex-auth/login-status?flowId=${started.flowId}`, + { method: "GET" }, + ); + const statusResp = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), options.config); + const state = await statusResp!.json() as { status: string; error?: string }; + if (state.status !== "pending") return { startStatus: resp!.status, state }; + await new Promise(resolve => queueMicrotask(resolve)); + } + throw new Error(`Timed out waiting for Codex OAuth flow ${started.flowId}`); + } finally { + globalThis.fetch = previousFetch; + timeoutSpy.mockRestore(); + startSpy.mockRestore(); + statusSpy.mockRestore(); + openSpy.mockRestore(); + } +} + function seedPoolAccount( config: OcxConfig, account: { @@ -938,6 +1022,25 @@ describe("codex-auth API", () => { expect(body.error).toContain("Invalid account id"); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "prototype", + "constructor", + "Constructor", + ])("POST /api/codex-auth/accounts rejects reserved account id %s", async (accountId) => { + enableManualImport(); + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: accountId })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + test("POST /api/codex-auth/accounts rejects invalid JSON when manual import is explicitly enabled", async () => { enableManualImport(); const req = new Request("http://localhost/api/codex-auth/accounts", { @@ -1068,6 +1171,62 @@ describe("codex-auth API", () => { }); }); + test("POST /api/codex-auth/accounts rejects an id owned by a namespace before warmup", async () => { + enableManualImport(); + let fetched = false; + globalThis.fetch = (async () => { + fetched = true; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); + const before = structuredClone(config); + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "work" })), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(fetched).toBe(false); + expect(config).toEqual(before); + expect(getCodexAccountCredential("work")).toBeNull(); + }); + + test("manual import rechecks namespace ownership after warmup before persistence", async () => { + enableManualImport(); + const config = makeConfig(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input) === "https://chatgpt.com/backend-api/codex/responses") { + config.codexAccountNamespaces = { "manual-race": "pool-a" }; + return new Response('event: response.completed\ndata: {"type":"response.completed"}\n\n', { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return previousFetch(input); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "manual-race" })), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "manual-race": "pool-a" }); + expect(getCodexAccountCredential("manual-race")).toBeNull(); + }); + test("PUT /api/codex-auth/auto-switch rejects invalid threshold", async () => { for (const bad of [-1, 101, 50.5, "abc"]) { const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -1126,6 +1285,17 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("work"); }); + test("PUT /api/codex-auth/accounts/alias preserves the dedicated main-account error", async () => { + const req = new Request("http://localhost/api/codex-auth/accounts/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: MAIN_CODEX_ACCOUNT_ID, alias: "Desktop" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Main Codex account alias is not configurable" }); + }); + test("PUT /api/codex-auth/auto-switch mutates live runtime config", async () => { const config = makeConfig({ autoSwitchThreshold: 80 }); const req = new Request("http://localhost/api/codex-auth/auto-switch", { @@ -1192,6 +1362,98 @@ describe("codex-auth API", () => { expect(getTrackedCodexWebSocketCountForAccount("pool-delete")).toBe(0); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "__PrOtO__", + "prototype", + "PROTOTYPE", + "constructor", + "Constructor", + ])("DELETE /api/codex-auth/accounts rejects reserved account id %s without mutation", async (accountId) => { + const config = makeConfig({ + activeCodexAccountId: "pool-untouched", + codexAccounts: [{ id: "pool-untouched", email: "pool-untouched@example.test", isMain: false }], + }); + saveCodexAccountCredential("pool-untouched", { + accessToken: "access-untouched", + refreshToken: "refresh-untouched", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-untouched", + }); + const beforeConfig = structuredClone(config); + const beforeCredential = getCodexAccountCredential("pool-untouched"); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + expect(config).toEqual(beforeConfig); + expect(listCodexAccountIds()).toEqual(["pool-untouched"]); + expect(getCodexAccountCredential("pool-untouched")).toEqual(beforeCredential); + }); + + test("DELETE /api/codex-auth/accounts removes a configured legacy reserved account", async () => { + const accountId = "Constructor"; + const config = makeConfig({ + activeCodexAccountId: accountId, + codexAccounts: [{ id: accountId, email: "legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(accountId, { + accessToken: "legacy-access", + refreshToken: "legacy-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-account", + }); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(accountId)}`, + { method: "DELETE" }, + ); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(accountId)).toBeNull(); + }); + + test("legacy non-main __main__ is quarantined and removable without touching Desktop auth", async () => { + const config = makeConfig({ + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + codexAccounts: [{ id: MAIN_CODEX_ACCOUNT_ID, email: "invalid-legacy@example.test", isMain: false }], + }); + saveCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID, { + accessToken: "legacy-pool-access", + refreshToken: "legacy-pool-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "legacy-pool-account", + }); + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "desktop-access", account_id: "desktop-account" }, + })); + + expect(getMainChatgptAccountId()).toBe("desktop-account"); + expect(resolveCodexAccountForThread("legacy-main-row", config)).toBeNull(); + + const req = new Request( + `http://localhost/api/codex-auth/accounts?id=${encodeURIComponent(MAIN_CODEX_ACCOUNT_ID)}`, + { method: "DELETE" }, + ); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccounts).toEqual([]); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(getCodexAccountCredential(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(getMainChatgptAccountId()).toBe("desktop-account"); + }); + test("GET /api/codex-auth/login-status returns idle by default", async () => { const req = new Request("http://localhost/api/codex-auth/login-status", { method: "GET" }); const url = new URL(req.url); @@ -1391,6 +1653,23 @@ describe("codex-auth API", () => { expect(data.error).toContain("Invalid account id"); }); + test.each([ + MAIN_CODEX_ACCOUNT_ID, + "__proto__", + "prototype", + "constructor", + "Constructor", + ])("POST /api/codex-auth/login rejects reserved account id %s before OAuth starts", async (accountId) => { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: accountId }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); + }); + test("POST /api/codex-auth/login rejects duplicate account id before OAuth starts", async () => { saveCodexAccountCredential("existing", { accessToken: "tok", @@ -1425,6 +1704,30 @@ describe("codex-auth API", () => { expect(data.error).toContain("Account id already exists"); }); + test("POST /api/codex-auth/login rejects an id owned by a namespace before OAuth starts", async () => { + const oauth = await import("../src/oauth"); + const startSpy = spyOn(oauth, "startLoginFlow"); + try { + const config = makeConfig({ codexAccountNamespaces: { work: "pool-a" } }); + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "work" }), + }); + + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "account id must not collide with a configured Codex account namespace", + }); + expect(startSpy).not.toHaveBeenCalled(); + expect(getCodexAccountCredential("work")).toBeNull(); + } finally { + startSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/login without reauth still rejects existing account id", async () => { const config = makeConfig({ codexAccounts: [{ id: "pool-no-reauth", email: "pool-no-reauth@example.test", isMain: false }], @@ -1481,6 +1784,7 @@ describe("codex-auth API", () => { test("POST /api/codex-auth/login with reauth does not reject existing pool account id", async () => { const config = makeConfig({ + codexAccountNamespaces: { "pool-reauth": "other-account" }, codexAccounts: [{ id: "pool-reauth", email: "pool-reauth@example.test", isMain: false }], }); saveCodexAccountCredential("pool-reauth", { @@ -1510,6 +1814,7 @@ describe("codex-auth API", () => { expect(resp!.status).not.toBe(400); const data = await resp!.json() as { ok?: boolean; flowId?: string; error?: string }; expect(data.error ?? "").not.toContain("already exists"); + expect(data.error ?? "").not.toContain("namespace"); expect(data.ok).toBe(true); expect(data.flowId).toBeTruthy(); expect(startSpy).toHaveBeenCalled(); @@ -1520,6 +1825,104 @@ describe("codex-auth API", () => { } }); + test("OAuth creation rejects a namespace claimed during warmup without persisting", async () => { + const config = makeConfig(); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "oauth-race" }, + oauthAccountId: "acct-oauth-race", + email: "oauth-race@example.test", + onWarmup: () => { + config.codexAccountNamespaces = { "oauth-race": "pool-a" }; + }, + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "account id must not collide with a configured Codex account namespace", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ "oauth-race": "pool-a" }); + expect(getCodexAccountCredential("oauth-race")).toBeNull(); + }); + + test("OAuth reauth cannot recreate an account deleted during warmup", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reauth-race", email: "reauth-race@example.test", isMain: false }], + }); + saveCodexAccountCredential("reauth-race", { + accessToken: "old-reauth-access", + refreshToken: "old-reauth-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-race", + }); + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-race", reauth: true }, + oauthAccountId: "acct-reauth-race", + email: "reauth-race@example.test", + onWarmup: () => deleteCodexAccount(config, "reauth-race"), + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "Pool account was removed while login was in progress. Add it again as a new account.", + }); + expect(config.codexAccounts).toEqual([]); + expect(getCodexAccountCredential("reauth-race")).toBeNull(); + }); + + test("deleteCodexAccount preserves a main account with the requested id", () => { + const mainAccount = { + id: "shared-account-id", + email: "main@example.test", + isMain: true, + }; + const config = makeConfig({ codexAccounts: [mainAccount] }); + saveCodexAccountCredential("shared-account-id", { + accessToken: "main-access", + refreshToken: "main-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "shared-account-id", + }); + + deleteCodexAccount(config, "shared-account-id"); + + expect(config.codexAccounts).toEqual([mainAccount]); + expect(getCodexAccountCredential("shared-account-id")).toBeNull(); + }); + + test("OAuth reauth preserves the stored plan when the usage probe fails", async () => { + const config = makeConfig({ + codexAccounts: [{ + id: "reauth-plan", + email: "reauth-plan@example.test", + plan: "business", + isMain: false, + }], + }); + saveCodexAccountCredential("reauth-plan", { + accessToken: "old-plan-access", + refreshToken: "old-plan-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-reauth-plan", + }); + + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: "reauth-plan", reauth: true }, + oauthAccountId: "acct-reauth-plan", + email: "reauth-plan@example.test", + onWarmup: () => {}, + usageResponse: () => new Response("unavailable", { status: 503 }), + }); + + expect(result.state).toMatchObject({ status: "done" }); + expect(config.codexAccounts?.[0]?.plan).toBe("business"); + }); + test("OAuth pool login excludes self from collision check when reauth", async () => { const source = await Bun.file("src/codex/auth-api.ts").text(); expect(source).toContain("checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined)"); diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5bbf5a0af..6d0135755 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -294,6 +294,27 @@ describe("combo management API", () => { }); }); + test("PUT rejects aliases owned by a Codex account namespace without mutating config", async () => { + await withTempHome(async () => { + const config = baseConfig({ codexAccountNamespaces: { side: "side-account-id" } }); + saveConfig(config); + const beforeMemory = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "intentional", + combo: { ...VALID_COMBO, alias: "side/gpt-5.5" }, + }); + + expect(response?.status).toBe(409); + expect(await responseJson(response)).toEqual({ + error: "combo alias must not use a configured Codex account namespace", + }); + expect(config).toEqual(beforeMemory); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + }); + }); + test("PUT rejects invalid and duplicate aliases without memory or disk mutation", async () => { await withTempHome(async () => { const config = baseConfig({ diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 87331c326..b3d4897f0 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -6,6 +6,7 @@ import { armClaudeCodeBaseline, getConfigPath, loadConfig, + reconcileLiveConfigFromDisk, saveConfig, saveConfigPreservingClaudeCode, } from "../src/config"; @@ -136,6 +137,42 @@ test("our own change wins a conflict and rebases the baseline", () => { expect((diskConfig().claudeCode as Record).authMode).toBe("proxy"); }); +test("OAuth reconciliation keeps a pending live Claude subtree authoritative", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + const persistedBaseline = loadConfig(); + live.claudeCode = { authMode: "subscription", systemEnv: true }; + live.disabledModels = ["pending/model"]; + writeDiskConfig({ + claudeCode: { authMode: "proxy" }, + contextCapValue: 240_000, + }); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(live.disabledModels).toEqual(["pending/model"]); + expect(live.contextCapValue).toBe(240_000); + + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(diskConfig().disabledModels).toEqual(["pending/model"]); + expect(diskConfig().contextCapValue).toBe(240_000); +}); + +test("OAuth reconciliation adopts a guarded Claude edit that predates its disk snapshot", () => { + const live = loadConfig(); + armClaudeCodeBaseline(live); + writeDiskConfig({ claudeCode: { authMode: "proxy" } }); + const persistedBaseline = loadConfig(); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.claudeCode).toEqual({ authMode: "proxy" }); + saveConfigPreservingClaudeCode(live); + expect(diskConfig().claudeCode).toEqual({ authMode: "proxy" }); +}); + // Structural compare, not JSON.stringify: key order must not fake an external edit. test("a key-order-only difference is not treated as an external edit", () => { const live = loadConfig(); diff --git a/tests/config.test.ts b/tests/config.test.ts index 54cfa7292..9873b2bf5 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -66,6 +66,25 @@ function writeResponsesPathConfig(responsesPath: string): void { }); } +function writeAccountNamespaceConfig( + codexAccountNamespaces: unknown, + overrides: Record = {}, +): void { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountNamespaces, + ...overrides, + }); +} + describe("opencodex config defaults", () => { test("atomic rename retries transient Windows sharing violations", () => { const sleeps: number[] = []; @@ -991,6 +1010,170 @@ describe("opencodex config defaults", () => { expect(isValidProviderName("constructor")).toBe(false); }); + test("persists an explicit Codex account selector map without enabling it by default", () => { + const selectors = { + desktop: "@main", + work: "work-account", + legacy: "work-account", + poolNamedMain: "main", + }; + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountNamespaces).toEqual(selectors); + expect(Object.hasOwn(getDefaultConfig(), "codexAccountNamespaces")).toBe(false); + }); + + test("validates Claude Desktop profiles and Codex account selectors independently", () => { + const desktopProfile = { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }; + writeAccountNamespaceConfig({ main: "@main" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics()).toMatchObject({ + error: null, + config: { claudeCode: { desktopProfile }, codexAccountNamespaces: { main: "@main" } }, + }); + + writeAccountNamespaceConfig({ main: "@main" }, { + claudeCode: { desktopProfile: { ...desktopProfile, version: 2 } }, + }); + expect(readConfigDiagnostics().error).toContain("claudeCode.desktopProfile"); + + writeAccountNamespaceConfig({ "bad/selector": "account-id" }, { claudeCode: { desktopProfile } }); + expect(readConfigDiagnostics().error).toContain("codexAccountNamespaces.bad/selector"); + }); + + test.each([ + ["null", null], + ["an array", []], + ["a string", "main"], + ] as const)("rejects Codex account selectors stored as %s", (_label, selectors) => { + writeAccountNamespaceConfig(selectors); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("codexAccountNamespaces must be a plain object"); + }); + + test.each([ + ["blank", "", "side-account"], + ["surrounding whitespace", " side", "side-account"], + ["a slash", "side/account", "side-account"], + ["a reserved prototype key", "__proto__", "side-account"], + ["a reserved constructor key", "constructor", "side-account"], + ["an empty target", "side", ""], + ["the internal main account id", "side", "__main__"], + ["a reserved prototype target", "side", "__proto__"], + ["a reserved prototype-name target", "side", "prototype"], + ["a reserved constructor target", "side", "Constructor"], + ["a target with whitespace", "side", "side account"], + ["a target with a slash", "side", "account/id"], + ["an overlong target", "side", "a".repeat(65)], + ["a non-string target", "side", 42], + ] as const)("rejects %s in the Codex account selector map", (_label, selector, target) => { + writeAccountNamespaceConfig(Object.fromEntries([[selector, target]])); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(`codexAccountNamespaces.${selector}`); + }); + + test.each([ + [ + "a configured provider", + { side: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + [ + "a configured provider with different casing", + { SIDE: "side-account" }, + { + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + }, + "must not collide", + ], + ["the combo namespace", { combo: "side-account" }, {}, "must not collide"], + ["the combo namespace with different casing", { Combo: "side-account" }, {}, "must not collide"], + ["the canonical OpenAI namespace with different casing", { OpenAI: "side-account" }, {}, "must not collide"], + [ + "the canonical OpenAI provider namespace before legacy migration", + { openai: "side-account" }, + { + providers: { + "openai-multi": { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai-multi", + }, + "must not collide", + ], + [ + "a combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: "side/gpt-5.5", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a whitespace-padded combo alias prefix", + { side: "side-account" }, + { + combos: { + intentional: { + alias: " side/gpt-5.5 ", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + }, + "combo alias must not use a configured Codex account namespace", + ], + [ + "a configured pool account id", + { work: "pool-a" }, + { + codexAccounts: [{ + id: "work", + email: "work@example.test", + isMain: false, + }], + }, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + [ + "another selector target", + { primary: "side", side: "pool-a" }, + {}, + "must not collide with configured Codex pool-account ids or account selector targets", + ], + ] as const)("rejects a Codex account selector colliding with %s", (_label, selectors, overrides, error) => { + writeAccountNamespaceConfig(selectors, overrides); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(error); + }); + test("backs up config when defaultProvider only exists on Object prototype", () => { writeConfig({ port: 10100, diff --git a/tests/kiro-review-regressions.test.ts b/tests/kiro-review-regressions.test.ts index 9f11edb1b..b1e18f2d9 100644 --- a/tests/kiro-review-regressions.test.ts +++ b/tests/kiro-review-regressions.test.ts @@ -221,7 +221,13 @@ describe("Kiro review regressions", () => { OAUTH_PROVIDERS.kiro.login = originalLogin; } - expect(events).toEqual(["load-config", "save-config", "settle:false"]); + expect(events).toEqual([ + "load-config", // namespace preflight before browser/CLI auth + "load-config", // provider validation before credential persistence + "load-config", // latest-row upsert after credential persistence + "save-config", + "settle:false", + ]); expect(getAccountSet("kiro")?.activeAccountId).toBe(previousActive); expect(getAccountSet("kiro")?.accounts).toHaveLength(1); expect(getAccountCredential("kiro", previousActive)).toMatchObject({ diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index dd11eacb2..4f9b15abd 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -517,6 +517,44 @@ describe("provider management validation", () => { } }); + test("provider management rejects names owned by a Codex account namespace without mutating config", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg = { + ...config("127.0.0.1"), + codexAccountNamespaces: { side: "side-account-id" }, + }; + saveConfig(cfg); + const beforeMemory = structuredClone(cfg); + const beforeDisk = readFileSync(join(TEST_DIR, "config.json"), "utf8"); + + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "side", + provider: { + adapter: "openai-chat", + baseUrl: "https://side.example.test/v1", + }, + }), + }), + requestUrl, + cfg, + { refreshCodexCatalog: async () => {} }, + ); + + expect(response?.status).toBe(409); + expect(await response?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + expect(cfg).toEqual(beforeMemory); + expect(readFileSync(join(TEST_DIR, "config.json"), "utf8")).toBe(beforeDisk); + }); + test("provider management rejects base URLs with embedded credentials", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/oauth-public-surface.test.ts b/tests/oauth-public-surface.test.ts index aa699e417..43668008d 100644 --- a/tests/oauth-public-surface.test.ts +++ b/tests/oauth-public-surface.test.ts @@ -1,18 +1,24 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { + clearLoginState, + getLoginStatus, isOAuthProvider, isPublicOAuthProvider, listOAuthProviders, OAUTH_PROVIDERS, runLogin, + startLoginFlow, upsertOAuthProvider, } from "../src/oauth"; import { handleManagementAPI } from "../src/server/management-api"; import type { OcxConfig } from "../src/types"; import type { OAuthController } from "../src/oauth/types"; import { getCredential } from "../src/oauth/store"; +import * as oauthStore from "../src/oauth/store"; +import { armClaudeCodeBaseline, loadConfig, saveConfig, saveConfigPreservingClaudeCode } from "../src/config"; +import { isApiAuthRequired, requireApiAuth } from "../src/server/auth-cors"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-public-surface"); const previousHome = process.env.OPENCODEX_HOME; @@ -32,17 +38,28 @@ function config(): OcxConfig { } beforeEach(() => { + clearLoginState("xai"); rmSync(TEST_DIR, { recursive: true, force: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; }); afterEach(() => { + clearLoginState("xai"); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(TEST_DIR, { recursive: true, force: true }); }); +async function waitForOAuthDone(provider: string): Promise> { + for (let attempt = 0; attempt < 200; attempt += 1) { + const status = getLoginStatus(provider); + if (status.done) return status; + await Bun.sleep(5); + } + throw new Error(`OAuth login for ${provider} did not settle`); +} + describe("legacy ChatGPT OAuth public-surface exclusion", () => { test("keeps low-level compatibility but excludes public discovery", () => { expect(isOAuthProvider("chatgpt")).toBe(true); @@ -99,4 +116,421 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(getCredential("chatgpt")?.access).toBe("legacy-access"); expect(cfg.providers.chatgpt).toBeUndefined(); }); + + test("OAuth provider creation rejects account namespace collisions before login or mutation", async () => { + const cfg = config(); + cfg.codexAccountNamespaces = { XAI: "side-account-id" }; + const before = structuredClone(cfg.providers); + + expect(() => upsertOAuthProvider(cfg, "xai")).toThrow(/must not collide with a configured Codex account namespace/); + expect(cfg.providers).toEqual(before); + + const routeReq = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const routeResponse = await handleManagementAPI(routeReq, new URL(routeReq.url), cfg); + expect(routeResponse?.status).toBe(409); + expect(await routeResponse?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + + saveConfig(cfg); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let loginCalls = 0; + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(0); + expect(getCredential("xai")).toBeNull(); + + saveConfig(config()); + OAUTH_PROVIDERS.xai.login = async () => { + loginCalls += 1; + const changedDuringLogin = config(); + changedDuringLogin.codexAccountNamespaces = { xai: "side-account-id" }; + saveConfig(changedDuringLogin); + return { access: "must-not-save", refresh: "must-not-save" }; + }; + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /must not collide with a configured Codex account namespace/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + } + expect(loginCalls).toBe(1); + expect(getCredential("xai")).toBeNull(); + }); + + test("OAuth provider creation preserves a namespace claimed after credential persistence", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + let changedAfterCredential = false; + let credentialWrites = 0; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "post-check-access", + refresh: "post-check-refresh", + accountId: "post-check-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + credentialWrites += 1; + await originalSaveCredential(provider, credential); + const changed = config(); + changed.defaultProvider = "concurrent"; + changed.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + changed.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + saveConfig(changed); + changedAfterCredential = true; + }); + + try { + await expect(runLogin("xai", {} as OAuthController)).rejects.toThrow( + /credential for "xai" was saved, but the provider entry was not written/, + ); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(changedAfterCredential).toBe(true); + expect(credentialWrites).toBe(1); + expect(getCredential("xai")?.access).toBe("post-check-access"); + const persistedConfig = loadConfig(); + expect(persistedConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(persistedConfig.providers.xai).toBeUndefined(); + }); + + test("OAuth provider creation preserves same-provider key changes during credential persistence", async () => { + const seeded = config(); + seeded.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + authMode: "key", + apiKey: "test-key-a", + apiKeyPool: [{ id: "key-a", key: "test-key-a" }], + }; + saveConfig(seeded); + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async () => ({ + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }); + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const changed = loadConfig(); + changed.providers.xai = { + ...changed.providers.xai!, + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }; + saveConfig(changed); + }); + + try { + await runLogin("xai", {} as OAuthController); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + } + + expect(loadConfig().providers.xai).toMatchObject({ + authMode: "key", + apiKey: "test-key-b", + apiKeyPool: [ + { id: "key-a", key: "test-key-a" }, + { id: "key-b", key: "test-key-b" }, + ], + }); + }); + + test("management OAuth activates the live provider only after persistence succeeds", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let releaseLogin!: () => void; + const loginGate = new Promise((resolve) => { releaseLogin = resolve; }); + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + await loginGate; + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(getLoginStatus("xai").done).toBe(false); + + releaseLogin(); + const status = await waitForOAuthDone("xai"); + expect(status.error).toBeUndefined(); + expect(status.loggedIn).toBe(true); + expect(liveConfig.providers.xai).toEqual(loadConfig().providers.xai); + expect(liveConfig.providers.xai).toBeDefined(); + } finally { + releaseLogin(); + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth merges its provider row with a pending live provider edit", async () => { + const liveConfig = config(); + saveConfig(liveConfig); + liveConfig.providers.xai = { + ...OAUTH_PROVIDERS.xai.providerConfig, + selectedModels: ["pending-model"], + }; + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "same-provider-device-code", + }); + return { + access: "same-provider-access", + refresh: "same-provider-refresh", + accountId: "same-provider-account", + expires: Date.now() + 60_000, + }; + }; + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status).toMatchObject({ done: true, loggedIn: true }); + expect(status.error).toBeUndefined(); + expect(liveConfig.providers.xai).toMatchObject({ + ...loadConfig().providers.xai, + selectedModels: ["pending-model"], + }); + + saveConfigPreservingClaudeCode(liveConfig); + expect(loadConfig().providers.xai?.selectedModels).toEqual(["pending-model"]); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement preserves the original login failure", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "failed-login-device-code", + }); + throw new Error("browser flow aborted"); + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("browser flow aborted"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("OAuth settlement reports reconciliation failure after a successful login", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "successful-login-device-code", + }); + return { + access: "successful-access", + refresh: "successful-refresh", + accountId: "successful-account", + expires: Date.now() + 60_000, + }; + }; + + try { + await startLoginFlow("xai", undefined, { + onSettled: () => { throw new Error("runtime reconciliation failed"); }, + }); + const status = await waitForOAuthDone("xai"); + expect(status.done).toBe(true); + expect(status.error).toBe("runtime reconciliation failed"); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("management OAuth safely reconciles live config after a late namespace claim", async () => { + const liveConfig = config(); + liveConfig.hostname = "0.0.0.0"; + liveConfig.port = 10444; + liveConfig.claudeCode = { authMode: "subscription" }; + saveConfig(liveConfig); + armClaudeCodeBaseline(liveConfig); + // Model visibility has mutated the shared object but yielded before saving. OAuth + // must not replace it with the pre-mutation disk snapshot when login settles. + liveConfig.disabledModels = ["pending/provider-model"]; + const originalLogin = OAUTH_PROVIDERS.xai.login; + const originalSaveCredential = oauthStore.saveCredential; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + ctrl.onAuth({ + url: "https://auth.example.test/authorize", + deviceCode: "test-device-code", + }); + return { + access: "route-collision-access", + refresh: "route-collision-refresh", + accountId: "route-collision-account", + expires: Date.now() + 60_000, + }; + }; + const saveSpy = spyOn(oauthStore, "saveCredential").mockImplementation(async (provider, credential) => { + await originalSaveCredential(provider, credential); + const concurrentConfig = config(); + concurrentConfig.defaultProvider = "concurrent"; + concurrentConfig.providers.concurrent = { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }; + concurrentConfig.codexAccountNamespaces = { + XAI: "side-account-id", + retained: "retained-account-id", + }; + concurrentConfig.claudeCode = { authMode: "proxy" }; + saveConfig(concurrentConfig); + }); + + try { + const request = new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + expect(response?.status).toBe(200); + + const status = await waitForOAuthDone("xai"); + expect(status.loggedIn).toBe(true); + expect(status.error).toMatch(/credential for "xai" was saved, but the provider entry was not written/); + expect(getCredential("xai")?.access).toBe("route-collision-access"); + expect(liveConfig).toMatchObject({ + defaultProvider: "concurrent", + providers: { + concurrent: { + adapter: "openai-chat", + baseUrl: "https://concurrent.example.test/v1", + }, + }, + codexAccountNamespaces: { + XAI: "side-account-id", + retained: "retained-account-id", + }, + }); + expect(liveConfig.providers.xai).toBeUndefined(); + expect(liveConfig.claudeCode?.authMode).toBe("proxy"); + expect(liveConfig.disabledModels).toEqual(["pending/provider-model"]); + // Reconciliation must not make the externally bound socket look loopback-only. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(isApiAuthRequired(liveConfig)).toBe(true); + const forgedLoopbackRequest = new Request("http://localhost:10444/api/config", { + headers: { host: "localhost:10444" }, + }); + expect(requireApiAuth(forgedLoopbackRequest, liveConfig, "management")?.status).toBe(401); + expect(loadConfig().providers.xai).toBeUndefined(); + + // A second disk edit must be compared with the state OAuth just adopted, not + // the stale startup baseline, or this unrelated save would restore "proxy". + const editedAgain = loadConfig(); + editedAgain.hostname = "127.0.0.1"; + editedAgain.port = 11445; + editedAgain.claudeCode = { authMode: "subscription", systemEnv: true }; + saveConfig(editedAgain); + + saveConfigPreservingClaudeCode(liveConfig); + const afterLaterSave = loadConfig(); + expect(afterLaterSave.codexAccountNamespaces).toEqual({ + XAI: "side-account-id", + retained: "retained-account-id", + }); + expect(afterLaterSave.providers.concurrent).toBeDefined(); + expect(afterLaterSave.providers.xai).toBeUndefined(); + expect(afterLaterSave.disabledModels).toEqual(["pending/provider-model"]); + expect(afterLaterSave.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + expect(liveConfig.claudeCode).toEqual({ authMode: "subscription", systemEnv: true }); + // Runtime admission remains tied to the open socket, but the next-start + // binding adopted from disk must survive this unrelated live save. + expect(liveConfig.hostname).toBe("0.0.0.0"); + expect(liveConfig.port).toBe(10444); + expect(afterLaterSave.hostname).toBe("127.0.0.1"); + expect(afterLaterSave.port).toBe(11445); + expect(loadConfig()).toMatchObject({ hostname: "127.0.0.1", port: 11445 }); + } finally { + OAUTH_PROVIDERS.xai.login = originalLogin; + saveSpy.mockRestore(); + clearLoginState("xai"); + } + }); }); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 2ad8bb630..3c6dc5186 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -300,6 +300,7 @@ describe("server local API auth", () => { test("safeConfigDTO redacts provider secrets and exposes booleans", () => { const unsafe = config("127.0.0.1"); unsafe.openaiProviderTierVersion = 1; + unsafe.codexAccountNamespaces = { side: "private-account-id" }; Object.assign(unsafe.providers.openai as unknown as Record, { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, @@ -321,6 +322,7 @@ describe("server local API auth", () => { "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "recorder-runtime", "_codexAccountOverride", "_codexAccountRequired", "runtime-token", "override-token", + "codexAccountNamespaces", "private-account-id", ]) expect(serialized).not.toContain(forbidden); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", From f0867e8d4aeb584adee28a49eab0b0e15f764f9e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:03:27 +0200 Subject: [PATCH 09/16] feat(codex): account pause controls and bulk exhaustion (#667) Maintainer takeover of #565. Persisted pause exclusion, bulk pause-exhausted, GUI controls, docs. Integrated with account namespaces and Spark quota scopes on current dev. --- README.md | 6 +- .../docs/ja/reference/configuration.md | 5 + .../docs/ko/reference/configuration.md | 5 + .../content/docs/reference/configuration.md | 9 + .../docs/ru/reference/configuration.md | 7 + .../docs/zh-cn/reference/configuration.md | 5 + gui/src/components/CodexAccountPool.tsx | 43 +- .../components/codex-account-pool-cards.tsx | 29 +- .../codex-account-pool-main-card.tsx | 71 +++- gui/src/hooks/useCodexAccountPool.ts | 83 +++- gui/src/i18n/de.ts | 13 + gui/src/i18n/en.ts | 13 + gui/src/i18n/ja.ts | 13 + gui/src/i18n/ko.ts | 13 + gui/src/i18n/ru.ts | 13 + gui/src/i18n/zh.ts | 13 + gui/src/icons.tsx | 2 + .../active-account-reauth-aggregate.test.ts | 3 +- .../codex-account-pool-behaviour.test.tsx | 143 ++++++- .../codex-account-pool-controller.test.ts | 26 +- .../codex-account-pool-toast-tone.test.tsx | 7 +- src/codex/account-lifecycle.ts | 2 + src/codex/account-pause.ts | 20 + src/codex/auth-api.ts | 170 +++++++- src/codex/quota.ts | 15 + src/codex/routing.ts | 35 +- src/codex/subagent-model-fallback.ts | 2 + src/config.ts | 1 + src/types.ts | 2 + structure/08_openai-provider-tiers.md | 7 + tests/codex-auth-api.test.ts | 381 +++++++++++++++++- tests/codex-auth-context.test.ts | 31 ++ tests/codex-routing.test.ts | 66 ++- tests/config.test.ts | 24 +- tests/provider-workspace-auth.test.ts | 4 +- tests/rate-limit-reset-credits.test.ts | 2 +- 36 files changed, 1237 insertions(+), 47 deletions(-) create mode 100644 src/codex/account-pause.ts diff --git a/README.md b/README.md index 4cbd50f62..cd8d98074 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,15 @@ next Codex session. opencodex keeps these behaviors: - **Existing sessions keep affinity.** A thread id is bound to the selected account and reused on later turns, so a long request or a mobile/SSH-attached session keeps using the same account. + Pausing an account clears its affinity map: in-flight requests keep captured credentials, but + subsequent turns are re-routed and cannot reuse the paused account. - **New sessions can auto-route.** When auto-switch is enabled, opencodex compares the hottest known quota window across 5h, weekly, and 30d usage, then picks a lower-usage eligible account for new sessions once the active account crosses the threshold. - **Quota lookup is built in.** The dashboard can refresh all account quotas in one click, and the - request log labels pool traffic with non-PII account ordinals. + request log labels pool traffic with non-PII account ordinals. **Pause exhausted** refreshes + eligible accounts that have credentials and pauses only those whose relevant quota window is + freshly confirmed at 100%; accounts without credentials and unknown or failed refreshes stay unchanged. - **Failures fail closed.** Token failures mark reauthentication instead of falling back to another credential silently; 429 quota responses put the account in cooldown and can fail over future work to another eligible pool account. diff --git a/docs-site/src/content/docs/ja/reference/configuration.md b/docs-site/src/content/docs/ja/reference/configuration.md index 10e6c10c9..f98785556 100644 --- a/docs-site/src/content/docs/ja/reference/configuration.md +++ b/docs-site/src/content/docs/ja/reference/configuration.md @@ -51,6 +51,7 @@ namespaced selected id を bare id に変えます。 | `codexShimAutoRestore?` | `boolean` | `true` | 完了した外部 Codex 更新で以前にインストールした shim が置換された場合に復元します。無効にするには `false`、またはプロセスで `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 | | `syncResumeHistory?` | `boolean` | `true` | 戻せる Codex App 履歴互換モード。opencodex は元の Codex thread metadata をバックアップし、旧 OpenAI interactive row を `opencodex` に再マッピングし、opencodex が作成した `exec` row を App に見えるソースとして一時的に昇格します。`ocx stop` / `ocx restore` はバックアップした OpenAI row を復元し、残った opencodex user thread を OpenAI に戻し、ネイティブ Codex が `config.toml` からプロキシを削除した後でも開き続けられるようにします。オフにするには `false` に設定します。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth ダッシュボードが管理する ChatGPT/Codex pool アカウント metadata。secret は `codex-accounts.json` に別途置きます。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth で再開するまで、今後のすべての Pool 選択から除外するアカウント ID。メインを一時停止した場合は `__main__` も含みます。 | | `codexAccountNamespaces?` | `Record` | — | 公開 model selector namespace から保存済み Codex アカウント target への任意 map。この foundation layer は map を検証・保存しますが、picker row の追加や routing の変更は行いません。 | | `activeCodexAccountId?` | `string` | — | 手動選択した pool アカウント。既存 thread affinity を消去して次のリクエストから適用し、処理中のリクエストは現在のアカウントを維持します。 | | `autoSwitchThreshold?` | `number` | `80` | 新しいセッション自動切替用の使用量百分率 threshold。既知の 5 時間、週次、30 日 quota window のうち最も高いスコアを使います。`0` なら quota 自動切替をオフにします。`quota` 戦略と `fill-first` の drain threshold にも使います。 | @@ -85,6 +86,10 @@ pool アカウントの追加と quota 更新はダッシュボードの **Codex ないアカウント metadata だけを保存し、access/refresh token は強化された Codex アカウント credential store に別途 保管します。既存 thread id はアカウント affinity を維持し、新しいセッションは `accountPoolStrategy`、quota、cooldown、health に 応じて自動ルーティングされます。 +一時停止したアカウントと quota metadata は表示されたままですが、自動切り替え、再試行/failover 選択、cooldown 復旧プローブ、手動有効化の対象外です。 +一時停止するとそのアカウントの thread affinity map も消去されます。処理中のリクエストは取得済み credential を維持しますが、以降のターンは再ルーティングされ、一時停止中のアカウントは再利用できません。 +状態は再起動後も保持され、すべてのアカウントが一時停止中なら Pool ルーティングは別のアカウントを暗黙に選ばず失敗します。 +**上限到達を一括停止** は credential がある適格アカウントだけを先に更新し、関連する quota window が今回 100% と確認できたアカウントだけを停止します。credential がないアカウントや、quota が不明、または更新に失敗したアカウントは変更しません。 **rotation 戦略**(新しいセッションのみ;bound thread は不変):`quota`(既定)— `autoSwitchThreshold` 超過時に最小 usage を選択;`round-robin` — 均等分散、`accountPoolStickyLimit`(既定 `1`、1–100)で 1 選択あたりの成功 bind 数;`fill-first` — アクティブアカウントを cooldown、再認証、または threshold まで使い切り(未知 usage は強制切替しない)後、安定ソート順で次へ。rotation は provider enforcement を回避しません — 複数アカウント利用は ToS 違反の可能性があります。 ::: diff --git a/docs-site/src/content/docs/ko/reference/configuration.md b/docs-site/src/content/docs/ko/reference/configuration.md index dc55a2de9..c3fa6f50c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration.md +++ b/docs-site/src/content/docs/ko/reference/configuration.md @@ -52,6 +52,7 @@ namespaced selected id를 bare id로 바꿉니다. | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 이전에 설치한 shim을 교체하면 자동으로 복구합니다. 끄려면 `false`로 설정하거나 프로세스에 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정합니다. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App 기록 호환 모드. opencodex가 원래 Codex thread metadata를 백업하고, 예전 OpenAI interactive row를 `opencodex`로 재매핑하며, opencodex가 만든 `exec` row를 App에 보이는 source로 잠시 승격합니다. `ocx stop` / `ocx restore`는 백업한 OpenAI row를 복원하고 남은 opencodex user thread를 OpenAI로 돌려 네이티브 Codex가 `config.toml`에서 프록시를 제거한 뒤에도 이어서 열 수 있게 합니다. 끄려면 `false`로 설정합니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 대시보드에서 관리하는 ChatGPT/Codex pool 계정 metadata. secret은 `codex-accounts.json`에 따로 둡니다. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Codex Auth에서 재개할 때까지 이후의 모든 Pool 선택에서 제외할 계정 ID. 메인 계정을 일시 중지하면 `__main__`도 포함됩니다. | | `codexAccountNamespaces?` | `Record` | — | 공개 model selector namespace에서 저장된 Codex 계정 target으로 연결하는 선택적 map입니다. 이 foundation layer는 map을 검증하고 저장하지만 picker row를 추가하거나 routing을 변경하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 수동으로 선택한 pool 계정. 선택 시 기존 thread affinity를 지우고 다음 요청부터 적용하며, 진행 중인 요청은 기존 계정을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 새 세션 자동 전환용 사용량 백분율 threshold. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`이면 quota 자동 전환을 끕니다. `quota` 전략과 `fill-first` drain threshold에도 사용됩니다. | @@ -87,6 +88,10 @@ pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지 아닌 계정 metadata만 저장하고, access/refresh token은 강화된 Codex 계정 credential store에 따로 보관합니다. 기존 thread id는 계정 affinity를 유지하며, 새 세션은 `accountPoolStrategy`, quota, cooldown, health에 따라 자동 라우팅됩니다. +일시 중지된 계정과 quota metadata는 계속 표시되지만 자동 전환, 재시도/failover 선택, cooldown 복구 probe, 수동 활성화에서는 제외됩니다. +일시 중지는 해당 계정의 thread affinity map도 지웁니다. 진행 중인 요청은 이미 확보한 credential을 유지하지만, 이후 턴은 다시 라우팅되며 일시 중지된 계정은 재사용할 수 없습니다. +상태는 재시작 후에도 유지되며, 모든 계정이 일시 중지되면 Pool 라우팅은 계정을 몰래 선택하지 않고 실패합니다. +**한도 도달 계정 일시 중지**는 credential이 있는 적격 계정만 먼저 새로고친 뒤 관련 quota window가 이번 응답에서 100%로 확인된 계정만 일시 중지합니다. credential이 없는 계정과 quota가 없거나 새로고침에 실패한 계정은 변경하지 않습니다. **rotation 전략**(새 세션만; bound thread는 변경 없음): `quota`(기본) — `autoSwitchThreshold` 초과 시 최저 usage 선택; `round-robin` — 균등 분배, `accountPoolStickyLimit`(기본 `1`, 1–100)로 한 선택당 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 4a4fb90b2..700cfd5d7 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -57,6 +57,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from every future Pool selection until resumed in Codex Auth. Includes the main `__main__` account when paused. | | `codexAccountNamespaces?` | `Record` | — | Optional public model-selector namespace → stored Codex account target map. This foundation layer validates and persists the map but does not add picker rows or change routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. Used by the `quota` strategy and as the drain threshold for `fill-first`. | @@ -115,6 +116,14 @@ credential store. Existing thread ids keep account affinity, while new sessions `accountPoolStrategy`, quota, cooldown, and health. A pre-stream upstream **429**/**402** on one pool account is retried once on an eligible alternate account in the same request (so Codex CLI does not stall on a depleted primary while another account still has quota). +Pause keeps an account and its quota metadata visible, but excludes it from automatic switching, +retry/failover selection, cooldown recovery probes, and manual activation. Pausing also clears that +account's thread-affinity map: in-flight requests keep their captured credentials, but subsequent +turns are re-routed and cannot reuse the paused account. The exclusion survives +restarts; if every account is paused, Pool routing fails instead of silently selecting one. +**Pause exhausted** first refreshes eligible accounts that have credentials available and pauses +only those whose relevant quota window is freshly confirmed at 100%; accounts without credentials +and unknown or failed quota refreshes are left unchanged. ::: **Rotation strategies** (new sessions only; bound threads are unchanged): diff --git a/docs-site/src/content/docs/ru/reference/configuration.md b/docs-site/src/content/docs/ru/reference/configuration.md index 97db567f8..d90d44766 100644 --- a/docs-site/src/content/docs/ru/reference/configuration.md +++ b/docs-site/src/content/docs/ru/reference/configuration.md @@ -56,6 +56,7 @@ opencodex настраивается файлом `~/.opencodex/config.json`. Е | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает ранее установленный shim после того, как завершённое внешнее обновление Codex заменило его. Для отключения задайте `false` или установите процессу `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. opencodex резервирует исходные метаданные потоков Codex, переназначает старые интерактивные строки OpenAI на `opencodex` и временно повышает созданные opencodex строки `exec` до видимого в приложении источника. `ocx stop` / `ocx restore` восстанавливают зарезервированные строки OpenAI и возвращают оставшиеся пользовательские потоки opencodex обратно к OpenAI, чтобы нативный Codex мог возобновлять их после удаления прокси из `config.toml`. Установите `false`, чтобы отказаться. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, управляемые дашбордом Codex Auth. Секреты хранятся отдельно в `codex-accounts.json`. | +| `pausedCodexAccountIds?` | `string[]` | `[]` | ID аккаунтов, исключённых из всех будущих выборов Pool до возобновления в Codex Auth. При паузе основного аккаунта включает `__main__`. | | `codexAccountNamespaces?` | `Record` | — | Необязательная map публичного namespace селектора модели на сохранённую цель аккаунта Codex. Этот foundation layer проверяет и сохраняет map, но не добавляет строки picker и не меняет routing. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт пула. Выбор очищает существующие привязки потоков и действует со следующего запроса; выполняющиеся запросы сохраняют захваченный аккаунт. | | `autoSwitchThreshold?` | `number` | `80` | Порог процента использования для автопереключения новых сессий. Оценка использует самое «горячее» из известных окон квоты — 5-часовое, недельное или 30-дневное. Установите `0`, чтобы отключить автопереключение по квоте. Используется стратегией `quota` и как порог исчерпания для `fill-first`. | @@ -94,6 +95,12 @@ raw id аккаунтов и email приватными, а селектор и защищённом хранилище учётных данных аккаунтов Codex. Существующие id потоков сохраняют привязку к аккаунту; новые сессии маршрутизируются по `accountPoolStrategy`, квоте, cooldown и работоспособности. +Приостановленный аккаунт и его метаданные квоты остаются видимыми, но исключаются из автоматического переключения, +повторов/failover, проб восстановления cooldown и ручной активации. Пауза также очищает карту affinity потоков +этого аккаунта: выполняющиеся запросы сохраняют захваченные учётные данные, но последующие ходы +перемаршрутизируются и не могут повторно использовать приостановленный аккаунт. Состояние сохраняется после перезапуска; +если приостановлены все аккаунты, маршрутизация Pool завершается ошибкой, а не выбирает аккаунт скрытно. +**Приостановить исчерпанные** сначала обновляет только подходящие аккаунты с доступными учётными данными и приостанавливает только те, для которых актуальное окно квоты в этом ответе подтверждено на уровне 100%. Аккаунты без учётных данных, с неизвестной квотой или неудачным обновлением не меняются. **Стратегии ротации** (только новые сессии; привязанные потоки не меняются): `quota` (по умолчанию) — выбор наименьшего usage при превышении `autoSwitchThreshold`; `round-robin` — равномерное diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index a14bfe139..0c0e2a5e5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | +| `pausedCodexAccountIds?` | `string[]` | `[]` | 在 Codex Auth 中恢复前,不参与任何后续 Pool 选择的账号 ID。暂停主账号时也包含 `__main__`。 | | `codexAccountNamespaces?` | `Record` | — | 可选的公开 model selector namespace 到已保存 Codex account target 的映射。此 foundation layer 只验证并持久化该映射,不会添加 picker row 或改变 routing。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。`quota` 策略和 `fill-first` 的耗尽 threshold 都会用到。 | @@ -82,6 +83,10 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account metadata;access/refresh token 存放在加固的 Codex account credential store 中。已有 thread id 会 保留 account affinity;新 session 按 `accountPoolStrategy`、quota、cooldown 和 health 自动路由。 +暂停后仍会显示账号及其 quota metadata,但不会参与自动切换、重试/failover 选择、cooldown 恢复探测或手动激活。 +暂停还会清除该账号的 thread affinity map:进行中的请求保留已捕获的 credential,但后续 turn 会重新路由,无法再使用已暂停账号。 +暂停状态会跨重启保留;如果所有账号均已暂停,Pool 路由会明确失败,而不会暗中选择某个账号。 +**暂停已达上限账号** 会先刷新有 credential 的合格账号,只暂停相关 quota window 本次明确返回 100% 的账号;无 credential、未知额度或刷新失败的账号保持不变。 **轮换策略**(仅新 session;已绑定 thread 不变):`quota`(默认)— 活跃账号 usage 超过 `autoSwitchThreshold` 时选最低者;`round-robin` — 均分,`accountPoolStickyLimit`(默认 `1`, diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 2a52c310d..c5a212e21 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -29,7 +29,7 @@ const DOCTOR_CMD = "ocx doctor"; * Auth page (WP060). `accountModeState` arrives as a prop (the parent owns the * /api/config fetch); `banner` is an optional slot rendered above the main card * (the Codex Auth page passes its mode banner); `embedded` (WP090) omits page - * chrome — currently a no-op stub reserved for the Providers workspace. + * title chrome while retaining the shared account actions in the Providers workspace. */ export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange, controller: injectedController }: { apiBase: string; @@ -55,7 +55,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban // but stays inert (no load, no polling) whenever a shared controller was injected. const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; - const { accounts, activeId, loadState, switchingId, load } = controller; + const { accounts, activeId, loadState, switchingId, pauseUpdatingId, pausingExhausted, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); @@ -103,7 +103,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const activePoolAccount = activeId && activeId !== "__main__" ? accounts.find(a => a.id === activeId) : null; - const activePoolNeedsReauth = accountNeedsReauth(activePoolAccount); + const activePoolNeedsReauth = !activePoolAccount?.paused && accountNeedsReauth(activePoolAccount); useEffect(() => { onActiveNeedsReauthChange?.(activePoolNeedsReauth); @@ -156,6 +156,20 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setToast(t(result.ok ? "prov.aliasSaved" : "prov.aliasSaveFailed")); }; + const togglePaused = async (account: CodexAccountEntry) => { + const paused = !account.paused; + const result = await controller.setAccountPaused(account.id, paused); + if (!result.ok && result.reason === "busy") return; + setConfirm(current => current?.id === account.id ? null : current); + setToastError(!result.ok); + setToast(t(result.ok + ? paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded" + : paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { + email: account.alias ?? account.email, + })); + setTimeout(() => setToast(""), 5000); + }; + const remove = async (id: string) => { const label = accounts.find(account => account.id === id)?.email ?? t("pws.accountOrdinal", { count: "1" }); if (!window.confirm(t("codexAuth.removeConfirm", { id: label }))) return; @@ -178,6 +192,18 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban } }; + const pauseExhausted = async () => { + const result = await controller.pauseExhaustedAccounts(); + if (!result.ok && result.reason === "busy") return; + setToastError(!result.ok); + setToast(result.ok + ? result.pausedCount > 0 + ? t("codexAuth.pauseExhaustedSucceeded", { count: String(result.pausedCount) }) + : t("codexAuth.pauseExhaustedNone") + : t("codexAuth.pauseExhaustedFailed")); + setTimeout(() => setToast(""), 5000); + }; + const openResetPopup = async (account: CodexAccountEntry) => { setResetPopup(account); setResetConfirm(false); @@ -216,7 +242,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const main = accounts.find(a => a.isMain); const pool = accounts.filter(a => !a.isMain); - const isMainActive = !activeId || activeId === "__main__"; + const isMainActive = !main?.paused && (!activeId || activeId === "__main__"); const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext"); return ( @@ -225,7 +251,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban t={t} embedded={embedded} refreshingQuota={refreshingQuota} + pausingExhausted={pausingExhausted} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onRefresh={() => { void refreshQuotas(); }} + onPauseExhausted={() => { void pauseExhausted(); }} /> {toast && {toast}} @@ -247,6 +276,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} switchActionLabel={switchActionLabel} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onOpenReset={openResetPopup} onCopyDoctor={copyDoctor} doctorCopyOutcomeFor={doctorCopy.outcomeFor} @@ -274,6 +306,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban threshold={autoSwitch.threshold ?? 0} onOpenReset={openResetPopup} onSwitch={setConfirm} + onTogglePause={togglePaused} + pauseUpdatingId={pauseUpdatingId} + pauseBusy={pauseUpdatingId !== null || pausingExhausted} onReauth={openReauth} onEditAlias={editAlias} onRemove={remove} diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx index 478b0e8a2..605f3a458 100644 --- a/gui/src/components/codex-account-pool-cards.tsx +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -1,5 +1,5 @@ import { useT } from "../i18n/shared"; -import { IconAlert, IconX } from "../icons"; +import { IconAlert, IconPause, IconPlay, IconX } from "../icons"; import { displayAccountId } from "../lib/privacy"; import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; @@ -23,6 +23,9 @@ export function CodexAccountPoolCards({ threshold, onOpenReset, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onReauth, onEditAlias, onRemove, @@ -36,6 +39,9 @@ export function CodexAccountPoolCards({ threshold: number; onOpenReset: (account: CodexAccountEntry) => void; onSwitch: (account: CodexAccountEntry) => void; + onTogglePause: (account: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onReauth: (id: string) => void; onEditAlias: (account: CodexAccountEntry) => void; onRemove: (id: string) => void; @@ -43,7 +49,7 @@ export function CodexAccountPoolCards({ doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; }) { const t = useT(); - const isNext = (id: string) => activeId === id; + const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id; return ( <> @@ -54,24 +60,25 @@ export function CodexAccountPoolCards({ const healthLabel = formatOAuthHealthLabel(t, a.health); const healthSummary = formatOAuthHealthSummary(t, "codex", a.id, a.health); return ( -
+
- + {a.alias ?? a.email} {a.plan && {a.plan}} + {a.paused && {t("codexAuth.paused")}} onOpenReset(a)} /> {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !showReauth && !inCooldown && ( + {isNext(a) && !showReauth && !inCooldown && ( {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} )} - {!isNext(a.id) && !showReauth && !inCooldown && ( + {!a.paused && !isNext(a) && !showReauth && !inCooldown && ( @@ -86,6 +93,15 @@ export function CodexAccountPoolCards({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(a.id))} )} + @@ -103,6 +119,7 @@ export function CodexAccountPoolCards({ {healthSummary && (
{healthSummary}
)} + {a.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 7f205a4da..97fd176e5 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { IconLock, IconRefresh } from "../icons"; +import { IconLock, IconPause, IconPlay, IconRefresh } from "../icons"; import QuotaBars from "./QuotaBars"; import { CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; @@ -23,6 +23,9 @@ export function CodexAccountPoolMainCard({ threshold, switchActionLabel, onSwitch, + onTogglePause, + pauseUpdatingId, + pauseBusy, onOpenReset, onCopyDoctor, doctorCopyOutcomeFor, @@ -34,6 +37,9 @@ export function CodexAccountPoolMainCard({ threshold: number; switchActionLabel: string; onSwitch: (entry: CodexAccountEntry) => void; + onTogglePause: (entry: CodexAccountEntry) => void; + pauseUpdatingId: string | null; + pauseBusy: boolean; onOpenReset: (account: CodexAccountEntry) => void; onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; @@ -45,6 +51,7 @@ export function CodexAccountPoolMainCard({ email: main?.email || mainFallbackLabel, plan: main?.plan, isMain: true, + paused: main?.paused ?? false, hasCredential: true, quota: main?.quota ?? null, }; @@ -62,17 +69,20 @@ export function CodexAccountPoolMainCard({ {t("codexAuth.mainAccount")} {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} + {main?.paused && {t("codexAuth.paused")}} {healthLabel && ( {healthLabel} )} {showReauth && !healthLabel && {t("codexAuth.needsReauth")}} - - {isMainActive - ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") - : t("codexAuth.current")} - + {!main?.paused && ( + + {isMainActive + ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") + : t("codexAuth.current")} + + )} - {!isMainActive && !showReauth && !inCooldown && ( + {!main?.paused && !isMainActive && !showReauth && !inCooldown && ( @@ -82,12 +92,24 @@ export function CodexAccountPoolMainCard({ {doctorCopyButtonLabel(t, doctorCopyOutcomeFor?.(mainId))} )} + {main && ( + + )} {t("codexAuth.appLogin")}
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
{healthSummary && (
{healthSummary}
)} + {main?.paused &&
{t("codexAuth.pausedHint")}
} {inCooldown && (
{t("pws.healthCooldownHint")}
)} @@ -102,20 +124,43 @@ export function CodexAccountPoolPageHead({ t, embedded, refreshingQuota, + pausingExhausted, + pauseBusy, onRefresh, + onPauseExhausted, }: { t: TFn; embedded: boolean; refreshingQuota: boolean; + pausingExhausted: boolean; + pauseBusy?: boolean; onRefresh: () => void; + onPauseExhausted: () => void; }) { - if (embedded) return null; return ( -
-

{t("nav.codexAuth")}

- +
+ {!embedded &&

{t("nav.codexAuth")}

} +
+ + +
); } diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index f23b7f73c..3c1d08fd6 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -21,6 +21,8 @@ export interface CodexAccountEntry { plan?: string; /** Required, not optional: the API always distinguishes the app-login row. */ isMain: boolean; + /** Persisted routing exclusion. Paused accounts remain visible but cannot be selected. */ + paused: boolean; hasCredential: boolean; quota: AccountQuota | null; needsReauth?: boolean; @@ -56,10 +58,14 @@ export interface CodexAccountPoolController { activeId: string | null; loadState: CodexAccountLoadState; switchingId: string | null; + pauseUpdatingId: string | null; + pausingExhausted: boolean; activeNeedsReauth: boolean; load(refreshQuota?: boolean): Promise; switchAccount(id: string | null): Promise>; + setAccountPaused(id: string, paused: boolean): Promise; + pauseExhaustedAccounts(): Promise>; saveAlias(id: string, alias: string): Promise; removeAccount(id: string): Promise; syncAfterAccountAdded(): Promise; @@ -78,6 +84,8 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const [activeId, setActiveId] = useState(null); const [loadState, setLoadState] = useState("loading"); const [switchingId, setSwitchingId] = useState(null); + const [pauseUpdatingId, setPauseUpdatingId] = useState(null); + const [pausingExhausted, setPausingExhausted] = useState(false); // Pause leases live in a ref: pausing must not re-render, and the effect below reads // the live set rather than a captured snapshot. const [pauseCount, setPauseCount] = useState(0); @@ -91,6 +99,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou // load already finished read it to seed their UI instead of waiting a poll interval. const lastThresholdRef = useRef<{ value: unknown } | null>(null); const switchingRef = useRef(null); + const pauseMutationRef = useRef<"bulk" | { accountId: string } | null>(null); const subscribeLoadObserver = useCallback((observer: CodexAccountLoadObserver) => { observersRef.current.add(observer); @@ -230,6 +239,73 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou } }, [apiBase, load]); + const setAccountPaused = useCallback(async (id: string, paused: boolean) => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = { accountId: id }; + setPauseUpdatingId(id); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, paused }), + }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const raw = await response.json().catch(() => ({})); + const result = (raw && typeof raw === "object" ? raw : {}) as { activeCodexAccountId?: string | null }; + setAccounts(current => current.map(account => ( + account.id === id || (id === "__main__" && account.isMain) + ? { ...account, paused } + : account + ))); + if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + } + void load(); + return { ok: true } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPauseUpdatingId(null); + } + }, [apiBase, load]); + + const pauseExhaustedAccounts = useCallback(async () => { + if (pauseMutationRef.current) return { ok: false, reason: "busy" } as const; + pauseMutationRef.current = "bulk"; + setPausingExhausted(true); + try { + const response = await fetch(`${apiBase}/api/codex-auth/accounts/pause-exhausted`, { method: "PUT" }); + if (!response.ok) return { ok: false, reason: "request" } as const; + const raw = await response.json().catch(() => ({})); + const result = (raw && typeof raw === "object" ? raw : {}) as { + pausedAccountIds?: string[]; + pausedCount?: number; + activeCodexAccountId?: string | null; + }; + const pausedIds = new Set(result.pausedAccountIds ?? []); + setAccounts(current => current.map(account => ( + pausedIds.has(account.id) || (pausedIds.has("__main__") && account.isMain) + ? { ...account, paused: true } + : account + ))); + if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { + const nextActiveId = result.activeCodexAccountId ?? null; + pendingActiveIdRef.current = { id: nextActiveId }; + setActiveId(nextActiveId); + } + void load(); + return { ok: true, pausedCount: result.pausedCount ?? pausedIds.size } as const; + } catch { + return { ok: false, reason: "request" } as const; + } finally { + pauseMutationRef.current = null; + setPausingExhausted(false); + } + }, [apiBase, load]); + const removeAccount = useCallback(async (id: string) => { try { const response = await fetch( @@ -254,16 +330,21 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou : null; const mainAccount = accounts.find(a => a.isMain); // Include health-only reauth so Providers overview attention matches row CTAs. - const activeNeedsReauth = accountNeedsReauth(activePoolAccount ?? mainAccount); + const activeAccount = activePoolAccount ?? mainAccount; + const activeNeedsReauth = !activeAccount?.paused && accountNeedsReauth(activeAccount); return { accounts, activeId, loadState, switchingId, + pauseUpdatingId, + pausingExhausted, activeNeedsReauth, load, switchAccount, + setAccountPaused, + pauseExhaustedAccounts, saveAlias, removeAccount, syncAfterAccountAdded, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 11167fc3b..5e85333ed 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -646,7 +646,20 @@ export const de: Record = { "codexAuth.refreshingQuota": "Aktualisiere…", "codexAuth.quotaRefreshed": "Kontingente aktualisiert", "codexAuth.quotaRefreshFailed": "Kontingente konnten nicht aktualisiert werden", + "codexAuth.pauseExhausted": "Ausgeschöpfte pausieren", + "codexAuth.pausingExhausted": "Kontingente werden geprüft…", + "codexAuth.pauseExhaustedSucceeded": "Konten am Limit pausiert: {count}", + "codexAuth.pauseExhaustedNone": "Keine Konten mit bestätigter 100-%-Nutzung.", + "codexAuth.pauseExhaustedFailed": "Ausgeschöpfte Konten konnten nicht geprüft und pausiert werden.", "codexAuth.noPool": "Noch keine Pool-Konten hinzugefügt.", + "codexAuth.pause": "Pausieren", + "codexAuth.resume": "Fortsetzen", + "codexAuth.paused": "PAUSIERT", + "codexAuth.pauseSucceeded": "{email} ist pausiert", + "codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar", + "codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.", + "codexAuth.resumeFailed": "{email} konnte nicht fortgesetzt werden. Es wurde nichts geändert.", + "codexAuth.pausedHint": "Bis zur Fortsetzung von automatischem Wechsel, Wiederholungen, Cooldown-Wiederherstellung und manueller Auswahl ausgeschlossen.", "codexAuth.fiveHour": "5 Std.", "codexAuth.weekly": "Woche", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index edb244f48..26aa5f653 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1060,7 +1060,20 @@ export const en = { "codexAuth.refreshingQuota": "Refreshing...", "codexAuth.quotaRefreshed": "Quotas refreshed", "codexAuth.quotaRefreshFailed": "Failed to refresh quotas", + "codexAuth.pauseExhausted": "Pause exhausted", + "codexAuth.pausingExhausted": "Checking quotas...", + "codexAuth.pauseExhaustedSucceeded": "Accounts at the limit paused: {count}", + "codexAuth.pauseExhaustedNone": "No accounts have confirmed 100% usage.", + "codexAuth.pauseExhaustedFailed": "Failed to check and pause exhausted accounts.", "codexAuth.noPool": "No pool accounts added yet.", + "codexAuth.pause": "Pause", + "codexAuth.resume": "Resume", + "codexAuth.paused": "PAUSED", + "codexAuth.pauseSucceeded": "{email} is paused", + "codexAuth.resumeSucceeded": "{email} is available to the pool again", + "codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.", + "codexAuth.resumeFailed": "Could not resume {email}. Nothing was changed.", + "codexAuth.pausedHint": "Excluded from automatic switching, retries, cooldown recovery, and manual selection until resumed.", "codexAuth.fiveHour": "5h", "codexAuth.weekly": "Week", "codexAuth.monthly": "30d", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3a6981701..47b1de3f0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1017,7 +1017,20 @@ export const ja: Record = { "codexAuth.refreshingQuota": "更新中...", "codexAuth.quotaRefreshed": "クォータを更新しました", "codexAuth.quotaRefreshFailed": "クォータの更新に失敗しました", + "codexAuth.pauseExhausted": "上限到達を一括停止", + "codexAuth.pausingExhausted": "クォータを確認中...", + "codexAuth.pauseExhaustedSucceeded": "上限に達したアカウントを停止しました: {count}", + "codexAuth.pauseExhaustedNone": "使用率 100% が確認されたアカウントはありません。", + "codexAuth.pauseExhaustedFailed": "上限到達アカウントの確認と停止に失敗しました。", "codexAuth.noPool": "まだプールアカウントは追加されていません。", + "codexAuth.pause": "一時停止", + "codexAuth.resume": "再開", + "codexAuth.paused": "一時停止中", + "codexAuth.pauseSucceeded": "{email} を一時停止しました", + "codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました", + "codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。", + "codexAuth.resumeFailed": "{email} を再開できませんでした。変更はありません。", + "codexAuth.pausedHint": "再開するまで、自動切り替え、再試行、クールダウン復旧、手動選択の対象外です。", "codexAuth.fiveHour": "5時間", "codexAuth.weekly": "週", "codexAuth.monthly": "30日", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5e206ab31..2372bf500 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -663,7 +663,20 @@ export const ko: Record = { "codexAuth.refreshingQuota": "새로고침 중...", "codexAuth.quotaRefreshed": "할당량을 다시 조회했습니다", "codexAuth.quotaRefreshFailed": "할당량 재조회에 실패했습니다", + "codexAuth.pauseExhausted": "한도 도달 계정 일시 중지", + "codexAuth.pausingExhausted": "할당량 확인 중...", + "codexAuth.pauseExhaustedSucceeded": "한도에 도달해 일시 중지된 계정: {count}", + "codexAuth.pauseExhaustedNone": "사용량 100%가 확인된 계정이 없습니다.", + "codexAuth.pauseExhaustedFailed": "한도 도달 계정을 확인하고 일시 중지하지 못했습니다.", "codexAuth.noPool": "풀 계정이 아직 없습니다.", + "codexAuth.pause": "일시 중지", + "codexAuth.resume": "재개", + "codexAuth.paused": "일시 중지됨", + "codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다", + "codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다", + "codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.resumeFailed": "{email} 계정을 재개하지 못했습니다. 변경 사항이 없습니다.", + "codexAuth.pausedHint": "재개할 때까지 자동 전환, 재시도, 쿨다운 복구 및 수동 선택에서 제외됩니다.", "codexAuth.fiveHour": "5시간", "codexAuth.weekly": "주간", "codexAuth.monthly": "30일", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 97e1d68da..e0ae4bdf4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1059,7 +1059,20 @@ export const ru: Record = { "codexAuth.refreshingQuota": "Обновление...", "codexAuth.quotaRefreshed": "Квоты обновлены", "codexAuth.quotaRefreshFailed": "Не удалось обновить квоты", + "codexAuth.pauseExhausted": "Приостановить исчерпанные", + "codexAuth.pausingExhausted": "Проверка квот...", + "codexAuth.pauseExhaustedSucceeded": "Приостановлено аккаунтов на лимите: {count}", + "codexAuth.pauseExhaustedNone": "Нет аккаунтов с подтверждённым использованием 100%.", + "codexAuth.pauseExhaustedFailed": "Не удалось проверить и приостановить исчерпанные аккаунты.", "codexAuth.noPool": "В пул ещё не добавлено ни одного аккаунта.", + "codexAuth.pause": "Приостановить", + "codexAuth.resume": "Возобновить", + "codexAuth.paused": "ПРИОСТАНОВЛЕН", + "codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен", + "codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле", + "codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.", + "codexAuth.resumeFailed": "Не удалось возобновить {email}. Изменений нет.", + "codexAuth.pausedHint": "До возобновления исключён из автоматического переключения, повторов, восстановления после задержки и ручного выбора.", "codexAuth.fiveHour": "5 ч", "codexAuth.weekly": "Неделя", "codexAuth.monthly": "30 дн.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ee16b561c..3a50a7658 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -663,7 +663,20 @@ export const zh: Record = { "codexAuth.refreshingQuota": "刷新中...", "codexAuth.quotaRefreshed": "额度已刷新", "codexAuth.quotaRefreshFailed": "额度刷新失败", + "codexAuth.pauseExhausted": "暂停已达上限账号", + "codexAuth.pausingExhausted": "正在检查额度...", + "codexAuth.pauseExhaustedSucceeded": "已暂停 {count} 个达到上限的账号", + "codexAuth.pauseExhaustedNone": "没有确认达到 100% 用量的账号。", + "codexAuth.pauseExhaustedFailed": "无法检查并暂停已达上限账号。", "codexAuth.noPool": "尚未添加池账号。", + "codexAuth.pause": "暂停", + "codexAuth.resume": "恢复", + "codexAuth.paused": "已暂停", + "codexAuth.pauseSucceeded": "已暂停 {email}", + "codexAuth.resumeSucceeded": "{email} 已重新加入账号池", + "codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。", + "codexAuth.resumeFailed": "无法恢复 {email},未做任何更改。", + "codexAuth.pausedHint": "恢复前不会参与自动切换、重试、冷却恢复或手动选择。", "codexAuth.fiveHour": "5 小时", "codexAuth.weekly": "每周", "codexAuth.monthly": "30天", diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 563dbc6a9..0e807d197 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -21,6 +21,8 @@ export const IconCheck = (p: P) => ( (); export const IconPlus = (p: P) => (); export const IconRefresh = (p: P) => (); +export const IconPause = (p: P) => (); +export const IconPlay = (p: P) => (); export const IconTrash = (p: P) => (); export const IconAlert = (p: P) => (); export const IconInfo = (p: P) => (); diff --git a/gui/tests/active-account-reauth-aggregate.test.ts b/gui/tests/active-account-reauth-aggregate.test.ts index e606c2347..fee1ba6f0 100644 --- a/gui/tests/active-account-reauth-aggregate.test.ts +++ b/gui/tests/active-account-reauth-aggregate.test.ts @@ -21,7 +21,8 @@ describe("Codex callback aggregate path", () => { test("shared Codex controller derives activeNeedsReauth from health as well", async () => { const hook = await Bun.file(new URL("../src/hooks/useCodexAccountPool.ts", import.meta.url)).text(); - expect(hook).toContain("accountNeedsReauth(activePoolAccount ?? mainAccount)"); + expect(hook).toContain("const activeAccount = activePoolAccount ?? mainAccount"); + expect(hook).toContain("!activeAccount?.paused && accountNeedsReauth(activeAccount)"); expect(hook).not.toMatch(/activePoolAccount\s*\?\s*Boolean\(activePoolAccount\.needsReauth\)/); }); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 84a58bb0c..2b02a4516 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -22,6 +22,10 @@ let calls: string[] = []; let originalFetch: typeof globalThis.fetch; let accounts: unknown[] = []; let threshold = 80; +let nextAccountsResponseGate: Promise | null = null; +let pauseResponseActiveId: string | null = null; +let bulkPausedAccountIds: string[] = ["a2"]; +let bulkResponseActiveId: string | null = null; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -36,13 +40,47 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; - accounts = [{ id: "a1", email: "account-one", isMain: true, hasCredential: true, quota: null }]; + nextAccountsResponseGate = null; + pauseResponseActiveId = null; + bulkPausedAccountIds = ["a2"]; + bulkResponseActiveId = null; + accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }]; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { const path = String(url).split("/api/")[1] ?? String(url); calls.push(`${init?.method ?? "GET"} ${path}`); + if (path === "codex-auth/accounts/pause") { + const body = JSON.parse(String(init?.body)) as { id: string; paused: boolean }; + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (account.id === body.id || (body.id === "__main__" && "isMain" in account && account.isMain === true)) + ? { ...account, paused: body.paused } + : account + )); + return { ok: true, json: async () => ({ activeCodexAccountId: pauseResponseActiveId }) } as unknown as Response; + } + if (path === "codex-auth/accounts/pause-exhausted") { + const pausedIds = new Set(bulkPausedAccountIds); + accounts = accounts.map(account => ( + typeof account === "object" && account !== null && "id" in account + && (pausedIds.has(String(account.id)) || (pausedIds.has("__main__") && "isMain" in account && account.isMain === true)) + ? { ...account, paused: true } + : account + )); + return { + ok: true, + json: async () => ({ + pausedAccountIds: bulkPausedAccountIds, + pausedCount: bulkPausedAccountIds.length, + activeCodexAccountId: bulkResponseActiveId, + }), + } as unknown as Response; + } if (path.startsWith("codex-auth/accounts")) { + const gate = nextAccountsResponseGate; + nextAccountsResponseGate = null; + if (gate) await gate; return { ok: true, json: async () => ({ accounts }) } as unknown as Response; } if (path.startsWith("codex-auth/active")) { @@ -105,6 +143,109 @@ test("an inert controller issues no requests at all", async () => { expect(calls.length).toBe(0); }); +test("pausing an account writes the persisted endpoint and updates shared state", async () => { + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("a1", true)).toEqual({ ok: true }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts[0]?.paused).toBe(true); + expect(seen.current!.activeId).toBeNull(); +}); + +test("pausing the main sentinel updates its distinct account row before reload", async () => { + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(calls).toContain("PUT codex-auth/accounts/pause"); + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); +}); + +test("pausing stores the actual fallback account returned by the API", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + pauseResponseActiveId = "a2"; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.setAccountPaused("__main__", true)).toEqual({ ok: true }); + }); + + expect(seen.current!.activeId).toBe("a2"); +}); + +test("a paused main account does not contribute active reauth state", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: true, hasCredential: true, needsReauth: true, quota: null }, + { id: "a2", email: "next", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + expect(seen.current!.activeId).toBeNull(); + expect(seen.current!.activeNeedsReauth).toBe(false); +}); + +test("bulk pausing writes one endpoint and updates every returned account", async () => { + accounts = [ + { id: "a1", email: "account-one", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "account-two", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + + expect(calls).toContain("PUT codex-auth/accounts/pause-exhausted"); + expect(seen.current!.accounts.find(account => account.id === "a2")?.paused).toBe(true); + expect(seen.current!.pausingExhausted).toBe(false); +}); + +test("bulk pausing translates the main sentinel to its distinct account row", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, hasCredential: true, quota: null }, + { id: "a2", email: "pool", isMain: false, paused: false, hasCredential: true, quota: null }, + ]; + bulkPausedAccountIds = ["__main__"]; + bulkResponseActiveId = "a2"; + const seen = await mountController(); + let releaseReload!: () => void; + nextAccountsResponseGate = new Promise(resolve => { releaseReload = resolve; }); + + await act(async () => { + expect(await seen.current!.pauseExhaustedAccounts()).toEqual({ ok: true, pausedCount: 1 }); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.id).toBe("a1"); + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); + + await act(async () => { + releaseReload(); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + expect(seen.current!.accounts.find(account => account.isMain)?.paused).toBe(true); + expect(seen.current!.activeId).toBe("a2"); +}); + test("two pause holders both have to release before polling resumes", async () => { const seen = await mountController(); const controller = seen.current!; diff --git a/gui/tests/codex-account-pool-controller.test.ts b/gui/tests/codex-account-pool-controller.test.ts index c27637aab..ae2f65722 100644 --- a/gui/tests/codex-account-pool-controller.test.ts +++ b/gui/tests/codex-account-pool-controller.test.ts @@ -14,8 +14,8 @@ test("the controller is the single data owner and exposes the agreed contract", // Data layer (Q6): list / active / loading / switching plus the mutating actions. for (const member of [ - "accounts", "activeId", "loadState", "switchingId", "activeNeedsReauth", - "load", "switchAccount", "saveAlias", "removeAccount", "syncAfterAccountAdded", + "accounts", "activeId", "loadState", "switchingId", "pauseUpdatingId", "pausingExhausted", "activeNeedsReauth", + "load", "switchAccount", "setAccountPaused", "pauseExhaustedAccounts", "saveAlias", "removeAccount", "syncAfterAccountAdded", ]) { expect(hook).toContain(member); } @@ -31,6 +31,28 @@ test("the controller is the single data owner and exposes the agreed contract", expect(hook).not.toContain("load(refreshQuota?: boolean, observer"); }); +test("main and added account cards expose the same persisted pause control", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const addedCards = await read("../src/components/codex-account-pool-cards.tsx"); + + expect(pool).toContain("controller.setAccountPaused(account.id, paused)"); + expect(mainCard).toContain("onTogglePause(mainSwitchEntry)"); + expect(addedCards).toContain("onTogglePause(a)"); + expect(mainCard).toContain('t(main.paused ? "codexAuth.resume" : "codexAuth.pause")'); + expect(addedCards).toContain('t(a.paused ? "codexAuth.resume" : "codexAuth.pause")'); +}); + +test("the pool header exposes one bulk action backed by the atomic endpoint", async () => { + const pool = await read("../src/components/CodexAccountPool.tsx"); + const mainCard = await read("../src/components/codex-account-pool-main-card.tsx"); + const hook = await read("../src/hooks/useCodexAccountPool.ts"); + + expect(pool).toContain("controller.pauseExhaustedAccounts()"); + expect(mainCard).toContain('t("codexAuth.pauseExhausted")'); + expect(hook).toContain("/api/codex-auth/accounts/pause-exhausted"); +}); + test("pause is a token lease, so two holders cannot cancel each other", async () => { const hook = await read("../src/hooks/useCodexAccountPool.ts"); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index af2db2f8f..45ec9fc08 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -22,6 +22,7 @@ const account: CodexAccountEntry = { id: "pool-1", email: "pool@example.test", isMain: false, + paused: false, hasCredential: true, quota: { resetCredits: 2, updatedAt: 1 }, }; @@ -29,15 +30,19 @@ const account: CodexAccountEntry = { function makeController(overrides: Partial = {}): CodexAccountPoolController { return { accounts: [ - { id: "main", email: "main@example.test", isMain: true, hasCredential: true, quota: null }, + { id: "main", email: "main@example.test", isMain: true, paused: false, hasCredential: true, quota: null }, account, ], activeId: null, loadState: "ready", switchingId: null, + pauseUpdatingId: null, + pausingExhausted: false, activeNeedsReauth: false, load: async () => true, switchAccount: async () => ({ ok: true, activeId: null }), + setAccountPaused: async () => ({ ok: true }), + pauseExhaustedAccounts: async () => ({ ok: true, pausedCount: 0 }), saveAlias: async () => ({ ok: true }), removeAccount: async () => ({ ok: false, reason: "request" }), syncAfterAccountAdded: async () => ({ ok: true }), diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index b6078bf05..35831af55 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -6,6 +6,7 @@ import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountInfoCache } from "./main-account-cache"; +import { forgetCodexAccountPause } from "./account-pause"; import type { OcxConfig } from "../types"; let observedMainChatgptAccountId: string | undefined; @@ -47,6 +48,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) .filter(account => account.isMain || account.id !== accountId); + forgetCodexAccountPause(runtimeConfig, accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-pause.ts b/src/codex/account-pause.ts new file mode 100644 index 000000000..8e73a1bcf --- /dev/null +++ b/src/codex/account-pause.ts @@ -0,0 +1,20 @@ +import type { OcxConfig } from "../types"; + +/** Whether an account is administratively excluded from future pool selection. */ +export function isCodexAccountPaused(config: OcxConfig, accountId: string): boolean { + return config.pausedCodexAccountIds?.includes(accountId) ?? false; +} + +/** Persist the account's pool eligibility without changing credentials or runtime health. */ +export function setCodexAccountPaused(config: OcxConfig, accountId: string, paused: boolean): void { + const pausedIds = new Set(config.pausedCodexAccountIds ?? []); + if (paused) pausedIds.add(accountId); + else pausedIds.delete(accountId); + + if (pausedIds.size > 0) config.pausedCodexAccountIds = [...pausedIds]; + else delete config.pausedCodexAccountIds; +} + +export function forgetCodexAccountPause(config: OcxConfig, accountId: string): void { + setCodexAccountPaused(config, accountId, false); +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 540f24f55..f2cea1bde 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -3,6 +3,7 @@ import { withCodexAccountLogLabel } from "./account-label"; import { getCodexAccountCredential, getValidCodexToken, + isCodexAccountGenerationLive, markCodexAccountValidated, saveCodexAccountCredential, CodexCredentialGenerationConflictError, @@ -10,13 +11,20 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; +import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; +import { + clearCodexAccountCooldown, + clearThreadAccountMapForAccount, + getEffectiveActiveCodexAccountId, + reconcileCodexActiveAfterExclusion, + resetCodexRoutingForManualSelection, +} from "./routing"; import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, } from "./pool-rotation"; -import { clearCodexAccountCooldown, getEffectiveActiveCodexAccountId, resetCodexRoutingForManualSelection } from "./routing"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -24,6 +32,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { clearAccountQuota, getAccountQuota, + isCodexQuotaExhausted, listAccountQuotas, parseUsageQuota, setAccountQuotaFromParsed, @@ -40,7 +49,7 @@ export { updateAccountQuota, } from "./quota"; import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt"; -import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; +import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearMainAccountInfoCache, getMainAccountInfoCache, @@ -126,6 +135,7 @@ function poolAccountDto( account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, + paused: boolean, ): CodexAuthAccountDto { const quota = quotaForPlan(quotaResult.quota, account.plan); const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); @@ -137,6 +147,7 @@ function poolAccountDto( ...(account.plan !== undefined ? { plan: account.plan } : {}), ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, + paused, quota: quota ? { ...quota } : null, needsReauth, hasCredential, @@ -242,6 +253,10 @@ const MAIN_CACHE_TTL = 5 * 60_000; const POOL_CACHE_TTL = 5 * 60_000; const POOL_QUOTA_REFRESH_CONCURRENCY = 4; +function nonEmptyPlan(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + function isRuntimeConfig(config: OcxConfig): boolean { return !!config && typeof config === "object" && !!config.providers; } @@ -305,6 +320,8 @@ async function isTerminalMainAuthResponse(resp: Response): Promise { interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -365,11 +382,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini const data = (await resp.json()) as WhamUsageResponse; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining); if (retried) return retried; - const quota = parseUsageQuota(data); + const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); + const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) }); const freshResetCredits = quota?.resetCredits; const result = { email: data.email ?? null, - plan: data.plan_type ?? null, + plan, quota, ts: Date.now(), }; @@ -383,6 +401,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini } return { info: result, + ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch { @@ -394,6 +413,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini interface PoolQuotaResult { quota: StoredAccountQuota | null; needsReauth: boolean; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; + /** Present only when this call's WHAM response included a non-empty `plan_type`. */ + freshPlan?: string; + /** Credential generation used by this fresh quota request. */ + freshCredentialGeneration?: number; /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ freshResetCredits?: number; } @@ -405,6 +430,7 @@ export interface CodexAuthAccountDto { plan?: string | null; logLabel?: string; isMain: boolean; + paused: boolean; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; needsReauth?: boolean; hasCredential: boolean; @@ -420,20 +446,27 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co return { quota: existing, needsReauth: false }; } try { - const { accessToken, chatgptAccountId } = await getValidCodexToken(accountId); + const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId); const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 }; const data = (await resp.json()) as WhamUsageResponse; - const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan }); + const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; + const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; if (!quota) return { quota: existing ?? null, needsReauth: false }; + if (!isCodexAccountGenerationLive(accountId, generation)) { + return { quota: getAccountQuota(accountId), needsReauth: false }; + } setAccountQuotaFromParsed(accountId, quota); return { quota: getAccountQuota(accountId), needsReauth: false, + freshQuota: quota, + freshCredentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; } catch (e) { @@ -514,7 +547,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa const quotaResult = cred ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan) : { quota: null, needsReauth: true }; - return poolAccountDto(a, quotaResult, !!cred); + return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id)); }); const hasMainCredential = readCodexTokens() !== null; const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); @@ -527,6 +560,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, isMain: true, + paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null, @@ -535,6 +569,69 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } +interface PauseExhaustedResult { + pausedAccountIds: string[]; + checkedAccountCount: number; + failedAccountCount: number; +} + +function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void { + reconcileCodexActiveAfterExclusion(config, pausedActiveId); +} + +async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise { + const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain); + const mainAttempted = readCodexTokens() !== null; + const [mainResult, poolResults] = await Promise.all([ + fetchMainAccountInfoAttempt(true, 1), + mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { + if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null }; + return { + account, + quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan), + }; + }), + ]); + + let checkedAccountCount = 0; + let failedAccountCount = 0; + const exhaustedIds: string[] = []; + if (mainAttempted) { + if (mainResult.freshQuota && mainResult.info.plan) { + checkedAccountCount += 1; + if ( + !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan) + ) { + exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID); + } + } else { + failedAccountCount += 1; + } + } + for (const { account, quotaResult } of poolResults) { + const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain); + if (!currentAccount) continue; + const generation = quotaResult?.freshCredentialGeneration; + const plan = quotaResult?.freshPlan ?? currentAccount.plan; + if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) { + failedAccountCount += 1; + continue; + } + checkedAccountCount += 1; + if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) { + exhaustedIds.push(account.id); + } + } + + for (const id of exhaustedIds) { + setCodexAccountPaused(config, id, true); + clearThreadAccountMapForAccount(id); + } + for (const id of exhaustedIds) selectFallbackAfterPause(config, id); + return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount }; +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -624,6 +721,59 @@ export async function handleCodexAuthAPI( return jsonResponse({ ok: true, id, alias: alias || null }); } + if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") { + const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400); + + const runtimeConfig = getRuntimeConfig(config); + const exists = id === MAIN_CODEX_ACCOUNT_ID + || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); + if (!exists) return jsonResponse({ error: "Account not found" }, 404); + + setCodexAccountPaused(runtimeConfig, id, body.paused); + if (body.paused) { + clearThreadAccountMapForAccount(id); + selectFallbackAfterPause(runtimeConfig, id); + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused: body.paused, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + + if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { + const runtimeConfig = getRuntimeConfig(config); + const result = await pauseExhaustedCodexAccounts(runtimeConfig); + const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result; + if (checkedAccountCount === 0 && failedAccountCount > 0) { + return jsonResponse({ + ok: false, + error: "Failed to refresh any Codex account quota", + checkedAccountCount, + failedAccountCount, + }, 502); + } + if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + pausedAccountIds, + pausedCount: pausedAccountIds.length, + checkedAccountCount, + failedAccountCount, + complete: failedAccountCount === 0, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only // model path for Codex Desktop, so a cooldown that outlives the real upstream limit // otherwise leaves editing config.toml as the user's only recovery. @@ -645,9 +795,13 @@ export async function handleCodexAuthAPI( let body: { accountId: string | null }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } const runtimeConfig = getRuntimeConfig(config); + const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); } + if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { + return jsonResponse({ error: "Account is paused" }, 409); + } if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); const exists = (runtimeConfig.codexAccounts ?? []) @@ -655,7 +809,7 @@ export async function handleCodexAuthAPI( if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - resetCodexRoutingForManualSelection(body.accountId ?? MAIN_CODEX_ACCOUNT_ID); + resetCodexRoutingForManualSelection(targetAccountId); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); } diff --git a/src/codex/quota.ts b/src/codex/quota.ts index f500b3641..5fc147116 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -33,6 +33,21 @@ const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; const accountQuota = new Map(); export const CODEX_UNKNOWN_USAGE_SCORE = 100; +export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; + +export function isCodexQuotaExhausted( + quota: Pick | null, + plan?: string | null, +): boolean { + if (!quota) return false; + const normalizedPlan = plan?.trim().toLowerCase(); + const values = normalizedPlan === "go" || normalizedPlan === "free" + ? [quota.monthlyPercent] + : [quota.weeklyPercent, quota.monthlyPercent]; + return values.some(value => typeof value === "number" + && Number.isFinite(value) + && value >= CODEX_EXHAUSTED_USAGE_PERCENT); +} export function normalizeUsagePercent(value: unknown): number | undefined { const numeric = typeof value === "number" diff --git a/src/codex/routing.ts b/src/codex/routing.ts index b359d07d1..bbc13f844 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; +import { isCodexAccountPaused } from "./account-pause"; import { isCodexAccountUsable } from "./account-usability"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { @@ -589,7 +590,8 @@ function isCodexAccountSelectable( now: number, quotaScope?: CodexQuotaScope, ): boolean { - return getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null + return !isCodexAccountPaused(config, accountId) + && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null && !isCodexAccountSoftAvoided(accountId, now) && isCodexAccountUsable(config, accountId); } @@ -695,6 +697,7 @@ function getEligiblePoolAccounts( const ids = (config.codexAccounts ?? []) .filter(account => isSelectableCodexPoolAccount(account) && account.id !== excludeId + && !isCodexAccountPaused(config, account.id) && !isAccountNeedsReauth(account.id)) .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null) .filter(account => !isCodexAccountSoftAvoided(account.id, now)) @@ -704,6 +707,7 @@ function getEligiblePoolAccounts( // first-class rotation candidate when its read-only token is usable (Option A). if ( excludeId !== MAIN_CODEX_ACCOUNT_ID + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) @@ -948,6 +952,29 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { rememberActiveCodexAccount(config, accountId); } +/** + * Reconcile the effective active account after an administrative exclusion such as pause. + * The operator's persisted selection is cleared when it names the excluded account; quota + * keeps its historical persisted promotion, while rotating strategies retain the replacement + * only in the process-local cursor. + */ +export function reconcileCodexActiveAfterExclusion( + config: OcxConfig, + excludedAccountId: string, + now = Date.now(), +): string | null { + const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId; + if (config.activeCodexAccountId === excludedAccountId) { + config.activeCodexAccountId = undefined; + } + if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null; + + runtimeActiveCodexAccountId = undefined; + const fallback = pickAlternateCodexAccount(config, excludedAccountId, now); + if (fallback) promoteActiveCodexAccount(config, fallback); + return fallback; +} + function isUnknownUsage(usage: number): boolean { return usage >= CODEX_UNKNOWN_USAGE_SCORE; } @@ -1056,7 +1083,7 @@ export function previewCodexAccountForRequest( if (!isCodexAccountSelectable(config, active, now, quotaScope)) { const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope); if (fallback) active = fallback; - else if (hasConfiguredPoolAccount(config, active)) return active; + else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active; else return null; } @@ -1074,6 +1101,7 @@ export function previewCodexAccountForRequest( if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? active : null; } + if (isCodexAccountPaused(config, active)) return null; if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active) ? active : null; } @@ -1150,7 +1178,7 @@ export function resolveCodexAccountForThreadDetailed( if (fallback) { if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback); active = fallback; - } else if (hasConfiguredPoolAccount(config, active)) { + } else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) { return { status: "selected", accountId: active }; } else { return { status: "none" }; @@ -1161,6 +1189,7 @@ export function resolveCodexAccountForThreadDetailed( if (!isCodexAccountUsable(config, active)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } + if (isCodexAccountPaused(config, active)) return { status: "none" }; if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) { return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" }; } diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index ec07d51db..6e936ec22 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -20,6 +20,7 @@ import { isCodexAccountInCooldown, } from "./routing"; import { isCodexAccountUsable } from "./account-usability"; +import { isCodexAccountPaused } from "./account-pause"; import { slugEquals } from "../providers/slug-codec"; import { isThreadSpawnRequest } from "../server/effort-policy"; import { PROVIDER_REGISTRY } from "../providers/registry"; @@ -187,6 +188,7 @@ export function isSubagentModelUnavailable( // route (canonical openai defaults to pool even when codexAccountMode is omitted). const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId); if (!resolvedAccountId) return true; + if (isCodexAccountPaused(config, resolvedAccountId)) return true; if (!isCodexAccountUsable(config, resolvedAccountId)) return true; if ( isCodexAccountInCooldown(resolvedAccountId, now) diff --git a/src/config.ts b/src/config.ts index c3ca1a4c2..a30ee879b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -594,6 +594,7 @@ const configSchema = z.object({ injectionEffort: z.string().optional().catch(undefined), syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), + pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), diff --git a/src/types.ts b/src/types.ts index 98612468f..12f5f1722 100644 --- a/src/types.ts +++ b/src/types.ts @@ -680,6 +680,8 @@ export interface OcxConfig { search?: OcxSearchConfig; /** Codex multi-account pool. */ codexAccounts?: CodexAccount[]; + /** Account ids administratively excluded from future pool selection until resumed. */ + pausedCodexAccountIds?: string[]; /** * Public model-selector namespaces bound to one Codex account. Values are stored account ids; * `"@main"` selects the Codex Desktop/main auth.json account. Account display aliases diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index a0e403eb5..8a0484d6c 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -23,6 +23,13 @@ An explicit `Retry-After` or an unclassified quota 429 is account-wide. A reset- the shared native group (including GPT-5.6 Terra/Luna). This allows a same-account combo to test an independent quota without allowing fallbacks that share the exhausted quota. +`pausedCodexAccountIds` is a persisted Pool eligibility boundary. A paused added account or the +stable `__main__` alias remains visible for maintenance and quota reads, but is excluded from new +affinity, quota rotation, cooldown probes, transient failover, and manual activation. In-flight +requests keep their captured credential. An all-paused pool fails closed. +The dashboard's bulk pause action refreshes all account quotas and mutates only accounts whose +plan-relevant window is freshly confirmed at exactly 100%; unknown and failed refreshes are skipped. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option openai-apikey/gpt-5.6-sol # OpenAI API key diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 12d38b36d..0b7fa9654 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -7,7 +7,7 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - maskEmail, + clearMainAccountInfoCache, maskEmail, } from "../src/codex/auth-api"; import { getCodexAccountCredential, @@ -16,10 +16,14 @@ import { saveCodexAccountCredential, } from "../src/codex/account-store"; import { + clearCodexUpstreamHealth, + clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../src/codex/routing"; +import { clearPoolRotationState } from "../src/codex/pool-rotation"; import { clearCodexWebSocketRegistry, getTrackedCodexWebSocketCountForAccount, @@ -27,7 +31,7 @@ import { } from "../src/codex/websocket-registry"; import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; -import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../src/codex/main-account"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState, @@ -201,6 +205,11 @@ beforeEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); resetMainCodexAccountIdentityTrackingForTests(); }); @@ -209,6 +218,11 @@ afterEach(() => { clearAccountNeedsReauth("__main__"); clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); clearCodexWebSocketRegistry(); globalThis.fetch = previousFetch; if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; @@ -1268,6 +1282,367 @@ describe("codex-auth API", () => { expect(config.activeCodexAccountId).toBe("pool-next"); }); + test("PUT /api/codex-auth/accounts/pause persists exclusion and applies to the next request", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + }); + seedPoolAccount(config, { id: "pool-extra", email: "extra@example.test" }); + saveCodexAccountCredential("pool-active", { + accessToken: "access-active", + refreshToken: "refresh-active", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-active", + }); + saveCodexAccountCredential("pool-next", { + accessToken: "access-next", + refreshToken: "refresh-next", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-next", + }); + updateAccountQuota("pool-active", 5); + updateAccountQuota("pool-next", 10); + updateAccountQuota("pool-extra", 20); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ id: "pool-active", paused: true, activeCodexAccountId: "pool-next" }); + expect(config.pausedCodexAccountIds).toEqual(["pool-active"]); + expect(config.activeCodexAccountId).toBe("pool-next"); + expect(resolveCodexAccountForThread("pause-thread", config)).toBe("pool-next"); + }); + + test("pausing the runtime-active round-robin account promotes an eligible replacement", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-active", email: "active@example.test", isMain: false }, + { id: "pool-next", email: "next@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-active", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-active", "pool-next"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-active"); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-active"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-active", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-active", + paused: true, + activeCodexAccountId: "pool-next", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("round-robin-pause", config)).toBe("pool-next"); + }); + + test("pausing a persisted non-active round-robin selection preserves the runtime account", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-persisted", email: "persisted@example.test", isMain: false }, + { id: "pool-runtime", email: "runtime@example.test", isMain: false }, + ], + activeCodexAccountId: "pool-persisted", + accountPoolStrategy: "round-robin", + accountPoolStickyLimit: 2, + }); + for (const id of ["pool-persisted", "pool-runtime"]) { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); + } + resetCodexRoutingForManualSelection("pool-runtime"); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + + const req = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "pool-persisted", paused: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + id: "pool-persisted", + paused: true, + activeCodexAccountId: "pool-runtime", + }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(resolveCodexAccountForThread("runtime-selection", config)).toBe("pool-runtime"); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted pauses only freshly confirmed exhausted accounts", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "exhausted", email: "exhausted@example.test", plan: "plus", isMain: false }, + { id: "available", email: "available@example.test", plan: "plus", isMain: false }, + { id: "free-weekly-only", email: "free@example.test", plan: "free", isMain: false }, + { id: "upgraded-plus", email: "upgraded@example.test", plan: "free", isMain: false }, + { id: "stale-unknown", email: "stale@example.test", plan: "plus", isMain: false }, + { id: "already-paused", email: "paused@example.test", plan: "plus", isMain: false }, + ], + activeCodexAccountId: "exhausted", + pausedCodexAccountIds: ["already-paused"], + }); + for (const account of config.codexAccounts ?? []) { + saveCodexAccountCredential(account.id, { + accessToken: `access-${account.id}`, + refreshToken: `refresh-${account.id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: account.id, + }); + } + updateAccountQuota("stale-unknown", 100); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("exhausted"); + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const accountId = new Headers(init?.headers).get("ChatGPT-Account-Id"); + if (accountId === "stale-unknown") return new Response(null, { status: 502 }); + const weekly = accountId === "exhausted" || accountId === "already-paused" || accountId === "free-weekly-only" || accountId === "upgraded-plus" + ? 100 + : 72; + const monthly = accountId === "free-weekly-only" || accountId === "upgraded-plus" ? 20 : 40; + return Response.json({ + plan_type: accountId === "free-weekly-only" ? "free" : "plus", + rate_limit: { + primary_window: { used_percent: weekly, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: monthly, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: ["exhausted", "upgraded-plus"], + pausedCount: 2, + activeCodexAccountId: "free-weekly-only", + checkedAccountCount: 5, + failedAccountCount: 1, + complete: false, + appliesImmediately: true, + }); + expect(config.pausedCodexAccountIds).toEqual(["already-paused", "exhausted", "upgraded-plus"]); + expect(config.activeCodexAccountId).toBe("free-weekly-only"); + expect(resolveCodexAccountForThread("bulk-pause-thread", config)).toBe("free-weekly-only"); + }); + + test("bulk pause preserves a known Free main plan when WHAM omits plan_type", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + let call = 0; + globalThis.fetch = (async () => { + call += 1; + return Response.json({ + email: "main@example.test", + ...(call === 1 ? { plan_type: "free" } : {}), + rate_limit: { + primary_window: { used_percent: call === 1 ? 10 : 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + }); + }) as typeof fetch; + const config = makeConfig(); + + const prime = new Request("http://localhost/api/codex-auth/accounts?refresh=1"); + expect((await handleCodexAuthAPI(prime, new URL(prime.url), config))!.status).toBe(200); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [], + pausedCount: 0, + checkedAccountCount: 1, + failedAccountCount: 0, + }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause fails closed when the main quota plan cannot be established", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + rate_limit: { + primary_window: { used_percent: 100, limit_window_seconds: 7 * 24 * 60 * 60 }, + tertiary_window: { used_percent: 20, limit_window_seconds: 30 * 24 * 60 * 60 }, + }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause reports an error when every quota refresh fails", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "offline", email: "offline@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("offline", { + accessToken: "offline-access", + refreshToken: "offline-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "offline", + }); + globalThis.fetch = (async () => new Response(null, { status: 502 })) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(502); + expect(await resp!.json()).toMatchObject({ ok: false, checkedAccountCount: 0, failedAccountCount: 1 }); + expect(config.pausedCodexAccountIds).toBeUndefined(); + }); + + test("bulk pause discards an exhausted result after the account is deleted and recreated", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "reused", email: "old@example.test", plan: "plus", isMain: false }], + }); + saveCodexAccountCredential("reused", { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "old-account", + }); + let releaseFetch!: () => void; + let markFetchStarted!: () => void; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + const fetchGate = new Promise(resolve => { releaseFetch = resolve; }); + globalThis.fetch = (async () => { + markFetchStarted(); + await fetchGate; + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + }); + }) as typeof fetch; + + const bulkReq = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const bulkPromise = handleCodexAuthAPI(bulkReq, new URL(bulkReq.url), config); + await fetchStarted; + const deleteReq = new Request("http://localhost/api/codex-auth/accounts?id=reused", { method: "DELETE" }); + expect((await handleCodexAuthAPI(deleteReq, new URL(deleteReq.url), config))!.status).toBe(200); + config.codexAccounts = [{ id: "reused", email: "new@example.test", plan: "plus", isMain: false }]; + saveCodexAccountCredential("reused", { + accessToken: "new-access", + refreshToken: "new-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "new-account", + }); + releaseFetch(); + const resp = await bulkPromise; + + expect(resp!.status).toBe(502); + expect(config.pausedCodexAccountIds).toBeUndefined(); + expect(getAccountQuota("reused")).toBeNull(); + }); + + test("PUT /api/codex-auth/accounts/pause-exhausted includes a freshly exhausted main account", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-account" }, + })); + globalThis.fetch = (async () => Response.json({ + email: "main@example.test", + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 100 } }, + })) as typeof fetch; + const config = makeConfig(); + const req = new Request("http://localhost/api/codex-auth/accounts/pause-exhausted", { method: "PUT" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(await resp!.json()).toMatchObject({ + pausedAccountIds: [MAIN_CODEX_ACCOUNT_ID], + pausedCount: 1, + }); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("PUT /api/codex-auth/active rejects null when the effective main account is paused", async () => { + const config = makeConfig({ pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID] }); + const req = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(409); + expect(await resp!.json()).toEqual({ error: "Account is paused" }); + expect(config.activeCodexAccountId).toBeUndefined(); + }); + + test("resuming restores eligibility and manual activation rejects paused accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: ["work", MAIN_CODEX_ACCOUNT_ID], + }); + const activateReq = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: "work" }), + }); + const activateResp = await handleCodexAuthAPI(activateReq, new URL(activateReq.url), config); + expect(activateResp!.status).toBe(409); + expect(config.activeCodexAccountId).toBeUndefined(); + + const resumeReq = new Request("http://localhost/api/codex-auth/accounts/pause", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "work", paused: false }), + }); + const resumeResp = await handleCodexAuthAPI(resumeReq, new URL(resumeReq.url), config); + + expect(resumeResp!.status).toBe(200); + expect(config.pausedCodexAccountIds).toEqual([MAIN_CODEX_ACCOUNT_ID]); + }); + + test("account list exposes persisted pause state for main and added accounts", async () => { + const config = makeConfig({ + codexAccounts: [{ id: "work", email: "work@example.test", isMain: false }], + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID, "work"], + }); + const req = new Request("http://localhost/api/codex-auth/accounts"); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const data = await resp!.json() as { accounts: Array<{ id: string; paused: boolean }> }; + + expect(data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)?.paused).toBe(true); + expect(data.accounts.find(account => account.id === "work")?.paused).toBe(true); + }); + test("PUT /api/codex-auth/accounts/alias changes display metadata only", async () => { const config = makeConfig({ codexAccounts: [{ id: "work", email: "work@example.test", plan: "plus", isMain: false }], @@ -1324,6 +1699,7 @@ describe("codex-auth API", () => { recordCodexUpstreamOutcome(config, "pool-delete", 500); expect(getCodexUpstreamHealth("pool-delete")).not.toBeNull(); markAccountNeedsReauth("pool-delete"); + config.pausedCodexAccountIds = ["pool-delete"]; const closed: { code?: number; reason?: string }[] = []; let cancelled = false; const ws = { @@ -1352,6 +1728,7 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.pausedCodexAccountIds).toBeUndefined(); expect(getCodexAccountCredential("pool-delete")).toBeNull(); expect(getAccountQuota("pool-delete")).toBeNull(); expect(isAccountNeedsReauth("pool-delete")).toBe(false); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 98c6dcfcc..16c9a921d 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -167,6 +167,37 @@ describe("Codex auth context", () => { )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); }); + test("pause excludes new auth selection without invalidating an in-flight context", async () => { + const cfg = config(); + cfg.codexAccounts?.push({ + id: "pool-b", + email: "pool-b@example.test", + isMain: false, + chatgptAccountId: "pool_b_acc", + }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + + const captured = await resolveCodexAuthContext(new Headers(), cfg, "pool"); + expect(captured).toMatchObject({ kind: "pool", accountId: "pool-a" }); + + cfg.pausedCodexAccountIds = ["pool-a"]; + + expect(isCodexAuthContextUsable(captured, cfg)).toBe(true); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "pool-b" }); + }); + test("selected pool headers replace inbound main auth", () => { const headers = headersForCodexAuthContext( new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }), diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 8e11154ee..351e98e84 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -39,7 +39,7 @@ import { parseUsageQuota, updateAccountQuota, } from "../src/codex/auth-api"; -import { CODEX_UNKNOWN_USAGE_SCORE } from "../src/codex/quota"; +import { CODEX_UNKNOWN_USAGE_SCORE, isCodexQuotaExhausted } from "../src/codex/quota"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; import { routeModel } from "../src/router"; import { consumeForInspection } from "../src/server/relay"; @@ -129,6 +129,16 @@ describe("codex routing", () => { expect(computeCodexUsageScore({})).toBe(CODEX_UNKNOWN_USAGE_SCORE); }); + test("bulk pause exhaustion requires an explicit 100% relevant window", () => { + expect(isCodexQuotaExhausted(null, "plus")).toBe(false); + expect(isCodexQuotaExhausted({}, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 99.9 }, "plus")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ monthlyPercent: 100 }, "plus")).toBe(true); + expect(isCodexQuotaExhausted({ weeklyPercent: 100, monthlyPercent: 20 }, "free")).toBe(false); + expect(isCodexQuotaExhausted({ weeklyPercent: 20, monthlyPercent: 100 }, "go")).toBe(true); + }); + test("weekly threshold breach switches new threads", () => { const config = makeConfig(); updateAccountQuota("a", 85); @@ -162,6 +172,21 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("after-success", config)).toBe("a"); }); + test("paused main account is excluded even when it is the active and lowest-usage candidate", () => { + writeFileSync(join(TEST_DIR, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-access", account_id: "main-chatgpt-id" }, + })); + const config = makeConfig({ + codexAccounts: [{ id: "a", email: "a@test", isMain: false }], + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + pausedCodexAccountIds: [MAIN_CODEX_ACCOUNT_ID], + }); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 1); + updateAccountQuota("a", 20); + + expect(resolveCodexAccountForThread("paused-main", config)).toBe("a"); + }); + test("go plan pool switching ignores the weekly window", () => { const config = makeConfig({ codexAccounts: [ @@ -194,6 +219,25 @@ describe("codex routing", () => { expect(pickLowestUsageCodexAccount(config)).toBe("b"); }); + test("paused accounts are excluded from new selection and existing affinity reuse", () => { + const config = makeConfig(); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("a"); + + config.pausedCodexAccountIds = ["a"]; + + expect(pickLowestUsageCodexAccount(config)).toBe("b"); + expect(resolveCodexAccountForThread("paused-affinity", config)).toBe("b"); + }); + + test("all paused accounts fail closed instead of falling back to a configured account", () => { + const config = makeConfig({ pausedCodexAccountIds: ["a", "b"] }); + + expect(pickLowestUsageCodexAccount(config)).toBeNull(); + expect(resolveCodexAccountForThread("all-paused", config)).toBeNull(); + }); + test("upstream outcome classifier separates caller, credential, and transient failures", () => { expect(classifyCodexUpstreamOutcome(200)).toBe("success"); expect(classifyCodexUpstreamOutcome(401)).toBe("credential"); @@ -357,6 +401,26 @@ describe("codex routing", () => { expect(resolveCodexAccountForThread("scoped-thread", config, now + 4, "spark")).toBe("b"); }); + test("429 fallback skips paused candidates", () => { + const config = makeConfig({ + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + pausedCodexAccountIds: ["b"], + }); + saveTestCredential("c"); + updateAccountQuota("a", 10); + updateAccountQuota("b", 1); + updateAccountQuota("c", 30); + + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60" }); + + expect(config.activeCodexAccountId).toBe("c"); + expect(resolveCodexAccountForThread("quota-skip-paused", config)).toBe("c"); + }); + test("2xx responses clear transient failures without clearing an unexpired cooldown", () => { const config = makeConfig(); const now = 1_800_000_000_000; diff --git a/tests/config.test.ts b/tests/config.test.ts index 9873b2bf5..d60a1c519 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -251,7 +251,6 @@ describe("opencodex config defaults", () => { }, defaultProvider: "openai", }; - writeConfig({ ...base, injectionModel: "gpt-5.6-terra", @@ -331,6 +330,29 @@ describe("opencodex config defaults", () => { expect(loadConfig().syncCodexSubagentDefaults).toBeUndefined(); }); + test("paused Codex account ids persist and reject malformed values", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + writeConfig({ ...base, pausedCodexAccountIds: ["__main__", "pool-a"] }); + expect(loadConfig().pausedCodexAccountIds).toEqual(["__main__", "pool-a"]); + + for (const invalid of ["pool-a", ["bad/account"], [1]]) { + writeConfig({ ...base, pausedCodexAccountIds: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("pausedCodexAccountIds"); + } + }); + test("loads valid config from OPENCODEX_HOME", () => { writeConfig({ port: 12345, diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index 5d203d3f1..ed69de67b 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -194,7 +194,9 @@ describe("workspace account integration seam", () => { // Health-only reauth_required must reach both aggregate surfaces. expect(pool).toContain("onActiveNeedsReauthChange?.(activePoolNeedsReauth)"); - expect(hook).toContain("accountNeedsReauth(activePoolAccount ?? mainAccount)"); + expect(hook).toContain("accountNeedsReauth(activeAccount)"); + expect(hook).toContain("!activeAccount?.paused &&"); + expect(hook).toContain("activePoolAccount ?? mainAccount"); expect(page).toContain("accountNeedsReauth(active)"); // WP3: background refresh pauses through a token lease, not a boolean read of the // modal flag. Two holders must both release before polling resumes. diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index bfe9eaa76..9d66dc1f6 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -263,7 +263,7 @@ describe("rate-limit reset credits", () => { expect(source).toContain(" onOpenReset(a)} />"); // Next-session still renders BESIDE the ticket; health projection also suppresses // it for projected reauth/cooldown (not only the legacy needsReauth flag). - expect(source).toContain("{isNext(a.id) && !showReauth && !inCooldown && ("); + expect(source).toContain("{isNext(a) && !showReauth && !inCooldown && ("); expect(source).toContain("{t(accountModeState === \"direct\" ? \"codexAuth.poolPrepared\" : \"codexAuth.nextSession\")}"); const styles = await Bun.file("gui/src/styles.css").text(); expect(styles).toContain(".card-badges { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }"); From f7be351c4ff8b8a54c5dbc165b59736b4e3f0941 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:27:04 +0200 Subject: [PATCH 10/16] docs: require needs-go-port for deferred Go ports (#672) Name the tracking-issue label in MAINTAINERS; mirror it in the AGENTS Transition summary. --- AGENTS.md | 4 ++-- MAINTAINERS.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c7c56de9..2502e1711 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,8 +67,8 @@ A merge into `dev` does not finish the task. The merging maintainer also rebases that work onto `dev2-go`, ports whatever needs a Go counterpart under `go/`, and merges the port. The item is done only when both lines carry the change. If a change has no Go counterpart, say so in the merge or tracking -issue; if the port has to wait, open a tracking issue against `dev2-go` naming -the source commits before closing out the `dev` merge. +issue; if the port has to wait, open a `needs-go-port` tracking issue against +`dev2-go` naming the source commits before closing out the `dev` merge. [`MAINTAINERS.md`](./MAINTAINERS.md) holds the authoritative wording. The Claude Desktop integration formerly carried on the `claudedesktop` branch is diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 79f0f39e0..8f27d3c97 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -45,7 +45,9 @@ rule is written out under [Review and merge policy](#review-and-merge-policy). indistinguishable from a forgotten one. - If the port cannot be completed immediately (a blocking dependency, a subsystem the port has not reached), open a tracking issue against - `dev2-go` before closing out the `dev` merge, and name the source commits. + `dev2-go` before closing out the `dev` merge, label it `needs-go-port`, + and name the source commits. That label is the durable signal that a + deferred port is intentional, not forgotten. - A pull request requires approval from at least one maintainer and successful required CI checks before merge. - Authors do not approve their own pull requests. From dcaede6e58624f352df3e0ceed508ed8f84a1ae0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:43:46 +0200 Subject: [PATCH 11/16] fix(cursor): steer Windows bridge shell away from PS 5.1 syntax loops (#627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cursor): steer Windows bridge shell away from PS 5.1 syntax loops Native-shell rejection told the model to replay the same bash/CMD command through the Codex bridge, which on Windows PowerShell 5.1 fails and loops (#604). Adapt for the host shell and inject PS-safe guidance instead. * fix(cursor): address #604 review — host-shell-neutral retry-stop guidance Drop proxy-OS platform branching and `;`-as-`&&` advice; use if ($?) and one corrected bridge attempt for Codex/CodeRabbit feedback on #627. * fix(cursor): state PowerShell 5.1 has no &&/|| in #604 guidance CodeRabbit follow-up: make unsupported-parser-error wording explicit and assert it in rejection/guidance tests. --- src/adapters/cursor/native-exec-shell.ts | 24 ++++++++++++++++++------ src/adapters/cursor/tool-definitions.ts | 8 +++++++- tests/cursor-native-exec-policy.test.ts | 6 ++++++ tests/cursor-tool-definitions.test.ts | 17 +++++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 27c07fd71..cda058fca 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -23,8 +23,20 @@ import { errorText, execBytes, execStreamCloseBytes } from "./native-exec-common const backgroundShells = new Map(); let nextShellId = 1; -const NATIVE_SHELL_DISABLED = - "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool with the same command."; +/** Rejection text when Cursor-native shell is denied by policy (issue #604). */ +export function nativeShellDisabledMessage(): string { + // Do not insist on "the same command" — that steers models into replaying bash/CMD + // idioms through the Codex bridge on Windows PowerShell 5.1 and looping (#604). + // Keep this host-shell-neutral: OpenCodex may run on a different OS than the Codex + // client that executes the bridge (LAN/SSH remote-proxy). + return ( + "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). " + + "Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool. " + + "Adapt the command for the Codex client host shell before calling the bridge " + + "(Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). " + + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands." + ); +} function rejectedShellResult(command: string, cwd: string, started: number) { return create(ShellResultSchema, { @@ -36,7 +48,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: NATIVE_SHELL_DISABLED, + stderr: nativeShellDisabledMessage(), executionTime: Date.now() - started, aborted: true, }), @@ -95,7 +107,7 @@ export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint event: { case: "start", value: create(ShellStreamStartSchema, { sandboxPolicy: args.requestedSandboxPolicy }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { - event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: NATIVE_SHELL_DISABLED }) }, + event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage() }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { event: { case: "exit", value: create(ShellStreamExitSchema, { code: 1, cwd, aborted: true }) }, @@ -196,7 +208,7 @@ export function rejectBackgroundShellSpawnExecForPolicy(execMsg: ExecServerMessa const args = execMsg.message.value; const cwd = resolve(args.workingDirectory || process.cwd()); return execBytes(execMsg, "backgroundShellSpawnResult", create(BackgroundShellSpawnResultSchema, { - result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: NATIVE_SHELL_DISABLED }) }, + result: { case: "error", value: create(BackgroundShellSpawnErrorSchema, { command: args.command, workingDirectory: cwd, error: nativeShellDisabledMessage() }) }, })); } @@ -233,7 +245,7 @@ export function backgroundShellSpawnExec(execMsg: ExecServerMessage): Uint8Array export function rejectWriteShellStdinExecForPolicy(execMsg: ExecServerMessage): Uint8Array { if (execMsg.message.case !== "writeShellStdinArgs") throw new Error("invalid shell stdin exec"); return execBytes(execMsg, "writeShellStdinResult", create(WriteShellStdinResultSchema, { - result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: NATIVE_SHELL_DISABLED }) }, + result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: nativeShellDisabledMessage() }) }, })); } diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 274cda914..7688f209a 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -417,6 +417,11 @@ export function buildCursorToolGuidanceSystemNote( const hasApplyPatch = cursorRequestAdvertisesApplyPatch(tools, toolChoice); const discoveryTools = discoveryToolLabel(wireNames); const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames); + // Host-shell-neutral: the Codex client executes bridge commands, and may differ from + // the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls. + const hostShellNote = hasBareExec + ? "Match shell syntax to the Codex client host that runs the bridge (not only the proxy OS). Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs (`< typeof note === "string"); return notes.join(" "); diff --git a/tests/cursor-native-exec-policy.test.ts b/tests/cursor-native-exec-policy.test.ts index e745cdec3..80ecee7c7 100644 --- a/tests/cursor-native-exec-policy.test.ts +++ b/tests/cursor-native-exec-policy.test.ts @@ -142,6 +142,12 @@ describe("Cursor native exec sandbox policy", () => { expect(deniedShellText).toContain("exec_command"); expect(deniedShellText).toContain("mcp_opencodex-responses_*"); expect(deniedShellText).toContain("Do not tell the user"); + expect(deniedShellText).not.toContain("with the same command"); + expect(deniedShellText).toContain("at most one corrected bridge attempt"); + expect(deniedShellText).toContain("if ($?)"); + expect(deniedShellText).toContain("`&&`/`||` are unsupported parser errors"); + expect(deniedShellText).toContain("do not treat `;` as a substitute for `&&`"); + expect(deniedShellText).toContain("Windows PowerShell 5.1"); expect(deniedShellText).not.toContain("disabled by OpenCodex policy"); expect(deniedShellText).not.toContain("sandbox denial"); expect(deniedShell.message.case).toBe("shellResult"); diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 652a93515..d418b304b 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -324,6 +324,23 @@ describe("Cursor tool definitions", () => { expect(note).toContain("Never tell the user that shell or read access is blocked"); }); + test("adds host-shell-neutral PowerShell and one-retry-stop guidance (#604)", () => { + const note = buildCursorToolGuidanceSystemNote([{ name: "shell_command", description: "Run", parameters: {} }]); + expect(note).toBeDefined(); + if (!note) throw new Error("Expected Cursor tool guidance note"); + + expect(note).toContain("Windows PowerShell 5.1"); + expect(note).toContain("cd /d"); + expect(note).toContain("< { const tools: OcxTool[] = [ { name: "exec_command", description: "Run", parameters: {} }, From a4fb28459993ce513d4017f7e414da5b340b84bc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:49:19 +0200 Subject: [PATCH 12/16] fix(gui): dedupe dashboard API token prompts on concurrent 401s (#651) Fixes #647. Single shared 401 token refresh gate; no prompt storm on concurrent /api/* 401s. --- gui/src/api.ts | 58 +++++++++-- gui/tests/api-auth-memory.test.ts | 166 ++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 8 deletions(-) diff --git a/gui/src/api.ts b/gui/src/api.ts index fd6a777bb..664cde6a9 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,5 +1,12 @@ let installed = false; +/** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */ let promptInFlight: Promise | null = null; +/** + * After the user cancels (or submits blank) once, suppress further prompts for this page + * lifetime so a staggered 401 fan-out does not reopen the dialog N times (#647 / Codex). + * A full reload clears module state and allows prompting again. + */ +let promptCancelled = false; function needsApiAuth(input: RequestInfo | URL): boolean { try { @@ -31,6 +38,11 @@ function clearToken(): void { memoryToken = null; } +/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */ +function clearTokenIfCurrent(expected: string | null): void { + if (expected != null && readToken() === expected) clearToken(); +} + function clearLegacySessionToken(): void { try { sessionStorage.removeItem(LEGACY_TOKEN_KEY); @@ -46,11 +58,31 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke return [input, { ...init, headers }]; } -async function promptForToken(): Promise { +/** + * Resolve a token after a 401. Concurrent callers share one in-flight resolution so a dashboard + * fan-out does not open one window.prompt per /api request (#647). Re-reads memoryToken before + * prompting so waiters that wake after another request already stored a token do not re-prompt. + */ +async function resolveTokenAfter401(failedToken: string | null): Promise { + if (promptCancelled) return null; if (promptInFlight) return promptInFlight; - promptInFlight = Promise.resolve() - .then(() => window.prompt("OpenCodex API token")?.trim() || null) - .finally(() => { promptInFlight = null; }); + + promptInFlight = (async () => { + if (promptCancelled) return null; + const current = readToken(); + if (current && current !== failedToken) return current; + + const prompted = window.prompt("OpenCodex API token")?.trim() || null; + if (prompted) { + storeToken(prompted); + return prompted; + } + promptCancelled = true; + return null; + })().finally(() => { + promptInFlight = null; + }); + return promptInFlight; } @@ -68,14 +100,23 @@ export function installApiAuthFetch(): void { const response = await originalFetch(firstInput, firstInit); if (response.status !== 401) return response; - if (token) clearToken(); - const nextToken = await promptForToken(); + // Another request may have stored a token while this one was in flight (or while prompt blocked). + const refreshed = readToken(); + if (refreshed && refreshed !== token) { + const [retryInput, retryInit] = withToken(input, init, refreshed); + const retry = await originalFetch(retryInput, retryInit); + if (retry.status !== 401) return retry; + clearTokenIfCurrent(refreshed); + } else { + clearTokenIfCurrent(token); + } + + const nextToken = await resolveTokenAfter401(token); if (!nextToken) return response; - storeToken(nextToken); const [retryInput, retryInit] = withToken(input, init, nextToken); const retry = await originalFetch(retryInput, retryInit); - if (retry.status === 401) clearToken(); + if (retry.status === 401) clearTokenIfCurrent(nextToken); return retry; }; } @@ -85,4 +126,5 @@ export function resetApiAuthFetchForTests(): void { installed = false; memoryToken = null; promptInFlight = null; + promptCancelled = false; } diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 4d27d8600..3bc008d70 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -113,6 +113,172 @@ test("cross-origin /api/* requests do not receive the API key or token prompt", expect(promptCalls).toBe(beforeCrossPrompts); }); +test("concurrent 401s share one token prompt and all retry with the stored token", async () => { + // Repro for #647: many /api/* requests start without a token (dashboard fan-out). + // Delivering 401s one-by-one after each auth cycle finishes matches the browser case where + // window.prompt blocks the main thread: each continuation still holds a captured null token + // and must reuse the in-memory token from an earlier request instead of prompting again. + let promptCalls = 0; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (headers.get("X-OpenCodex-API-Key") === "shared-token") { + return new Response("{}", { status: 200 }); + } + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "shared-token"; + }; + await installMockAuthFetch(mockFetch); + + const endpoints = [ + "/api/config", + "/api/providers", + "/api/models", + "/api/selected-models", + "/api/disabled-models", + "/api/effort-caps", + "/api/sidecar-settings", + "/api/injection-model", + "/api/v2", + "/api/keys", + "/api/provider-presets", + "/api/key-providers", + "/api/oauth/providers", + "/api/codex-auth/accounts", + ]; + const pending = endpoints.map((path) => fetch(path).then((r) => r.status)); + // Let every request reach the 401 gate before any response is delivered. + for (let i = 0; i < 20 && release401.length < endpoints.length; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(endpoints.length); + + for (let i = 0; i < endpoints.length; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([200]); +}); + +test("stale concurrent 401 does not clear a token refreshed by another request", async () => { + // Codex/CodeRabbit race: request A prompts and stores T2; request B still holding stale T1 + // must not wipe T2 (clearTokenIfCurrent) before its re-read / shared gate join. + let promptCalls = 0; + let acceptV1 = true; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + const key = headers.get("X-OpenCodex-API-Key"); + if (key === "token-v2") return new Response("{}", { status: 200 }); + if (acceptV1 && key === "token-v1") return new Response("{}", { status: 200 }); + if (key === "token-v1") { + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "token-v1"; + }; + await installMockAuthFetch(mockFetch); + expect((await fetch("/api/config")).status).toBe(200); + expect(promptCalls).toBe(1); + + acceptV1 = false; + promptCalls = 0; + window.prompt = () => { + promptCalls += 1; + return "token-v2"; + }; + + const pending = [fetch("/api/config"), fetch("/api/providers")].map((p) => p.then((r) => r.status)); + for (let i = 0; i < 20 && release401.length < 2; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(2); + + for (let i = 0; i < 2; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([200]); +}); + +test("canceling the token prompt once does not reopen it for the rest of the 401 fan-out", async () => { + let promptCalls = 0; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (headers.get("X-OpenCodex-API-Key")) { + return new Response("{}", { status: 200 }); + } + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return null; + }; + await installMockAuthFetch(mockFetch); + + const endpoints = ["/api/config", "/api/providers", "/api/models", "/api/keys"]; + const pending = endpoints.map((path) => fetch(path).then((r) => r.status)); + for (let i = 0; i < 20 && release401.length < endpoints.length; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(endpoints.length); + + for (let i = 0; i < endpoints.length; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([401]); +}); + test("cross-origin /v1/* requests do not receive the API key or token prompt", async () => { let promptCalls = 0; let phase: "seed" | "cross" = "seed"; From 14dde562bbc2b0e4d322b3a5c913e719cb746f39 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:59:33 +0200 Subject: [PATCH 13/16] fix(ci): harden PR target draft conversion and issue-quality Version gates (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens wrong-base PR drafting so the required check fails even when draft GraphQL soft-fails, and tightens bug-form Version/OS/reproduction gates. Go port: none — CI/workflows and issue-quality scripts only; no Go counterpart. --- .github/scripts/enforce-pr-target.test.cjs | 46 ++++ .github/scripts/issue-quality.cjs | 104 ++++++- .github/scripts/issue-quality.test.cjs | 167 ++++++++++++ .github/workflows/enforce-pr-target.yml | 131 ++++++--- .github/workflows/issue-quality-tests.yml | 5 + tests/ci-workflows.test.ts | 301 ++++++++++++--------- 6 files changed, 585 insertions(+), 169 deletions(-) create mode 100644 .github/scripts/enforce-pr-target.test.cjs diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs new file mode 100644 index 000000000..018f8b3c3 --- /dev/null +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -0,0 +1,46 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); + +describe("enforce-pr-target workflow", () => { + const workflowPath = path.join(__dirname, "../workflows/enforce-pr-target.yml"); + const workflow = fs.readFileSync(workflowPath, "utf8"); + + it("uses pull_request_target without checking out PR head code", () => { + assert.match(workflow, /pull_request_target:/); + assert.doesNotMatch( + workflow, + /actions\/checkout@/, + "wrong-branch enforcer must not check out untrusted PR code", + ); + }); + + it("grants contents:write so draft GraphQL mutations work with GITHUB_TOKEN", () => { + // convertPullRequestToDraft / markPullRequestReadyForReview fail with + // "Resource not accessible by integration" when contents stays unset/read + // (seen on #626). Assert the real permissions block, not comment text + // that also mentions these scopes. + const permissionsBlock = workflow.match(/^permissions:\n((?:[ \t]+.+\n)+)/m); + assert.ok(permissionsBlock, "workflow must declare a top-level permissions block"); + const lines = permissionsBlock[1] + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .sort(); + assert.deepEqual(lines, ["contents: write", "pull-requests: write"]); + }); + + it("fails the required check on a wrong base even if draft conversion fails", () => { + assert.match(workflow, /core\.setFailed\(/); + assert.match(workflow, /draftConversionFailed/); + assert.match(workflow, /Could not convert pull request to draft/); + }); + + it("soft-fails ready-for-review restoration the same way", () => { + assert.match(workflow, /readyConversionFailed/); + assert.match(workflow, /Could not mark pull request ready for review/); + }); +}); diff --git a/.github/scripts/issue-quality.cjs b/.github/scripts/issue-quality.cjs index b973b3a92..681cdc55b 100644 --- a/.github/scripts/issue-quality.cjs +++ b/.github/scripts/issue-quality.cjs @@ -25,19 +25,29 @@ function unwrapSingleEnclosingFence(text) { return match[2]; } -function isPlaceholderOnlyValue(raw) { - if (typeof raw !== "string") return false; +/** + * Shared strip/trim/unwrap used by placeholder and unusable-stand-in matchers. + * Returns null when the value is absent after normalisation. + */ +function normalizeRawSectionValue(raw) { + if (typeof raw !== "string") return null; let value = raw.replace(//g, "").trim(); - if (!value) return false; + if (!value) return null; - // A lone fenced block whose entire body is a placeholder is still placeholder - // text (e.g. ```text\nN/A\n```), not a real example. + // A lone fenced block whose entire body is a stand-in is still a stand-in + // (e.g. ```text\nN/A\n```), not a real example. const unwrapped = unwrapSingleEnclosingFence(value); if (unwrapped !== null) { value = unwrapped.trim(); - if (!value) return false; + if (!value) return null; } + return value; +} + +function isPlaceholderOnlyValue(raw) { + const value = normalizeRawSectionValue(raw); + if (value === null) return false; return PLACEHOLDER_ONLY_RE.test(value); } @@ -398,6 +408,20 @@ function isPlaceholder(text) { return isPlaceholderOnlyValue(text); } +/** + * True when Version is an "I don't know" stand-in rather than an install id. + * Kept separate from PLACEHOLDER_ONLY_RE so legacy N/A / No response soft-pass + * behaviour is unchanged. + */ +const UNUSABLE_VERSION_RE = + /^[\s_*~`]*(?:unknown|unkown|uknown|don'?t\s+know|do\s+not\s+know|idk|dunno|not\s+sure|unsure|\?+|모름|잘\s*모름|모르겠(?:습니다|음)?|不明|わからない|分からない|不知道|不清楚|keine\s+ahnung|wei[sß]{1,2}\s+nicht)[\s_*~`]*[.!?]*$/i; + +function isUnusableVersion(raw) { + const value = normalizeRawSectionValue(raw); + if (value === null) return false; + return UNUSABLE_VERSION_RE.test(value); +} + const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu; @@ -433,6 +457,16 @@ function isTooTerseFeatureSection(text) { return true; } +/** + * Bug Reproduction needs steps or concrete signals. A title-like phrase with + * no commands, paths, digits, or product keywords is not actionable. + */ +function isTooTerseBugReproduction(text) { + if (isEmpty(text) || isPlaceholder(text)) return false; + if (hasConcreteDetail(text)) return false; + return countWords(text) < 12; +} + /** * Check if raw section text is a placeholder-only variant without relying on * clean() first. Used to distinguish intentionally blank optional fields @@ -628,6 +662,8 @@ function validateIssue(issue) { const repro = extractSection(body, "Reproduction"); const version = extractSection(body, "Version"); const os = extractSection(body, "Operating system") ?? extractSection(body, "OS"); + // New Bug report template always includes Client or integration. + const isNewBugForm = extractSection(body, "Client or integration") !== null; if (isEmpty(summary) && isEmpty(repro)) { // Soft-pass substantial non-English / freeform structured reports once @@ -655,17 +691,56 @@ function validateIssue(issue) { if (isEmpty(repro)) { reasons.push("Reproduction is empty."); guidance.push("List the exact steps to reproduce the problem."); + } else if (!softPass && isTooTerseBugReproduction(repro)) { + reasons.push("Reproduction is too vague to act on."); + guidance.push("List exact steps, commands, and the observed failure — not only a short phrase."); } } - // Required environment fields removed after submission. - // Only fire when the headings exist in the body (new form). Legacy bug - // reports never had Version or OS fields, so null means absent, not removed. - // Skip when the raw value is a "No response" placeholder -- the old form had - // both fields as optional, so legacy issues legitimately contain those headings - // with the GitHub placeholder. Only close when the field was actively cleared. - if (!softPass && version !== null && os !== null && isEmpty(version) && isEmpty(os) && - !isRawPlaceholder(version) && !isRawPlaceholder(os)) { + // Version "Unknown" / "모름" / "idk" is never actionable, on any form. + if (!softPass && version !== null && isUnusableVersion(version)) { + reasons.push("Version is missing or unknown."); + guidance.push("Report the installed `@bitkyc08/opencodex` version (for example `2.7.42`) or a commit SHA from `ocx --version`."); + } else if ( + !softPass && + isNewBugForm && + version !== null && + (isEmpty(version) || isRawPlaceholder(version)) + ) { + // New form requires Version. Legacy N/A / No response soft-pass stays + // only for bodies without Client or integration. + reasons.push("Version is missing."); + guidance.push("Add your OpenCodex version so we can reproduce the environment."); + } + + if (!softPass && isNewBugForm && os !== null && isUnusableVersion(os)) { + reasons.push("Operating system is missing or unknown."); + guidance.push("Add your OS name and version (for example Windows 11 24H2)."); + } else if ( + !softPass && + isNewBugForm && + os !== null && + (isEmpty(os) || isRawPlaceholder(os)) + ) { + reasons.push("Operating system is missing."); + guidance.push("Add your OS name and version (for example Windows 11 24H2)."); + } + + // Required environment fields removed after submission on bodies that are + // not the new form (no Client or integration). Legacy reports never had + // Version or OS fields, so null means absent, not removed. Skip when the + // raw value is a "No response" placeholder — the old form had both fields + // as optional. Only close when the field was actively cleared. + if ( + !softPass && + !isNewBugForm && + version !== null && + os !== null && + isEmpty(version) && + isEmpty(os) && + !isRawPlaceholder(version) && + !isRawPlaceholder(os) + ) { reasons.push("Version and Operating system are both missing."); guidance.push("Add your OpenCodex version and OS so we can reproduce the environment."); } @@ -873,6 +948,7 @@ module.exports = { isPlaceholderOnlyValue, isPlaceholder, isRawPlaceholder, + isUnusableVersion, countWords, hasConcreteDetail, labelForKind, diff --git a/.github/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index 6dbe61e3c..9c8f9bb86 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -16,6 +16,7 @@ const { isPlaceholderOnlyValue, isPlaceholder, isRawPlaceholder, + isUnusableVersion, countWords, hasConcreteDetail, rejectsWorkflowDispatchPullRequest, @@ -693,6 +694,162 @@ describe("validateIssue - bug", () => { assert.equal(result.valid, false); assert.ok(result.reasons.some((r) => r.includes("Version"))); }); + + it("rejects unknown / don't-know Version values (#624)", () => { + const versions = [ + "Unknown", + "Uknown", + "unkown", + "Don't know", + "dont know", + "idk", + "모름", + "잘 모름", + "?", + "???", + ]; + for (const version of versions) { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "1. ocx start --port 10100", + "2. Send any Codex CLI request through the proxy", + "3. Observe the connection drop", + "### Version", + version, + "### Operating system", + "Windows 11", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal( + result.valid, + false, + `Expected unusable Version "${version}" to be invalid, got: ${result.reasons.join("; ")}`, + ); + assert.ok( + result.reasons.some((r) => /Version/i.test(r) && /unknown|missing/i.test(r)), + `Expected Version unknown/missing reason for "${version}", got: ${result.reasons.join("; ")}`, + ); + } + }); + + it("rejects issue #624-style low-effort new-form bug", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "CLI로 확인해봤는데 오픈코덱스 프록시가 중간에 자꾸 연결이 끊어져서 그런거라고 합니다.", + "", + "수정 바랍니다.", + "### Reproduction", + "예기치않게중단됨", + "### Version", + "모름", + "### Operating system", + "윈11", + "### Provider and model", + "_No response_", + "### Logs or error output", + "```shell", + "", + "```", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Version/i.test(r))); + assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague|empty/i.test(r))); + }); + + it("rejects a new-form bug with a usable Version but placeholder OS", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "Proxy returns 502 when streaming is enabled on Windows.", + "### Reproduction", + "1. ocx start", + "2. Send a streaming /v1/responses request", + "### Version", + "2.7.42", + "### Operating system", + "No response", + ].join("\n"); + const result = validateIssue({ title: "Streaming 502", body, labels: ["bug"] }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Operating system/i.test(r))); + }); + + it("rejects a new-form bug whose Reproduction is only a vague phrase", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "Unexpected interruption", + "### Version", + "2.7.42", + "### Operating system", + "Windows 11", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Reproduction/i.test(r) && /vague/i.test(r))); + }); + + it("rejects unknown Operating system stand-ins on the new bug form", () => { + const body = [ + "### Client or integration", + "Codex CLI", + "### Area", + "CLI", + "### Summary", + "The OpenCodex proxy keeps dropping the Codex CLI connection mid-request.", + "### Reproduction", + "1. ocx start --port 10100", + "2. Send any Codex CLI request through the proxy", + "3. Observe the connection drop", + "### Version", + "2.7.42", + "### Operating system", + "Unknown", + ].join("\n"); + const result = validateIssue({ + title: "Unexpected interruption continues to occur", + body, + labels: ["bug"], + }); + assert.equal(result.kind, "bug"); + assert.equal(result.valid, false); + assert.ok(result.reasons.some((r) => /Operating system/i.test(r))); + }); }); // --------------------------------------------------------------------------- @@ -839,6 +996,16 @@ describe("normalisation", () => { assert.equal(clean("Not available!"), ""); }); + it("detects unusable Version stand-ins without treating them as generic placeholders", () => { + for (const value of ["Unknown", "Uknown", "모름", "idk", "don't know"]) { + assert.equal(isUnusableVersion(value), true, value); + assert.equal(isPlaceholderOnlyValue(value), false, value); + } + for (const value of ["2.7.42", "N/A", "No response", "main@abc1234"]) { + assert.equal(isUnusableVersion(value), false, value); + } + }); + it("does not treat sentences containing placeholder phrases as empty", () => { assert.equal(clean("This is N/A for voice mode today."), "This is N/A for voice mode today."); assert.equal(clean("Not applicable to Claude Code."), "Not applicable to Claude Code."); diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index da76509d9..6e8a52af6 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -8,7 +8,13 @@ on: - edited - ready_for_review +# pull-requests:write covers title/comment updates. +# contents:write is required for convertPullRequestToDraft / +# markPullRequestReadyForReview GraphQL mutations with GITHUB_TOKEN +# (otherwise: "Resource not accessible by integration"). This workflow +# never checks out PR head code. permissions: + contents: write pull-requests: write concurrency: @@ -58,6 +64,7 @@ jobs: comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER) ); + let botCommentId = botComment?.id ?? null; function parseState(body) { const match = body?.match(STATE_PATTERN); @@ -90,23 +97,24 @@ jobs: } async function upsertComment(body) { - if (botComment) { + if (botCommentId) { await github.rest.issues.updateComment({ owner, repo, - comment_id: botComment.id, + comment_id: botCommentId, body }); return; } - await github.rest.issues.createComment({ + const created = await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body }); + botCommentId = created.data.id; } async function convertToDraft() { @@ -158,10 +166,12 @@ jobs: // Wrong branch: // - add the title prefix - // - convert ready PRs to draft + // - convert ready PRs to draft (best-effort) // - post or update the explanation + // - fail the required check so merge stays blocked even if draft fails // - remember which changes were made by this workflow if (wrongBase) { + const willPrefixTitle = !pr.title.startsWith(TITLE_PREFIX); const state = storedState?.active ? { ...storedState } : { @@ -171,20 +181,15 @@ jobs: titlePrefixedByBot: false }; - if (!pr.draft) { - state.autoDraftedByBot = true; - } - - if (!pr.title.startsWith(TITLE_PREFIX)) { + // Claim title ownership before mutating so a crash after the + // title write still records what the bot owns. Do not claim a + // new draft here — only after convertToDraft succeeds. + if (willPrefixTitle) { state.titlePrefixedByBot = true; } - const draftExplanation = state.autoDraftedByBot - ? "This pull request is being kept as a draft automatically. Once the target branch is corrected, it will be marked ready for review again." - : "This pull request was already a draft. Its draft status will be preserved after the target branch is corrected."; + let draftConversionFailed = false; - // Store the restoration state before changing the PR, allowing - // a rerun to recover if an API request fails partway through. await upsertComment( [ COMMENT_MARKER, @@ -194,15 +199,11 @@ jobs: "", `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, "", - `Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`, - "", - `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. Most contributions go to ${inlineCode(DEFAULT_BASE)} first; use ${inlineCode("dev2-go")} only for scoped Go native-port work. \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏`, - "", - draftExplanation + "Recording ownership state before applying title/draft changes…" ].join("\n") ); - if (!pr.title.startsWith(TITLE_PREFIX)) { + if (willPrefixTitle) { await github.rest.pulls.update({ owner, repo, @@ -212,9 +213,58 @@ jobs: } if (!pr.draft) { - await convertToDraft(); + try { + await convertToDraft(); + state.autoDraftedByBot = true; + // Checkpoint after a successful draft so ownership survives + // if the final explanation write fails. + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + "⚠️ **Wrong target branch**", + "", + `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, + "", + "Draft conversion succeeded; finalising explanation…" + ].join("\n") + ); + } catch (error) { + draftConversionFailed = true; + state.autoDraftedByBot = false; + core.warning( + `Could not convert pull request to draft: ${error.message}` + ); + } } + const draftExplanation = draftConversionFailed + ? "Automatic draft conversion failed (token cannot change draft status). Please convert this pull request to a draft manually, or retarget it to the correct base. The required `enforce-target` check will keep failing until the base is corrected." + : state.autoDraftedByBot + ? "This pull request is being kept as a draft automatically. Once the target branch is corrected, it will be marked ready for review again." + : "This pull request was already a draft. Its draft status will be preserved after the target branch is corrected."; + + await upsertComment( + [ + COMMENT_MARKER, + stateMarker(state), + "", + "⚠️ **Wrong target branch**", + "", + `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, + "", + `Its title has been prefixed with ${inlineCode(TITLE_PREFIX.trim())}.`, + "", + `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. Most contributions go to ${inlineCode(DEFAULT_BASE)} first; use ${inlineCode("dev2-go")} only for scoped Go native-port work. \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏`, + "", + draftExplanation + ].join("\n") + ); + + core.setFailed( + `Pull request targets ${pr.base.ref}; allowed bases are ${ALLOWED_BASES.join(", ")}.` + ); return; } @@ -243,27 +293,46 @@ jobs: // Mark the PR ready only if this workflow originally converted it // to draft. Intentionally drafted PRs remain drafts. + let readyConversionFailed = false; if ( storedState.autoDraftedByBot && pr.draft ) { - await markReadyForReview(); + try { + await markReadyForReview(); + } catch (error) { + readyConversionFailed = true; + core.warning( + `Could not mark pull request ready for review: ${error.message}` + ); + } } - const completedState = { - version: 1, - active: false, - autoDraftedByBot: false, - titlePrefixedByBot: false - }; + const completedState = readyConversionFailed + ? { + // Keep ownership active so a later edited/reopened/ + // ready_for_review event retries markReadyForReview. + version: 1, + active: true, + autoDraftedByBot: true, + titlePrefixedByBot: false + } + : { + version: 1, + active: false, + autoDraftedByBot: false, + titlePrefixedByBot: false + }; const titleResult = storedState.titlePrefixedByBot ? `The ${inlineCode(TITLE_PREFIX.trim())} title prefix has been removed.` : "The title was left unchanged."; - const draftResult = storedState.autoDraftedByBot - ? "The pull request has been marked ready for review again." - : "Its existing draft status has been preserved."; + const draftResult = readyConversionFailed + ? "Automatic ready-for-review conversion failed; please mark the pull request ready manually if it is still a draft." + : storedState.autoDraftedByBot + ? "The pull request has been marked ready for review again." + : "Its existing draft status has been preserved."; await upsertComment( [ diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml index 76d7e97ad..c6c72b66a 100644 --- a/.github/workflows/issue-quality-tests.yml +++ b/.github/workflows/issue-quality-tests.yml @@ -8,6 +8,7 @@ on: - ".github/scripts/issue-quality.test.cjs" - ".github/scripts/pr-labeler.cjs" - ".github/scripts/pr-labeler.test.cjs" + - ".github/scripts/enforce-pr-target.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -15,6 +16,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" @@ -25,6 +27,7 @@ on: - ".github/scripts/issue-quality.test.cjs" - ".github/scripts/pr-labeler.cjs" - ".github/scripts/pr-labeler.test.cjs" + - ".github/scripts/enforce-pr-target.test.cjs" - ".github/scripts/issue-translation.cjs" - ".github/scripts/issue-translation.test.cjs" - ".github/scripts/issue-triage.cjs" @@ -32,6 +35,7 @@ on: - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - ".github/workflows/enforce-issue-quality.yml" + - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" @@ -53,6 +57,7 @@ jobs: run: | node --test .github/scripts/issue-quality.test.cjs node --test .github/scripts/pr-labeler.test.cjs + node --test .github/scripts/enforce-pr-target.test.cjs node --test .github/scripts/issue-translation.test.cjs node --test .github/scripts/issue-triage.test.cjs node --test .github/scripts/parse-issue-translation-response.test.cjs diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index ec95bc200..644f29c42 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -5,8 +5,17 @@ import { callsTo, methodsOf, runEnforcePrTarget, + type HarnessResult, } from "./helpers/enforce-pr-target-harness"; +/** Final enforcer comment body after pending/draft checkpoints. */ +function lastEnforcerCommentBody(result: HarnessResult): string { + const updates = callsTo(result, "issues.updateComment") as Array<{ body: string }>; + if (updates.length > 0) return updates[updates.length - 1]!.body; + const creates = callsTo(result, "issues.createComment") as Array<{ body: string }>; + return creates[creates.length - 1]!.body; +} + const root = new URL("../", import.meta.url); const doctorGuiIfChangedScript = fileURLToPath(new URL("../scripts/doctor-gui-if-changed.ts", import.meta.url)); @@ -466,10 +475,15 @@ describe("GitHub Actions hardening", () => { // single assertion. expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual(["types"]); - // Exactly one permission scope. Asserting that `pull-requests: write` is - // present says nothing about what was added beside it, and a `write-all` - // scalar is not an object at all. - expect(workflow.permissions).toEqual({ "pull-requests": "write" }); + // Exactly the scopes this gate needs. `pull-requests: write` covers title + // and comment updates. `contents: write` is required for the draft GraphQL + // mutations with GITHUB_TOKEN (#626: "Resource not accessible by integration" + // when contents was unset). Asserting the whole object pins both presence + // and the absence of anything broader (write-all, contents alone, …). + expect(workflow.permissions).toEqual({ + contents: "write", + "pull-requests": "write", + }); // One run per PR, so two rapid events cannot race on the title/draft state, // and no `cancel-in-progress` — cancelling the in-flight run mid-mutation is @@ -738,9 +752,11 @@ describe("GitHub Actions hardening", () => { "issues.createComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain(`\`${ref}\``); + expect(lastEnforcerCommentBody(result)).toContain(`\`${ref}\``); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } }); @@ -787,14 +803,16 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); expect(callsTo(result, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Port the runtime entry" }, ]); - const [comment] = callsTo(result, "issues.updateComment") as [{ body: string }]; - expect(comment.body).toContain('"active":true'); - expect(comment.body).toContain('"autoDraftedByBot":true'); - expect(comment.body).toContain('"titlePrefixedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"active":true'); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":true'); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); }); test("the wrong-target explanation lists every allowed base and names the default", async () => { @@ -805,11 +823,11 @@ describe("GitHub Actions hardening", () => { const result = await run({ pr: { base: { ref: "main" }, title: "Add a thing", draft: false }, }); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; + const commentBody = lastEnforcerCommentBody(result); - expect(comment.body).toContain("must target one of `dev` or `dev2-go`"); - expect(comment.body).toContain("Please retarget this PR to `dev`"); - expect(comment.body).toContain("only for scoped Go native-port work"); + expect(commentBody).toContain("must target one of `dev` or `dev2-go`"); + expect(commentBody).toContain("Please retarget this PR to `dev`"); + expect(commentBody).toContain("only for scoped Go native-port work"); }); test("a PR targeting main is prefixed, drafted, and explained — and nothing else", async () => { @@ -817,14 +835,17 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "Add a thing", draft: false }, }); - // The comment lands before the mutations, so a rerun after a failed - // mutation can still find the restoration state. + // Pending ownership comment first, then title/draft, then checkpoints so + // a mid-run crash still records ownership and autoDraftedByBot only after + // convertToDraft succeeds (#626 / Codex review on #631). expect(methodsOf(result)).toEqual([ "pulls.get", "issues.listComments", "issues.createComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); // The title update carries the title and nothing else. `base`, `state` @@ -834,17 +855,22 @@ describe("GitHub Actions hardening", () => { { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, ]); - // The comment addresses this PR, by its own number. - const [comment] = callsTo(result, "issues.createComment") as [{ issue_number: number; body: string }]; - expect(comment.issue_number).toBe(42); - expect(comment.body).toContain(MARKER); - expect(comment.body).toContain("@contributor"); + // The first comment create addresses this PR, by its own number. + const [created] = callsTo(result, "issues.createComment") as [{ issue_number: number; body: string }]; + expect(created.issue_number).toBe(42); + expect(created.body).toContain(MARKER); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain("@contributor"); + expect(commentBody).toContain('"autoDraftedByBot":true'); // The only GraphQL mutation is the draft conversion — not a retarget. const [draft] = callsTo(result, "graphql") as [{ query: string; variables: unknown }]; expect(draft.query).toContain("convertPullRequestToDraft"); expect(draft.query).not.toContain("updatePullRequest"); expect(draft.variables).toEqual({ pullRequestId: "PR_kwDOnode42" }); + + // Wrong-base runs must fail the required check even when mutations succeed. + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); }); test("a PR that was already a draft is not un-drafted afterwards", async () => { @@ -852,16 +878,18 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, draft: true }, }); - // No draft conversion: it is already a draft. And the state it records - // says so, which is what stops the restore path from marking it ready. + // No draft conversion: it is already a draft. Pending ownership first, + // then title prefix, then final explanation. State records that the bot + // did not draft — which stops restore from marking it ready. expect(methodsOf(wrong)).toEqual([ "pulls.get", "issues.listComments", "issues.createComment", "pulls.update", + "issues.updateComment", ]); - const [comment] = callsTo(wrong, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"autoDraftedByBot":false'); + expect(lastEnforcerCommentBody(wrong)).toContain('"autoDraftedByBot":false'); + expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); // Now retarget it correctly, feeding that state back in. const restored = await run({ @@ -925,8 +953,13 @@ describe("GitHub Actions hardening", () => { comments: [botComment({ version: 1, active: true, autoDraftedByBot: true, titlePrefixedByBot: true })], }); - // The comment is refreshed; the title and draft state are already right. - expect(methodsOf(result)).toEqual(["pulls.get", "issues.listComments", "issues.updateComment"]); + // The comment is refreshed (pending + final); title and draft are already right. + expect(methodsOf(result)).toEqual([ + "pulls.get", + "issues.listComments", + "issues.updateComment", + "issues.updateComment", + ]); }); test("the verdict comes from the fetched PR, not the stale event payload", async () => { @@ -948,6 +981,8 @@ describe("GitHub Actions hardening", () => { "issues.createComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); expect(callsTo(wentWrong, "pulls.update")).toEqual([ { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, @@ -972,9 +1007,8 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "Add a thing", draft: false }, eventPayload: { base: { ref: "dev" }, title: "Add a thing", draft: false }, }); - const [posted] = callsTo(wrongTarget, "issues.createComment") as [{ body: string }]; - expect(posted.body).toContain("currently targets `main`"); - expect(posted.body).not.toContain("currently targets `dev`"); + expect(lastEnforcerCommentBody(wrongTarget)).toContain("currently targets `main`"); + expect(lastEnforcerCommentBody(wrongTarget)).not.toContain("currently targets `dev`"); // The corrected-path sentence: the event still carries the old wrong // base, the live PR is on dev2-go. Naming the event's base here tells the @@ -1043,6 +1077,8 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); expect(result.warnings.join(" ")).toContain("Could not parse stored workflow state"); }); @@ -1069,6 +1105,7 @@ describe("GitHub Actions hardening", () => { "pulls.get", "issues.listComments", "issues.updateComment", + "issues.updateComment", ]); // Corrected: nothing to undo, but the state must still be cleared or the @@ -1137,20 +1174,20 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "Add a thing", draft: false, user: { login: "someone-else" } }, }); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; + const commentBody = lastEnforcerCommentBody(result); // Addressed to the PR author, so GitHub actually notifies them. - expect(comment.body).toContain("@someone-else"); + expect(commentBody).toContain("@someone-else"); // Names every branch involved, so the instruction is actionable without // context: where the PR is now, where it should go, and the one other // base that is legitimate but not the default. - expect(comment.body).toContain("`main`"); - expect(comment.body).toContain("`dev`"); - expect(comment.body).toContain("`dev2-go`"); + expect(commentBody).toContain("`main`"); + expect(commentBody).toContain("`dev`"); + expect(commentBody).toContain("`dev2-go`"); // Points at the documentation rather than assuming the reader knows. - expect(comment.body).toContain("https://lidge-jun.github.io/opencodex/contributing/"); + expect(commentBody).toContain("https://lidge-jun.github.io/opencodex/contributing/"); // And carries the state the next run needs. - expect(comment.body).toContain(MARKER); - expect(comment.body).toContain('"version":1'); + expect(commentBody).toContain(MARKER); + expect(commentBody).toContain('"version":1'); }); test("comment listing asks for full pages, so the bot's own comment is found", async () => { @@ -1230,10 +1267,12 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", "pulls.update", "graphql", + "issues.updateComment", + "issues.updateComment", ]); - const [refreshed] = callsTo(wrong, "issues.updateComment") as [{ body: string }]; - expect(refreshed.body).toContain(`"version":${version}`); - expect(refreshed.body).toContain('"active":true'); + expect(lastEnforcerCommentBody(wrong)).toContain(`"version":${version}`); + expect(lastEnforcerCommentBody(wrong)).toContain('"active":true'); + expect(wrong.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } }); @@ -1280,19 +1319,21 @@ describe("GitHub Actions hardening", () => { expect(cleared.body).toContain('"active":false'); }); - test("the state comment is written before the PR is touched", async () => { - // Order is the recovery story. The comment records what the workflow is - // about to change; if a mutation fails afterwards, a rerun reads that - // record and finishes the job. Write the PR first and a failure in - // between leaves a renamed, drafted PR with no record that the bot did - // it — permanently stuck. The scenario above asserts the full call list, - // but this states the invariant on its own so a reordering says why. + test("ownership comment is checkpointed before mutations and finalized after", async () => { + // Pending ownership is written before title/draft so a crash mid-mutation + // still records what the bot owns. autoDraftedByBot is only true in the + // final body after convertToDraft succeeds (#626 / #631). const result = await run({ pr: { base: { ref: "main" }, draft: false } }); const methods = methodsOf(result); - const comment = methods.indexOf("issues.createComment"); - expect(comment).toBeGreaterThan(-1); - expect(comment).toBeLessThan(methods.indexOf("pulls.update")); - expect(comment).toBeLessThan(methods.indexOf("graphql")); + const pending = methods.indexOf("issues.createComment"); + const title = methods.indexOf("pulls.update"); + const draft = methods.indexOf("graphql"); + const finalUpdate = methods.lastIndexOf("issues.updateComment"); + expect(pending).toBeGreaterThan(-1); + expect(pending).toBeLessThan(title); + expect(title).toBeLessThan(draft); + expect(draft).toBeLessThan(finalUpdate); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); }); test("a title that is exactly the prefix is still enforced", async () => { @@ -1305,18 +1346,18 @@ describe("GitHub Actions hardening", () => { pr: { base: { ref: "main" }, title: "[WRONG BRANCH] ", draft: false }, }); - // Already prefixed, so no title write — but the state and the draft - // conversion still have to happen. + // Already prefixed, so no title write — but pending/draft/final still run. expect(callsTo(result, "pulls.update")).toEqual([]); expect(methodsOf(result)).toEqual([ "pulls.get", "issues.listComments", "issues.createComment", "graphql", + "issues.updateComment", + "issues.updateComment", ]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"titlePrefixedByBot":false'); - expect(comment.body).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); }); test("an empty title is enforced rather than skipped", async () => { @@ -1335,6 +1376,7 @@ describe("GitHub Actions hardening", () => { "issues.listComments", "issues.createComment", "pulls.update", + "issues.updateComment", ]); }); @@ -1357,10 +1399,10 @@ describe("GitHub Actions hardening", () => { "pulls.get", "issues.listComments", "issues.createComment", + "issues.updateComment", ]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"titlePrefixedByBot":false'); - expect(comment.body).toContain('"active":true'); + expect(lastEnforcerCommentBody(result)).toContain('"titlePrefixedByBot":false'); + expect(lastEnforcerCommentBody(result)).toContain('"active":true'); }); test("a title that already carries the prefix twice is still enforced", async () => { @@ -1379,12 +1421,13 @@ describe("GitHub Actions hardening", () => { "issues.listComments", "issues.createComment", "graphql", + "issues.updateComment", + "issues.updateComment", ]); // Already prefixed by the `startsWith` test, so no third prefix is added. expect(callsTo(result, "pulls.update")).toEqual([]); - const [comment] = callsTo(result, "issues.createComment") as [{ body: string }]; - expect(comment.body).toContain('"active":true'); - expect(comment.body).toContain('"autoDraftedByBot":true'); + expect(lastEnforcerCommentBody(result)).toContain('"active":true'); + expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); }); test("with two bot comments, the workflow reads and writes the first", async () => { @@ -1564,27 +1607,37 @@ describe("GitHub Actions hardening", () => { expect(probe.bun).toBe("undefined"); }); - test("an API failure is never swallowed, whatever status it carries", async () => { - // Octokit rejects with a `RequestError` that has a `.status`, and an - // audit round swallowed exactly one code: - // - // catch (error) { if (error.status === 404) return; throw error; } - // - // A green workflow, an un-drafted PR. Drive the failure at several - // statuses so a code-specific catch cannot hide in the gap. + test("a draft GraphQL failure is soft-failed with accurate state and a hard check failure", async () => { + // Observed on PR #626: convertPullRequestToDraft failed with + // "Resource not accessible by integration", the job crashed before + // setFailed, and the PR stayed ready. Soft-catch the draft mutation, + // record autoDraftedByBot:false, explain the fallback, and still fail + // the required check so merge stays blocked. const { script } = await readEnforcePrTarget(); for (const status of [403, 404, 422, 500]) { - await expect( - runEnforcePrTarget(script, { - pr: { base: { ref: "main" }, draft: false }, - failOn: ["graphql"], - failStatus: status, - }), - ).rejects.toThrow(/simulated failure: graphql/); + const result = await runEnforcePrTarget(script, { + pr: { base: { ref: "main" }, draft: false }, + failOn: ["graphql"], + failStatus: status, + }); + expect(methodsOf(result)).toEqual([ + "pulls.get", + "issues.listComments", + "issues.createComment", + "pulls.update", + "graphql", + "issues.updateComment", + ]); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain('"autoDraftedByBot":false'); + expect(commentBody).toContain("Automatic draft conversion failed"); + expect(result.warnings.some((w) => w.includes("Could not convert pull request to draft"))).toBe(true); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); } - // Same for the title update, which fails before the draft conversion. + // Title update failures still propagate — without the prefix the gate + // has no durable signal on the PR itself. for (const status of [403, 404, 422]) { await expect( runEnforcePrTarget(script, { @@ -1596,40 +1649,44 @@ describe("GitHub Actions hardening", () => { } }); - test("a failed draft conversion propagates, and the state is already stored", async () => { - // Observed on PR #527 (devlog .../050_live_evidence.md): the GraphQL - // mutation failed, the workflow ended in failure, and the PR stayed - // ready — but the comment already claimed `autoDraftedByBot: true`. - // - // The ordering is deliberate: writing the state first is what makes a - // rerun recoverable. This pins both halves of it, including the part - // that is wrong — the recorded state disagrees with reality until the - // rerun. The redesign in 040 owns fixing that; this test documents it so - // the fix is visible when it lands. + test("a failed draft conversion does not claim autoDraftedByBot", async () => { const { script } = await readEnforcePrTarget(); - const observed: string[] = []; - - await expect( - runEnforcePrTarget(script, { - pr: { base: { ref: "main" }, draft: false }, - failOn: ["graphql"], - }).catch((error: unknown) => { - observed.push(String(error)); - throw error; - }), - ).rejects.toThrow(/simulated failure: graphql/); - - // The failure is not swallowed. An audit round wrapped the whole script - // in `try { … } catch {}`, which turns every API failure into a silent - // no-op and a green check; this assertion is what makes that visible. - expect(observed).toHaveLength(1); - - // …and the state comment went out before the mutation that failed. - const upTo = await runEnforcePrTarget(script, { + const result = await runEnforcePrTarget(script, { pr: { base: { ref: "main" }, draft: false }, - failOn: ["pulls.update"], - }).catch(() => null); - expect(upTo).toBeNull(); + failOn: ["graphql"], + }); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain('"autoDraftedByBot":false'); + expect(commentBody).toContain('"titlePrefixedByBot":true'); + expect(result.warnings.some((w) => w.startsWith("setFailed:"))).toBe(true); + }); + + test("a failed ready-for-review conversion keeps ownership active for retry", async () => { + const { script } = await readEnforcePrTarget(); + const result = await runEnforcePrTarget(script, { + pr: { base: { ref: "dev" }, draft: true, title: "[WRONG BRANCH] Add a thing" }, + comments: [ + { + id: 7, + user: { login: "github-actions[bot]" }, + body: [ + "", + ``, + ].join("\n"), + }, + ], + failOn: ["graphql"], + }); + const commentBody = lastEnforcerCommentBody(result); + expect(commentBody).toContain('"active":true'); + expect(commentBody).toContain('"autoDraftedByBot":true'); + expect(commentBody).toContain("Automatic ready-for-review conversion failed"); + expect(result.warnings.some((w) => w.includes("Could not mark pull request ready for review"))).toBe(true); }); }); @@ -1646,6 +1703,7 @@ describe("GitHub Actions hardening", () => { expect(script).toMatch(/storedState\.titlePrefixedByBot/); expect(script).toMatch(/await\s+convertToDraft\(\)/); expect(script).toMatch(/await\s+markReadyForReview\(\)/); + expect(script).toMatch(/core\.setFailed\(/); // Tie each helper to its GraphQL body. Asserting that the call and the // mutation name both appear somewhere leaves a gap: declaring an empty @@ -1673,25 +1731,20 @@ describe("GitHub Actions hardening", () => { expect(script).toMatch(/\n\s*if \(!storedState\?\.active\) \{\n/); expect(script).toMatch(/\n\s*if \(wrongBase\) \{\n/); - // Observed on PR #527 (devlog .../050_live_evidence.md): the state is written - // BEFORE the mutation and is not reconciled when the mutation fails, so a - // failed convertToDraft still records autoDraftedByBot: true. This documents - // that ordering rather than endorsing it — the redesign in 040 owns fixing - // it, and this assertion should change with it. - // - // Scope the comparison to the wrong-base branch: upsertComment is called from - // both branches, so comparing across the whole script would measure whichever - // call happens to come first in the text. + // Pending ownership is written before mutations; convertToDraft runs next; + // a later upsertComment records autoDraftedByBot only after success (#631). const branchStart = script.indexOf("if (wrongBase) {"); expect(branchStart).toBeGreaterThan(-1); const branch = script.slice(branchStart); - const stateWriteIndex = branch.indexOf("await upsertComment("); + const pendingWriteIndex = branch.indexOf("await upsertComment("); const draftCallIndex = branch.indexOf("await convertToDraft()"); - expect(stateWriteIndex).toBeGreaterThan(-1); - expect(draftCallIndex).toBeGreaterThan(stateWriteIndex); + const afterDraftWriteIndex = branch.indexOf("await upsertComment(", draftCallIndex); + expect(pendingWriteIndex).toBeGreaterThan(-1); + expect(draftCallIndex).toBeGreaterThan(-1); + expect(pendingWriteIndex).toBeLessThan(draftCallIndex); + expect(afterDraftWriteIndex).toBeGreaterThan(draftCallIndex); }); - test("docs deployment is pinned, bounded, and scoped to Pages", async () => { const workflow = await readText(".github/workflows/deploy-docs.yml"); From 9d181fc414c462ac759870ca33e151a49b99c0a7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:10:23 +0200 Subject: [PATCH 14/16] fix(catalog): accept Together top-level /models arrays (#639) * fix(catalog): accept Together top-level /models arrays Closes #617 * fix(catalog): keep models[] for probe only; array+data for discovery --- src/codex/catalog/provider-fetch.ts | 30 +++++++++++++++++++++--- src/server/management/provider-routes.ts | 10 ++++---- tests/provider-connection-test.test.ts | 13 ++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 7ad985eef..916babd25 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -71,6 +71,32 @@ export function isProviderModelsApiItems(value: unknown): value is ProviderModel ); } +/** + * Normalize OpenAI-compatible /models payloads for catalog discovery. + * Supports `{ data: [...] }` and top-level arrays (Together AI `#617`). + * Google's `{ models: [...] }` is handled by the connectivity probe only — catalog + * discovery must not treat a stray `models` key on openai-chat responses as valid. + */ +export function providerModelsListFromResponse(json: unknown): unknown { + if (Array.isArray(json)) return json; + if (json !== null && typeof json === "object" && !Array.isArray(json)) { + const data = (json as { data?: unknown }).data; + if (Array.isArray(data)) return data; + } + return undefined; +} + +/** Connectivity-probe shape: also accepts Google `{ models: [...] }`. */ +export function providerModelsListFromProbeResponse(json: unknown): unknown { + if (Array.isArray(json)) return json; + if (json !== null && typeof json === "object" && !Array.isArray(json)) { + const obj = json as { data?: unknown; models?: unknown }; + if (Array.isArray(obj.data)) return obj.data; + if (Array.isArray(obj.models)) return obj.models; + } + return undefined; +} + export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow; return typeof configured === "number" && configured > 0 ? configured : undefined; @@ -366,9 +392,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, } return models; } - const data = json !== null && typeof json === "object" && !Array.isArray(json) - ? (json as { data?: unknown }).data - : undefined; + const data = providerModelsListFromResponse(json); if (!isProviderModelsApiItems(data)) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); if (shouldLog) { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index f4b0fed2b..7735d22a4 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { providerModelsListFromProbeResponse } from "../../codex/catalog/provider-fetch"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -344,12 +345,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise null) as { data?: unknown; models?: unknown } | null; - // OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape - // buildModelsRequest can produce) returns { models: [...] }. - const list = json && typeof json === "object" && !Array.isArray(json) - ? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined) - : undefined; + const json = await res.json().catch(() => null); + // OpenAI-style { data }, Google { models }, and Together-style top-level arrays (#617). + const list = providerModelsListFromProbeResponse(json); if (!Array.isArray(list)) { return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" }); } diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index f4bf64775..55cb7d9e4 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -135,6 +135,19 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(body.models).toBe(3); }); + test("Together-style top-level /models array is accepted (#617)", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify([{ id: "meta/llama" }, { id: "Qwen/Qwen" }]), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + const config = baseConfig({ + together: { adapter: "openai-chat", baseUrl: "https://api.together.xyz/v1", apiKey: "tg-key" }, + }); + const { body } = await probe(config, "together"); + expect(body.ok).toBe(true); + expect(body.models).toBe(2); + }); + test("malformed 2xx data is an explicit failure, not a silent pass", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ nope: true }), { status: 200, From e6169b0e583f2965e3c0899eede4795e034adea0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:12:48 +0200 Subject: [PATCH 15/16] fix(cli): status/doctor use identity-verified live proxy (#642) Fixes #618. Prefer findLiveProxy over ocx.pid alone; keep status/doctor read-only on malformed config; preserve authoritative null live pids and runtime vs config listen source. --- src/cli/doctor.ts | 28 +++++++++------- src/cli/status.ts | 60 +++++++++++++++++++++++++++-------- src/server/proxy-liveness.ts | 17 ++++++++-- tests/cli-status-json.test.ts | 9 +++++- tests/proxy-liveness.test.ts | 16 +++++----- 5 files changed, 94 insertions(+), 36 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index f4a28361a..b3bcf7c02 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -11,6 +11,7 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config"; +import { findLiveProxy } from "../server/proxy-liveness"; import { gracefulStopHost } from "../lib/process-control"; import { maskAccountId } from "../lib/privacy"; import { loadServiceTokenFromFile } from "../lib/service-secrets"; @@ -718,9 +719,21 @@ export async function runDoctor(args: string[] = []): Promise { } } + // #618: identity-verified liveness first so pid-file absence does not hide a live service. + // Reuse the diagnostics config already loaded above so doctor stays read-only on malformed JSON. + const live = await findLiveProxy({ + configFn: () => ({ port: doctorConfig.port, hostname: doctorConfig.hostname }), + }); + const livePid = live ? live.pid : readPid(); + const liveRuntime = live + ? { pid: live.pid ?? 0, port: live.port, hostname: live.hostname } + : (livePid ? readRuntimePort(livePid) : null); + const currentProxyEnv = collectProxyEnv(); const configuredProxy = collectConfiguredProxy(); - const runningProxyEnv = collectRunningProxyEnv(); + const runningProxyEnv = collectRunningProxyEnv({ + readPidFn: () => (live ? live.pid : readPid()), + }); console.log("\nCurrent doctor process proxy env (presence only)"); for (const row of currentProxyEnv) { @@ -742,17 +755,10 @@ export async function runDoctor(args: string[] = []): Promise { } } - // #314: service-process memory/runtime identity via the authed management - // endpoint. readPid() FIRST (liveness), then the pid-scoped runtime record — - // readRuntimePort alone can serve a stale file pointing at a foreign port. - // Hoisted out of the block below: the Hints section reuses the same liveness pair - // for the proxy-down restart hint. - const livePid = readPid(); - const liveRuntime = livePid ? readRuntimePort(livePid) : null; console.log("\nMemory / runtime"); { const runtime = liveRuntime; - if (!runtime) { + if (!runtime || !live) { console.log(` -- doctor process Bun ${Bun.version} (this is NOT the service process)`); console.log(" -- no running ocx proxy found (no live pid/runtime record)"); } else { @@ -815,8 +821,8 @@ export async function runDoctor(args: string[] = []): Promise { // Hints, not fixes. const hints: string[] = []; const proxyDown = proxyDownRestartHint({ - proxyRunning: Boolean(livePid && liveRuntime), - port: doctorConfig.port ?? 10100, + proxyRunning: Boolean(live), + port: live?.port ?? doctorConfig.port ?? 10100, serviceViable: startup.serviceViable, }); if (proxyDown) hints.push(proxyDown); diff --git a/src/cli/status.ts b/src/cli/status.ts index a9686db01..76d61d7f8 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,7 +1,7 @@ import { durableBunRuntime } from "../lib/bun-runtime"; import { codexAutoStartEnabled, getConfigPath, getPidPath, readConfigDiagnostics, readPid, readRuntimePort, type RuntimePortState } from "../config"; import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../codex/plugins-doctor"; -import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; +import { findLiveProxy, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; import { diagnoseService } from "../service"; import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health"; @@ -102,6 +102,14 @@ export function selectListenTarget( }; } +/** Prefer live result (including authoritative null pid) over the on-disk pid file. */ +export function resolveStatusPid( + live: { pid: number | null } | null, + pidFile: number | null, +): number | null { + return live ? live.pid : pidFile; +} + async function checkProxyHealth(target: ListenTarget): Promise { const url = target.healthUrl; const controller = new AbortController(); @@ -132,9 +140,33 @@ async function checkProxyHealth(target: ListenTarget): Promise { export async function collectStatus(): Promise { const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; - const pid = readPid(); - const listen = selectListenTarget(config, pid, pid ? readRuntimePort(pid) : null); - const health = await checkProxyHealth(listen); + // Prefer identity-verified liveness (runtime-port + /healthz) over ocx.pid alone (#618). + // Pass the already-resolved diagnostics config so findLiveProxy does not re-load and + // warn on malformed config.json (status --json must stay stderr-clean). + const live = await findLiveProxy({ + configFn: () => ({ port: config.port, hostname: config.hostname }), + }); + const pidFile = readPid(); + // Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile. + const pid = resolveStatusPid(live, pidFile); + const listen = live + ? { + port: live.port, + hostname: live.hostname, + source: live.source, + healthUrl: `http://${probeHostname(live.hostname)}:${live.port}/healthz`, + dashboardUrl: `http://localhost:${live.port}/`, + } + : selectListenTarget(config, pidFile, pidFile ? readRuntimePort(pidFile) : null); + // findLiveProxy already identity-probed /healthz; avoid a second fetch that can race. + const health = live + ? { + ok: true, + url: listen.healthUrl, + message: `ok (pid ${live.pid ?? "unknown"})`, + label: `${listen.healthUrl} ok (live)`, + } + : await checkProxyHealth(listen); const bunRuntime = durableBunRuntime(); const service = diagnoseService(); const serviceSummary = service.summary; @@ -228,13 +260,15 @@ export async function collectStatus(): Promise { runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null, }, }; - const proxyLabel = pid && health.ok - ? `running (PID ${pid})` - : pid - ? `PID file points to PID ${pid}, but health check failed` - : health.ok - ? "reachable, but PID file is missing or stale" - : "not running"; + const proxyLabel = live + ? `running (PID ${live.pid ?? pid ?? "unknown"})` + : pid && health.ok + ? `running (PID ${pid})` + : pid + ? `PID file points to PID ${pid}, but health check failed` + : health.ok + ? "reachable, but PID file is missing or stale" + : "not running"; return { proxyLabel, @@ -242,8 +276,8 @@ export async function collectStatus(): Promise { json: { schemaVersion: 1, proxy: { - running: Boolean(pid && health.ok), - pid, + running: Boolean(live) || Boolean(pid && health.ok), + pid: live?.pid ?? pid, health: { ok: health.ok, url: health.url, diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 67ff35667..0ed7e6bbf 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -37,6 +37,8 @@ export interface LiveProxy { port: number; /** Raw bind hostname the probe succeeded against; compose URLs via `probeHostname`. */ hostname?: string; + /** Whether the successful probe used runtime-port metadata or the configured listen port. */ + source: "runtime" | "config"; } /** @@ -118,7 +120,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise { expect(target.dashboardUrl).toBe("http://localhost:58195/"); }); + test("resolveStatusPid preserves an authoritative null from live orphan checks", () => { + expect(resolveStatusPid({ pid: null }, 4242)).toBeNull(); + expect(resolveStatusPid({ pid: 1111 }, 4242)).toBe(1111); + expect(resolveStatusPid(null, 4242)).toBe(4242); + expect(resolveStatusPid(null, null)).toBeNull(); + }); + test("listen target brackets raw IPv6 hostnames in the health URL", () => { const target = selectListenTarget( { port: 10100, hostname: "::1" }, diff --git a/tests/proxy-liveness.test.ts b/tests/proxy-liveness.test.ts index cdb787fb0..66c09b4fd 100644 --- a/tests/proxy-liveness.test.ts +++ b/tests/proxy-liveness.test.ts @@ -66,7 +66,7 @@ describe("findLiveProxy", () => { }) as typeof fetch, }); - expect(live).toEqual({ pid: 4242, port: 58195 }); + expect(live).toEqual({ pid: 4242, port: 58195, source: "runtime" }); expect(urls).toEqual(["http://127.0.0.1:58195/healthz"]); }); @@ -78,7 +78,7 @@ describe("findLiveProxy", () => { fetchFn: (async () => healthz(OURS)) as typeof fetch, }); - expect(live).toEqual({ pid: 4242, port: 10100 }); + expect(live).toEqual({ pid: 4242, port: 10100, source: "config" }); }); test("a foreign listener on the configured port is not treated as our proxy", async () => { @@ -104,7 +104,7 @@ describe("findLiveProxy", () => { }) as typeof fetch, }); - expect(live).toEqual({ pid: 4242, port: 58195, hostname: "::1" }); + expect(live).toEqual({ pid: 4242, port: 58195, hostname: "::1", source: "runtime" }); expect(urls).toEqual(["http://[::1]:58195/healthz"]); }); @@ -120,7 +120,7 @@ describe("findLiveProxy", () => { // The record's pid 1111 may be dead/reused — synthesizing it would let `ocx stop` // kill an unrelated process via the taskkill/kill fallback. - expect(live).toEqual({ pid: null, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: null, port: 58195, hostname: undefined, source: "runtime" }); }); test("an orphaned record whose healthz pid mismatches is rejected (config fallback still runs)", async () => { @@ -145,7 +145,7 @@ describe("findLiveProxy", () => { // The runtime probe fails the pid check; the config fallback probes the same port // without a pid expectation and adopts the reported live pid instead. - expect(live).toEqual({ pid: 9999, port: 58195 }); + expect(live).toEqual({ pid: 9999, port: 58195, source: "config" }); }); test("a pidless legacy healthz never promotes an unverified cheap pid to a kill target", async () => { @@ -158,7 +158,7 @@ describe("findLiveProxy", () => { fetchFn: (async () => healthz(legacyBody)) as typeof fetch, }); - expect(live).toEqual({ pid: null, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: null, port: 58195, hostname: undefined, source: "runtime" }); }); test("a pidless legacy healthz returns the cheap pid once full identity verification echoes it", async () => { @@ -176,7 +176,7 @@ describe("findLiveProxy", () => { }); expect(verified).toEqual([1111]); - expect(live).toEqual({ pid: 1111, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: 1111, port: 58195, hostname: undefined, source: "runtime" }); }); test("a verifier answering with a DIFFERENT pid than the candidate is rejected (TOCTOU guard)", async () => { @@ -189,6 +189,6 @@ describe("findLiveProxy", () => { fetchFn: (async () => healthz(legacyBody)) as typeof fetch, }); - expect(live).toEqual({ pid: null, port: 58195, hostname: undefined }); + expect(live).toEqual({ pid: null, port: 58195, hostname: undefined, source: "runtime" }); }); }); From f19a73c344bcbe7b284e9597aff4ac0fa1f857be Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:16:15 +0200 Subject: [PATCH 16/16] fix(catalog): omit bare OpenAI models without openai provider (#643) Fixes #636. Skip bare gpt-*/native OpenAI catalog rows when no enabled canonical openai provider exists; make NoEnabledOpenAiProviderError actionable. --- src/codex/catalog/sync.ts | 21 ++++++++++++++++++--- src/router.ts | 5 ++++- tests/router.test.ts | 4 ++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 877c18471..3189c1e73 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -329,9 +329,11 @@ export function mergeCatalogEntriesForSync( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), hasPhysicalComboProvider = false, + includeNativeOpenAi = true, ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); - const native = catalogModels + const native = includeNativeOpenAi + ? catalogModels .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.owned_by !== COMBO_NAMESPACE @@ -367,11 +369,14 @@ export function mergeCatalogEntriesForSync( // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved); return preserved; - }); + }) + : []; // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a // routed provider exposing the same id can never delete the native OpenAI/Codex base row. + // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404. const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); + if (includeNativeOpenAi) { for (const slug of nativeOpenAiSlugs()) { if (nativeSlugs.has(slug)) continue; nativeSlugs.add(slug); @@ -382,6 +387,7 @@ export function mergeCatalogEntriesForSync( : 9; native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority)); } + } const freshSlugs = new Set( routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), @@ -497,7 +503,16 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a // native template can never leak supports_websockets while the flag is off. const wsEnabled = websocketsEnabled(config); - catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider); + const enabledProviders = Object.entries(config.providers ?? {}) + .filter(([, prov]) => prov.disabled !== true); + const hasCanonicalOpenai = enabledProviders.some(([name, prov]) => + name === "openai" && isCanonicalOpenAiForwardProvider(prov), + ); + // #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise + // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no + // providers are configured yet (fresh install / catalog bootstrap tests). + const includeNativeOpenAi = enabledProviders.length === 0 || hasCanonicalOpenai; + catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); diff --git a/src/router.ts b/src/router.ts index 6dc331f4d..b20ef7f12 100644 --- a/src/router.ts +++ b/src/router.ts @@ -293,7 +293,10 @@ function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][] export class NoEnabledOpenAiProviderError extends Error { constructor(modelId: string) { - super(`No enabled OpenAI provider for model: ${modelId}. Run 'ocx init' to configure a provider, or check that your config has an enabled 'openai' provider.`); + super( + `Model ${modelId} requires the canonical openai provider. ` + + `Run: ocx provider add openai && ocx sync && ocx restart`, + ); this.name = "NoEnabledOpenAiProviderError"; } } diff --git a/tests/router.test.ts b/tests/router.test.ts index 82cf6cb5b..ac6fe6d7e 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -142,9 +142,9 @@ describe("routeModel registry effort defaults", () => { expect(routeModel({ ...base, providers: { ...base.providers, openai: { ...forward, codexAccountMode: "direct" } } }, "gpt-5.5")) .toMatchObject({ providerName: "openai", codexAccountMode: "direct" }); expect(() => routeModel({ ...base, providers: { ...base.providers, openai: { ...forward, disabled: true } } }, "gpt-5.5")) - .toThrow(NoEnabledOpenAiProviderError); + .toThrow(/requires the canonical openai provider/); const unavailable = { ...base, providers: { "openai-proxy": base.providers["openai-proxy"] } }; - expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(NoEnabledOpenAiProviderError); + expect(() => routeModel(unavailable, "gpt-5.5")).toThrow(/ocx provider add openai/); }); test("rejects legacy chatgpt namespaces even when configured", () => {