From 9a5b6d9d72dcf59d2b6741778f597ba335bf2123 Mon Sep 17 00:00:00 2001 From: MateuszJuszczyk Date: Fri, 25 Sep 2026 23:41:02 +0200 Subject: [PATCH] feat(codex): quota-driven desktop-authless auto failover While the main Codex 5h quota reports exhausted, recent Desktop builds disable the composer account-wide, including for independently credentialed routed models. Opt-in 'codexDesktopAuthlessAuto' engages desktop-authless routing on exhaustion and releases it on forced- refresh-confirmed recovery, rewriting config.toml and restarting Codex clients per transition. Related #4878. --- .../content/docs/guides/codex-integration.md | 22 +++ .../docs/reference/configuration/server.md | 1 + scripts/test-layout/layout.json | 1 + src/cli/system-command.ts | 19 ++- src/codex/desktop-authless-auto.ts | 145 ++++++++++++++++++ src/config/schema/config-schema.ts | 1 + src/server/index.ts | 11 +- src/server/index/codex-sweep-workers.ts | 24 +++ src/server/index/startup-warnings.ts | 2 + src/server/management/config-routes.ts | 15 +- src/types/config.ts | 10 ++ structure/config.md | 16 +- structure/dashboard-and-usage.md | 5 +- .../codex-desktop-authless-auto.test.ts | 138 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/lab/core-lab-boundary.test.ts | 2 +- 16 files changed, 390 insertions(+), 23 deletions(-) create mode 100644 src/codex/desktop-authless-auto.ts create mode 100644 src/server/index/codex-sweep-workers.ts create mode 100644 tests/codex-integration/codex-desktop-authless-auto.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ecfc90c111f..6718d648f66 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -523,6 +523,28 @@ This only changes the Desktop login gate. Non-loopback binds keep `requires_open the `env_key` admission credential regardless of the switch; it never exposes an OpenCodex listener without authentication. +### Automatic failover on quota exhaustion + +Recent Codex Desktop releases disable the composer account-wide while the ChatGPT 5h quota +reports exhausted — including for independently credentialed routed models that consume none +of that quota, since every catalog model reaches Desktop through the same provider. When that +happens, no request leaves the client, so proxy-side routing cannot help; only the provider +form matters. + +`codexDesktopAuthlessAuto` watches the main-account quota on the shared minute sweep and flips +the switch above by itself: + +```bash +ocx system settings --desktop-authless-auto on +``` + +While the 5h window is exhausted, authless routing is engaged and routed models stay +submittable. Recovery is confirmed against a forced upstream quota read (never a stale cache) +before authenticated routing is restored. Each transition rewrites `~/.codex/config.toml` and +restarts Codex clients to adopt it, and only transitions act — a sweep that finds the desired +state already stored is a no-op. Manual `codexDesktopAuthless` edits are respected until the +next sweep re-evaluates them. + ## Thread identity and history The default loopback form keeps new threads tagged with Codex's native `openai` provider, so normal diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 16c2fcae3c9..4f05f3d4430 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -34,6 +34,7 @@ runs helper features around provider requests. | `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. False makes ensure a no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Restore an installed shim after a completed external Codex update replaces it. Environment opt-out: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | | `codexDesktopAuthless?` | `boolean` | `false` | Opt-in authless Codex Desktop routing on a loopback bind: inject the dedicated `opencodex` provider with `requires_openai_auth = false` so Desktop opens without a ChatGPT login. Ignored on non-loopback binds. `ocx system settings --desktop-authless on`. See [Codex integration](/guides/codex-integration/#authless-codex-desktop-opt-in). | +| `codexDesktopAuthlessAuto?` | `boolean` | `false` | Opt-in automatic failover between authenticated and authless Desktop routing, driven by main-account Codex quota. While the 5h window reports exhausted, `codexDesktopAuthless` is engaged so independently credentialed routed models stay submittable in Codex Desktop; on recovery it is released again. Each transition rewrites `~/.codex/config.toml` and restarts Codex clients. `ocx system settings --desktop-authless-auto on`. | | `codexAccountPickerEnabled?` | `boolean` | unset | Whether generated account-qualified catalog rows (`/`) are advertised. Enabling it once fills an absent or empty `codexAccountNamespaces` with default selectors; `false` hides the generated rows without deleting exact routing bindings. An omitted flag keeps a non-empty hand-written selector map enabled. The Luna Reserve row is account-qualified, so it is written only when a selector targets the main Codex account; when it is suppressed, catalog sync logs the reason and the action that restores it. | | `codexAccountNamespaces?` | `Record` | unset | Public selector to Codex account mapping. The main login maps to the config-only sentinel `@main`; added accounts map to their account id. Normally generated by enabling `codexAccountPickerEnabled` rather than hand-authored. An empty map means no account-qualified rows exist, including the Luna Reserve row. | | `codexClientCompaction?` | `boolean` | `false` | Opt into Codex client-side compaction on an authenticated loopback bind. Uses the dedicated `opencodex` provider identity with `requires_openai_auth = true`, preventing new routed compactions from storing OpenCodeX-owned `ocx1:` state. `codexDesktopAuthless` takes precedence when both are enabled and keeps `requires_openai_auth = false`. V2 sub-agent routing is unchanged. `ocx system settings --client-compaction on`. See [Codex integration](/guides/codex-integration/#client-side-compaction-opt-in). | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 35a7e5f60ac..c455e1d2beb 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -685,6 +685,7 @@ "codex-provider-table-retention.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-desktop-authless-auto.test.ts": "codex-integration", "codex-quota-capacity.test.ts": "codex-integration", "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", diff --git a/src/cli/system-command.ts b/src/cli/system-command.ts index c9d447abaa4..d1f58ac98c3 100644 --- a/src/cli/system-command.ts +++ b/src/cli/system-command.ts @@ -15,7 +15,7 @@ import { const USAGE = `Usage: ocx system [status] [--json] ocx system settings [--auto-start ] [--stream-mode ] - [--desktop-authless ] [--client-compaction ] [--json] + [--desktop-authless ] [--desktop-authless-auto ] [--client-compaction ] [--json] ocx system startup [--json] ocx system diagnostics [--json] ocx system sync [--json] @@ -60,9 +60,11 @@ function desktopSwitchInertReason(reason: unknown): string { function settingsUpdateLines( result: unknown, - changed: { desktopAuthless: boolean; clientCompaction: boolean }, + changed: { desktopAuthless: boolean; desktopAuthlessAuto: boolean; clientCompaction: boolean }, ): string[] { - if (!changed.desktopAuthless && !changed.clientCompaction) return ["System settings updated."]; + if (!changed.desktopAuthless && !changed.desktopAuthlessAuto && !changed.clientCompaction) { + return ["System settings updated."]; + } const switches = recordValue(recordValue(result)?.codexDesktopSwitches); if (!switches) return ["System settings updated."]; @@ -98,6 +100,12 @@ function settingsUpdateLines( if (changed.clientCompaction && !appendSwitch("codexClientCompaction", "Codex client compaction")) { return ["System settings updated."]; } + if (changed.desktopAuthlessAuto) { + // No effective-state report exists for the auto switch (it only arms the quota worker), + // so the stored value is stated on its own rather than through the switch reporter. + const stored = recordValue(result)?.codexDesktopAuthlessAuto; + lines.push(`Codex desktop authless auto: stored ${stored === true ? "on" : "off"}.`); + } const apply = recordValue(switches.apply); const authSource = recordValue(switches.authSource); @@ -127,10 +135,11 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise { const autoStart = takeBooleanOption(args, "--auto-start"); const streamMode = takeOption(args, "--stream-mode"); const desktopAuthless = takeBooleanOption(args, "--desktop-authless"); + const desktopAuthlessAuto = takeBooleanOption(args, "--desktop-authless-auto"); const clientCompaction = takeBooleanOption(args, "--client-compaction"); rejectArgs(args, USAGE); if (autoStart === undefined && streamMode === undefined - && desktopAuthless === undefined && clientCompaction === undefined) { + && desktopAuthless === undefined && desktopAuthlessAuto === undefined && clientCompaction === undefined) { const result = await runtimeRequest("/api/settings", {}, deps); printData(result, wantsJson, summaryLines(result)); return; @@ -139,11 +148,13 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise { ...(autoStart !== undefined ? { codexAutoStart: autoStart } : {}), ...(streamMode !== undefined ? { streamMode } : {}), ...(desktopAuthless !== undefined ? { codexDesktopAuthless: desktopAuthless } : {}), + ...(desktopAuthlessAuto !== undefined ? { codexDesktopAuthlessAuto: desktopAuthlessAuto } : {}), ...(clientCompaction !== undefined ? { codexClientCompaction: clientCompaction } : {}), }; const result = await runtimeRequest("/api/settings", { method: "PUT", body: JSON.stringify(body) }, deps); printData(result, wantsJson, settingsUpdateLines(result, { desktopAuthless: desktopAuthless !== undefined, + desktopAuthlessAuto: desktopAuthlessAuto !== undefined, clientCompaction: clientCompaction !== undefined, })); } diff --git a/src/codex/desktop-authless-auto.ts b/src/codex/desktop-authless-auto.ts new file mode 100644 index 00000000000..5b38e095c49 --- /dev/null +++ b/src/codex/desktop-authless-auto.ts @@ -0,0 +1,145 @@ +import { mutatePersistedConfig } from "../config"; +import { registerStateSweepAfterTick } from "../lib/state-store-sweeper"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { providerCodexAccountMode } from "../providers/registry"; +import type { OcxConfig } from "../types"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { getAccountQuota, isCodexQuotaExhausted, type StoredAccountQuota } from "./quota"; + +export interface DesktopAuthlessAutoDeps { + getQuota?: (accountId: string) => StoredAccountQuota | null; + refreshQuota?: (config: OcxConfig, accountId: string) => Promise; + persistSetting?: (config: OcxConfig, enabled: boolean) => boolean; + applyInjection?: (config: OcxConfig) => Promise<{ applied: boolean }>; + restartClients?: () => Promise; +} + +export interface DesktopAuthlessAutoResult { + acted: boolean; + exhausted?: boolean; + authless?: boolean; +} + +let inFlight: Promise | null = null; + +/** Load upstream quota metadata only when a recovery actually needs a fresh read. */ +async function refreshQuota(config: OcxConfig, accountId: string): Promise { + const { refreshCodexQuotaForActivation } = await import("./auth-api"); + await refreshCodexQuotaForActivation(config, accountId); +} + +/** + * Persist the failover decision with the same shape the settings route writes: `true` sets + * the key, `false` deletes it, because absent is the documented default and a written + * `false` would survive as a decision nobody made. + */ +function persistSetting(config: OcxConfig, enabled: boolean): boolean { + try { + const outcome = mutatePersistedConfig(persisted => { + if (enabled) { + if (persisted.codexDesktopAuthless === true) return { changed: false, value: false }; + persisted.codexDesktopAuthless = true; + return { changed: true, value: true }; + } + if (persisted.codexDesktopAuthless === undefined) return { changed: false, value: false }; + delete persisted.codexDesktopAuthless; + return { changed: true, value: true }; + }); + if (outcome.status === "unavailable" || !outcome.value) return false; + if (enabled) config.codexDesktopAuthless = true; + else delete config.codexDesktopAuthless; + return true; + } catch { + return false; + } +} + +/** Rewrite ~/.codex/config.toml now rather than at the next `ocx sync`. */ +async function applyInjection(config: OcxConfig): Promise<{ applied: boolean }> { + const { applyCodexConfigInjection } = await import("./desktop-switches"); + return applyCodexConfigInjection(config); +} + +/** Restart Codex clients so the rewritten routing takes effect. Best-effort. */ +async function restartClients(): Promise { + const { performCodexRestart } = await import("./app-server-restart-service"); + return performCodexRestart(); +} + +/** + * Engage desktop-authless routing while the main Codex quota reports exhausted, and release + * it once the window recovers — the automatic form of the manual `codexDesktopAuthless` + * switch. Only transitions act: a sweep whose desired state already matches the stored + * setting is a no-op, so quota hovering near the boundary cannot flap the config. + * + * Recovery is confirmed against a forced upstream read, never the possibly stale cache: + * flipping back on stale data would re-arm the Desktop account gate while the window is + * still exhausted. Every failure path returns instead of throwing; a failover that could + * break the sweep it runs on would be worse than a missed transition, which the next sweep + * retries. + */ +export async function runDesktopAuthlessAuto( + config: OcxConfig, + deps: DesktopAuthlessAutoDeps = {}, +): Promise { + if (config.codexDesktopAuthlessAuto !== true) return { acted: false }; + // A client-role process never owns the Codex home, so it must not rewrite it. + if (config.runtimeRole === "client") return { acted: false }; + const openai = config.providers[OPENAI_CODEX_PROVIDER_ID]; + if (!openai || openai.disabled === true || !isCanonicalOpenAiForwardProvider(openai)) { + return { acted: false }; + } + if (providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return { acted: false }; + if (inFlight) return inFlight; + const quotaFor = deps.getQuota ?? getAccountQuota; + inFlight = (async (): Promise => { + const quota = quotaFor(MAIN_CODEX_ACCOUNT_ID); + const exhausted = isCodexQuotaExhausted(quota); + const stored = config.codexDesktopAuthless === true; + if (exhausted === stored) return { acted: false, exhausted, authless: stored }; + if (!exhausted) { + try { + await (deps.refreshQuota ?? refreshQuota)(config, MAIN_CODEX_ACCOUNT_ID); + } catch { + return { acted: false, exhausted: false, authless: stored }; + } + if (isCodexQuotaExhausted(quotaFor(MAIN_CODEX_ACCOUNT_ID))) { + return { acted: false, exhausted: true, authless: stored }; + } + } + const persist = deps.persistSetting ?? persistSetting; + if (!persist(config, exhausted)) { + console.warn("[desktop-authless-auto] quota transition observed but the setting could not be persisted; retry on the next sweep"); + return { acted: false, exhausted, authless: stored }; + } + try { + const injection = await (deps.applyInjection ?? applyInjection)(config); + if (!injection.applied) { + console.warn("[desktop-authless-auto] setting persisted but ~/.codex/config.toml was not rewritten; run 'ocx sync' to apply it"); + } + } catch (error) { + console.warn(`[desktop-authless-auto] config injection failed: ${error instanceof Error ? error.message : String(error)}`); + } + try { + await (deps.restartClients ?? restartClients)(); + } catch (error) { + console.warn(`[desktop-authless-auto] Codex client restart failed: ${error instanceof Error ? error.message : String(error)}; restart Codex manually to adopt the new routing`); + } + console.warn(`[desktop-authless-auto] ${exhausted ? "engaged authless routing: main Codex quota exhausted" : "released authless routing: main Codex quota recovered"}`); + return { acted: true, exhausted, authless: exhausted }; + })().finally(() => { inFlight = null; }); + return inFlight; +} + +/** Attach failover to the shared minute sweep and return its owner-scoped cleanup. */ +export function registerDesktopAuthlessAutoWorker(config: OcxConfig): () => void { + return registerStateSweepAfterTick({ + name: "desktop-authless-auto", + afterTick: () => { void runDesktopAuthlessAuto(config); }, + }); +} + +/** Clear single-flight state between isolated test cases. */ +export function resetDesktopAuthlessAutoForTests(): void { + inFlight = null; +} diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index c881a269fc1..4a71825c39c 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -220,6 +220,7 @@ export const configSchema = z.object({ ).optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), codexDesktopAuthless: z.boolean().optional().catch(undefined), + codexDesktopAuthlessAuto: z.boolean().optional().catch(undefined), codexClientCompaction: z.boolean().optional().catch(undefined), // Presentation-only label for the injected provider. A malformed value degrades to undefined // and the default label is emitted, rather than failing the parse or writing a config Codex diff --git a/src/server/index.ts b/src/server/index.ts index eadd955ea12..74959dad04b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -44,7 +44,7 @@ import { registerCodexCooldownRecoveryProbeWorker, } from "../codex/auth-api"; import { activateResetCreditAutoRedeem } from "../codex/reset-credit-auto-redeem"; -import { registerCodexQuotaAutoRefreshWorker } from "../codex/quota-auto-refresh"; +import { registerCodexSweepWorkers } from "./index/codex-sweep-workers"; import { reconcileLiveStateStores, setLiveStateStoreConfig, @@ -635,7 +635,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } const linkPolicy = (): RequestPolicyView => requestPolicyView(config, LINK_INGRESS_HOSTNAME, { allowedKeyIds: optionalListeners.linkAdmissionKeyIds() }); let backgroundLifecycle: ReturnType | null = null; - let unregisterQuotaAutoRefresh: (() => void) | null = null; + let unregisterCodexSweepWorkers: (() => void) | null = null; let remoteWorkspaceStopping = false; let remoteWorkspaceShutdown: (() => Promise) | undefined; const managementApiDeps: ManagementApiDeps = { @@ -656,8 +656,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe }; try { backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy); - unregisterQuotaAutoRefresh = (deps.registerCodexQuotaAutoRefreshWorker - ?? registerCodexQuotaAutoRefreshWorker)(config); + unregisterCodexSweepWorkers = registerCodexSweepWorkers(config, deps); // External `ocx config set` / direct config.json edits run in other // processes; poll the file so Logs/Usage display prices follow them live. // Started inside the guarded startup transaction so the catch below can @@ -737,7 +736,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe optionalListeners.start({ config, publicPort: server.port ?? listenPort, requestedPort: listenPort, maxRequestBodySize: inboundBodyLimitBytes, dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer) }); } catch (error) { - unregisterQuotaAutoRefresh?.(); + unregisterCodexSweepWorkers?.(); userCostOverlayReconciler?.stop(); backgroundLifecycle?.releaseAfterFailedStart(); void nativeMainLifecycle.release(); @@ -779,7 +778,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe try { userCostOverlayReconciler?.stop(); } finally { - unregisterQuotaAutoRefresh?.(); + unregisterCodexSweepWorkers?.(); } }, ], diff --git a/src/server/index/codex-sweep-workers.ts b/src/server/index/codex-sweep-workers.ts new file mode 100644 index 00000000000..adbdcae4dd4 --- /dev/null +++ b/src/server/index/codex-sweep-workers.ts @@ -0,0 +1,24 @@ +import { registerCodexQuotaAutoRefreshWorker } from "../../codex/quota-auto-refresh"; +import { registerDesktopAuthlessAutoWorker } from "../../codex/desktop-authless-auto"; +import type { OcxConfig } from "../../types"; +import type { StartServerDeps } from "./startup-warnings"; + +/** + * Register the Codex minute-sweep workers behind one cleanup. + * + * The sweep workers grow as a pair (quota-window activation, desktop-authless failover) while + * `src/server/index.ts` sits exactly at its file-size cap, so the combined registration lives + * here: the composition root keeps one call site and one cleanup instead of one per worker. + */ +export function registerCodexSweepWorkers( + config: OcxConfig, + deps: Pick, +): () => void { + const cleanups = [ + (deps.registerCodexQuotaAutoRefreshWorker ?? registerCodexQuotaAutoRefreshWorker)(config), + (deps.registerDesktopAuthlessAutoWorker ?? registerDesktopAuthlessAutoWorker)(config), + ]; + return () => { + for (const cleanup of cleanups) cleanup(); + }; +} diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 2e395a688ea..d3349e5476a 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -160,6 +160,8 @@ export interface StartServerDeps { packageTreeServiceHomeOwned?: () => boolean; /** Test-only seam for observing quota-worker registration ownership. */ registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker; + /** Test-only seam for observing authless-failover worker registration ownership. */ + registerDesktopAuthlessAutoWorker?: typeof import("../../codex/desktop-authless-auto").registerDesktopAuthlessAutoWorker; } export function inspectStartupOwnership( diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 9682b062649..3e5c9aa20b9 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -453,6 +453,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise = {}): OcxConfig { + return { + codexDesktopAuthlessAuto: true, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + ...overrides, + }; +} + +function quota(overrides: Partial = {}): StoredAccountQuota { + return { updatedAt: NOW, shortPercent: 100, weeklyPercent: 20, ...overrides }; +} + +interface SpyDeps extends Required> { + calls: string[]; +} + +function spies(current: { quota: StoredAccountQuota | null }): SpyDeps & DesktopAuthlessAutoDeps { + const calls: string[] = []; + return { + calls, + getQuota: (accountId: string) => { + expect(accountId).toBe(MAIN_CODEX_ACCOUNT_ID); + return current.quota; + }, + refreshQuota: async () => { calls.push("refresh"); }, + persistSetting: (cfg, enabled) => { + calls.push(`persist:${enabled}`); + if (enabled) cfg.codexDesktopAuthless = true; + else delete cfg.codexDesktopAuthless; + return true; + }, + applyInjection: async () => { calls.push("apply"); return { applied: true }; }, + restartClients: async () => { calls.push("restart"); }, + }; +} + +beforeEach(() => { resetDesktopAuthlessAutoForTests(); }); + +describe("desktop authless auto failover", () => { + test("does nothing when the auto setting is off", async () => { + const cfg = config({ codexDesktopAuthlessAuto: undefined }); + const deps = spies({ quota: quota() }); + const result = await runDesktopAuthlessAuto(cfg, deps); + expect(result.acted).toBe(false); + expect(deps.calls).toEqual([]); + expect(cfg.codexDesktopAuthless).toBeUndefined(); + }); + + test("engages authless routing when the main quota is exhausted", async () => { + const cfg = config(); + const deps = spies({ quota: quota() }); + const result = await runDesktopAuthlessAuto(cfg, deps); + expect(result).toEqual({ acted: true, exhausted: true, authless: true }); + expect(deps.calls).toEqual(["persist:true", "apply", "restart"]); + expect(cfg.codexDesktopAuthless).toBe(true); + }); + + test("stays engaged without acting while exhaustion persists", async () => { + const cfg = config({ codexDesktopAuthless: true }); + const deps = spies({ quota: quota() }); + const result = await runDesktopAuthlessAuto(cfg, deps); + expect(result).toEqual({ acted: false, exhausted: true, authless: true }); + expect(deps.calls).toEqual([]); + }); + + test("releases authless routing once a forced refresh confirms recovery", async () => { + const cfg = config({ codexDesktopAuthless: true }); + const current = { quota: quota({ shortPercent: 12 }) as StoredAccountQuota | null }; + const deps = spies(current); + deps.refreshQuota = async () => { + deps.calls.push("refresh"); + current.quota = quota({ shortPercent: 12, weeklyPercent: 20 }); + }; + const result = await runDesktopAuthlessAuto(cfg, deps); + expect(result).toEqual({ acted: true, exhausted: false, authless: false }); + expect(deps.calls).toEqual(["refresh", "persist:false", "apply", "restart"]); + expect(cfg.codexDesktopAuthless).toBeUndefined(); + }); + + test("stays engaged when the confirmation refresh still reports exhaustion", async () => { + const cfg = config({ codexDesktopAuthless: true }); + // Cached snapshot looks recovered, but the forced upstream read disagrees. + const current = { quota: quota({ shortPercent: 12 }) as StoredAccountQuota | null }; + const deps = spies(current); + deps.refreshQuota = async () => { + deps.calls.push("refresh"); + current.quota = quota(); + }; + const result = await runDesktopAuthlessAuto(cfg, deps); + expect(result.acted).toBe(false); + expect(deps.calls).toEqual(["refresh"]); + expect(cfg.codexDesktopAuthless).toBe(true); + }); + + test("skips non-pool account modes and client-role processes", async () => { + const direct = config(); + direct.providers.openai = { ...direct.providers.openai, codexAccountMode: "direct" }; + const directDeps = spies({ quota: quota() }); + expect((await runDesktopAuthlessAuto(direct, directDeps)).acted).toBe(false); + expect(directDeps.calls).toEqual([]); + + const client = config({ runtimeRole: "client" }); + const clientDeps = spies({ quota: quota() }); + expect((await runDesktopAuthlessAuto(client, clientDeps)).acted).toBe(false); + expect(clientDeps.calls).toEqual([]); + }); + + test("a failed apply or restart still reports the persisted transition", async () => { + const cfg = config(); + const deps = spies({ quota: quota() }); + deps.applyInjection = async () => { deps.calls.push("apply"); throw new Error("injector busy"); }; + deps.restartClients = async () => { deps.calls.push("restart"); throw new Error("no clients"); }; + const result = await runDesktopAuthlessAuto(cfg, deps); + expect(result.acted).toBe(true); + expect(cfg.codexDesktopAuthless).toBe(true); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fdb563d16f1..7e1fca34b20 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -511,6 +511,7 @@ "codex-provider-table-retention.test.ts": "codex-integration", "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-desktop-authless-auto.test.ts": "codex-integration", "codex-quota-capacity.test.ts": "codex-integration", "codex-quota-history.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index 7cbbc315b5c..2ad788d3cfb 100644 --- a/tests/lab/core-lab-boundary.test.ts +++ b/tests/lab/core-lab-boundary.test.ts @@ -1057,7 +1057,7 @@ describe("activation window stays synchronous", () => { * function is the hole this list exists to keep visible. */ const UNRESOLVED_CALLEES: Record = { - unregisterQuotaAutoRefresh: "let-binding holding the return of registerCodexQuotaAutoRefreshWorker, optional-called on the bind-failure path. There is no `function unregisterQuotaAutoRefresh` to inspect; following the assignment would be depth 2.", + unregisterCodexSweepWorkers: "let-binding holding the combined cleanup of registerCodexSweepWorkers (quota-window activation plus desktop-authless failover), optional-called on the bind-failure path. There is no `function unregisterCodexSweepWorkers` to inspect; following the assignment would be depth 2.", };