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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<selector>/<model>`) 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<string, string>` | 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). |
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 15 additions & 4 deletions src/cli/system-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
const USAGE = `Usage:
ocx system [status] [--json]
ocx system settings [--auto-start <on|off>] [--stream-mode <auto|legacy-tee|eager-relay>]
[--desktop-authless <on|off>] [--client-compaction <on|off>] [--json]
[--desktop-authless <on|off>] [--desktop-authless-auto <on|off>] [--client-compaction <on|off>] [--json]
ocx system startup <health|install-service|install-shim> [--json]
ocx system diagnostics [--json]
ocx system sync [--json]
Expand Down Expand Up @@ -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."];

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -127,10 +135,11 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
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;
Expand All @@ -139,11 +148,13 @@ async function settings(argv: string[], deps: RuntimeApiDeps): Promise<void> {
...(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,
}));
}
Expand Down
145 changes: 145 additions & 0 deletions src/codex/desktop-authless-auto.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
persistSetting?: (config: OcxConfig, enabled: boolean) => boolean;
applyInjection?: (config: OcxConfig) => Promise<{ applied: boolean }>;
restartClients?: () => Promise<unknown>;
}

export interface DesktopAuthlessAutoResult {
acted: boolean;
exhausted?: boolean;
authless?: boolean;
}

let inFlight: Promise<DesktopAuthlessAutoResult> | null = null;

/** Load upstream quota metadata only when a recovery actually needs a fresh read. */
async function refreshQuota(config: OcxConfig, accountId: string): Promise<void> {
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<unknown> {
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<DesktopAuthlessAutoResult> {
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<DesktopAuthlessAutoResult> => {
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;
}
1 change: 1 addition & 0 deletions src/config/schema/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof acquireServerBackgroundLifecycle> | null = null;
let unregisterQuotaAutoRefresh: (() => void) | null = null;
let unregisterCodexSweepWorkers: (() => void) | null = null;
let remoteWorkspaceStopping = false;
let remoteWorkspaceShutdown: (() => Promise<void>) | undefined;
const managementApiDeps: ManagementApiDeps = {
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -779,7 +778,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
try {
userCostOverlayReconciler?.stop();
} finally {
unregisterQuotaAutoRefresh?.();
unregisterCodexSweepWorkers?.();
}
},
],
Expand Down
24 changes: 24 additions & 0 deletions src/server/index/codex-sweep-workers.ts
Original file line number Diff line number Diff line change
@@ -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<StartServerDeps, "registerCodexQuotaAutoRefreshWorker" | "registerDesktopAuthlessAutoWorker">,
): () => void {
const cleanups = [
(deps.registerCodexQuotaAutoRefreshWorker ?? registerCodexQuotaAutoRefreshWorker)(config),
(deps.registerDesktopAuthlessAutoWorker ?? registerDesktopAuthlessAutoWorker)(config),
];
return () => {
for (const cleanup of cleanups) cleanup();
};
}
2 changes: 2 additions & 0 deletions src/server/index/startup-warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading