Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Stable Codex Runtime Selection Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans or implement task-by-task with TDD.

**Goal:** One canonical Codex runtime resolver shared by sync, clamp, startup probes, and CLI diagnostics so OpenCodex never silently clamps against an older binary than the one users run.

**Architecture:** New `src/codex/runtime.ts` owns resolve/persist/diagnose. `bundled.ts` / `effort.ts` / `v2.ts` / sync logging / status / doctor / dashboard consume it. Persist selection in `getConfigDir()/codex-runtime.json`.

**Tech Stack:** TypeScript, existing `codexExecInvocation`, Bun tests.

## Global Constraints

- Selection order: environment → configured → shim → path → fallback
- Validate with `--version` (and reject missing/stale)
- Do not silently downgrade from a persisted valid runtime
- Keep global effort clamp; no routed-model exemption
- PR base: `dev`

---

### Task 1: Resolver + persistence (TDD)
- Create `src/codex/runtime.ts` + `tests/codex-runtime.test.ts`
- Types `CodexRuntimeSource`, `ResolvedCodexRuntime`, clamp diagnostic
- Persist/load configured runtime; reject invalid; warn on older fallback

### Task 2: Wire catalog + clamp + v2
- `loadBundledCodexCatalog` / `codexSupportedReasoningEfforts` / `codexFeaturesInvocation` use resolved runtime command
- Sync logs one clamp summary with `EffortClampDiagnostic`

### Task 3: status / doctor / dashboard
- `ocx status` runtime + clamp fields
- `ocx doctor` multi-install / stale / mismatch checks + optional `--fix-codex-runtime`
- Dashboard warning when clamp removed efforts (management API field)

### Task 4: Verify + PR to dev
- targeted tests, typecheck, open PR
47 changes: 47 additions & 0 deletions gui/src/pages/Startup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export default function Startup({ apiBase }: { apiBase: string }) {
const [trayError, setTrayError] = useState(false);
const [installBusy, setInstallBusy] = useState<StartupInstallAction | null>(null);
const [installResult, setInstallResult] = useState<{ kind: "success" | "error"; action: StartupInstallAction; detail?: string } | null>(null);
const [codexRuntimeWarning, setCodexRuntimeWarning] = useState<string | null>(null);
const [codexRuntimeFix, setCodexRuntimeFix] = useState<string | null>(null);

const refresh = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
Expand All @@ -99,6 +101,31 @@ export default function Startup({ apiBase }: { apiBase: string }) {
if (signal?.aborted) return;
setData(next);
setFailed(next.diagnosticStale);
try {
const settingsRes = await fetch(`${apiBase}/api/settings`, { signal });
if (settingsRes.ok) {
const settings = await settingsRes.json() as {
codexRuntime?: {
warning?: string | null;
newerAvailable?: { path?: string } | null;
catalogClamp?: { active?: boolean; removedEfforts?: string[] };
};
};
if (!signal?.aborted) {
setCodexRuntimeWarning(settings.codexRuntime?.warning ?? null);
setCodexRuntimeFix(
settings.codexRuntime?.newerAvailable || settings.codexRuntime?.catalogClamp?.active
? "ocx doctor --fix-codex-runtime"
: null,
Comment thread
Wibias marked this conversation as resolved.
Resolved
);
}
}
Comment thread
Wibias marked this conversation as resolved.
Resolved
} catch {
if (!signal?.aborted) {
setCodexRuntimeWarning(null);
setCodexRuntimeFix(null);
}
}
if (next.platform === "win32") {
setTrayError(false);
try {
Expand Down Expand Up @@ -233,6 +260,26 @@ export default function Startup({ apiBase }: { apiBase: string }) {
) : data ? (
<>
{failed && <div className="notice notice-warn" role="alert">{t("startup.staleData")}</div>}
{codexRuntimeWarning && (
<div className="notice notice-warn" role="status">
<p>{codexRuntimeWarning}</p>
{codexRuntimeFix && (
<p>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
void navigator.clipboard.writeText(codexRuntimeFix);
setCopied(codexRuntimeFix);
}}
>
{copied === codexRuntimeFix ? "Copied" : "Copy fix command"}
Comment thread
Wibias marked this conversation as resolved.
Resolved
</button>
<code style={{ marginLeft: "0.5rem" }}>{codexRuntimeFix}</code>
Comment thread
Wibias marked this conversation as resolved.
Resolved
</p>
)}
</div>
)}
<section className={`panel startup-hero ${statusClass}`} aria-live="polite">
<div className="startup-hero-icon"><StatusIcon /></div>
<div className="startup-hero-copy">
Expand Down
56 changes: 55 additions & 1 deletion src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
import { countPendingOpencodexHistory } from "../codex/history-provider";
import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings";
import { collectStartupHealth, startupHealthSummary } from "../codex/autostart-health";
import {
displayCodexRuntimePath,
loadLastEffortClamp,
persistCodexRuntime,
resolveAndPersistCodexRuntime,
resolveCodexRuntime,
} from "../codex/runtime";
export { resolveCodexHomeDir } from "../codex/home";

const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
Expand Down Expand Up @@ -434,7 +441,25 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
return lines;
}

export async function runDoctor(): Promise<void> {
export async function runDoctor(args: string[] = []): Promise<void> {
if (args.includes("--fix-codex-runtime")) {
const resolved = resolveCodexRuntime();
if (!resolved.newerAvailable) {
console.log("No newer Codex runtime found; keeping current selection.");
const current = resolveAndPersistCodexRuntime();
console.log(`Selected: ${displayCodexRuntimePath(current.runtime.command)} (${current.runtime.version ?? "unknown"})`);
return;
}
persistCodexRuntime({
command: resolved.newerAvailable.command,
version: resolved.newerAvailable.version,
source: "configured",
Comment thread
Wibias marked this conversation as resolved.
Resolved
});
console.log(`Updated Codex runtime to ${displayCodexRuntimePath(resolved.newerAvailable.command)} (${resolved.newerAvailable.version ?? "unknown"}).`);
console.log("Run ocx sync to refresh the catalog against this runtime.");
Comment thread
Wibias marked this conversation as resolved.
Resolved
return;
}

console.log("opencodex doctor\n");

// Ordering note: the memory/runtime section renders after "Running proxy
Expand All @@ -455,6 +480,35 @@ export async function runDoctor(): Promise<void> {
console.log(` ${startup.rebootSafe ? "ok " : "!! "} ${startupHealthSummary(startup)}`);
console.log(` routing=${startup.routingKind}, service=${startup.serviceViable ? "viable" : startup.serviceInstalled ? "installed-but-unhealthy" : "absent"}, shim=${startup.shimHealthy ? "healthy" : startup.shimInstalled ? "stale" : "absent"}`);

console.log("\nCodex runtime selection");
{
const resolved = resolveCodexRuntime();
const selected = resolved.runtime;
console.log(` ok Selected runtime: ${displayCodexRuntimePath(selected.command)} (${selected.version ?? "unknown"}, source=${selected.source})`);
const envFailures = resolved.failures.filter(item => item.source === "environment");
for (const failure of envFailures) {
console.log(` !! Invalid CODEX_CLI_PATH: ${failure.reason}`);
}
const shimFailures = resolved.failures.filter(item => item.source === "shim");
if (shimFailures.length > 0) {
console.log(` !! Stale shim target rejected (${shimFailures.length})`);
}
if (resolved.replacedConfigured) {
console.log(` !! Preferred runtime unavailable; fell back to ${displayCodexRuntimePath(selected.command)}`);
}
if (resolved.newerAvailable) {
console.log(` !! Multiple Codex installations found.`);
console.log(` ok Newer usable runtime found: ${displayCodexRuntimePath(resolved.newerAvailable.command)} (${resolved.newerAvailable.version ?? "unknown"})`);
console.log(" Suggested: set CODEX_CLI_PATH to the desired binary and run ocx sync.");
console.log(" Optional: ocx doctor --fix-codex-runtime");
}
const lastClamp = loadLastEffortClamp();
if (lastClamp && lastClamp.removedEfforts.length > 0) {
console.log(` !! ${lastClamp.removedEfforts.join(" and ")} were removed during catalog sync.`);
console.log(" Suggested: set CODEX_CLI_PATH to a newer Codex binary and run ocx sync.");
}
}

const currentProxyEnv = collectProxyEnv();
const configuredProxy = collectConfiguredProxy();
const runningProxyEnv = collectRunningProxyEnv();
Expand Down
12 changes: 11 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,16 @@ async function handleStatus() {
console.log(` Restart safety: ${startupHealthSummary(status.json.startup)}`);
console.log(` Service: ${status.json.service.summary}`);
console.log(` ${status.json.codexShim.summary}`);
console.log(` Codex runtime: ${status.json.codexRuntime.path}`);
console.log(` Codex version: ${status.json.codexRuntime.version ?? "unknown"}`);
console.log(` Codex source: ${status.json.codexRuntime.source}`);
console.log(` Catalog clamp: ${status.json.codexRuntime.catalogClamp.active ? "active" : "inactive"}`);
if (status.json.codexRuntime.catalogClamp.removedEfforts.length > 0) {
console.log(` Removed efforts: ${status.json.codexRuntime.catalogClamp.removedEfforts.join(", ")}`);
}
if (status.json.codexRuntime.warning) {
console.log(` ⚠️ ${status.json.codexRuntime.warning}`);
}
if (status.json.codexPlugins.applicable) {
const icon = status.json.codexPlugins.stale ? "⚠️ " : "✅";
console.log(` ${icon} Codex bundled plugins: ${status.json.codexPlugins.summary}`);
Expand Down Expand Up @@ -581,7 +591,7 @@ switch (command) {
break;
case "doctor": {
const { runDoctor } = await import("./doctor");
await runDoctor();
await runDoctor(args.slice(1));
break;
}
case "debug": {
Expand Down
41 changes: 41 additions & 0 deletions src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { diagnoseService } from "../service";
import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
import { getCodexRoutingKind } from "../codex/inject";
import { diagnoseCodexShim } from "../codex/shim";
import { displayCodexRuntimePath, loadLastEffortClamp, resolveCodexRuntime } from "../codex/runtime";

type HealthCheck = {
ok: boolean;
Expand Down Expand Up @@ -51,6 +52,17 @@ export type CliStatusJson = {
service: { summary: string };
codexShim: { summary: string };
codexPlugins: CodexPluginsDiagnostic;
codexRuntime: {
path: string;
version: string | null;
source: string;
newerAvailable: { path: string; version: string | null } | null;
warning: string | null;
catalogClamp: {
active: boolean;
removedEfforts: string[];
};
};
};

export type CliStatusView = {
Expand Down Expand Up @@ -129,6 +141,34 @@ export async function collectStatus(): Promise<CliStatusView> {
routingKind: getCodexRoutingKind(),
});
const codexPlugins = diagnoseCodexBundledPlugins();
const resolvedRuntime = resolveCodexRuntime();
const lastClamp = loadLastEffortClamp();
const clampActive = Boolean(lastClamp && lastClamp.removedEfforts.length > 0);
const warningParts: string[] = [];
if (resolvedRuntime.newerAvailable) {
warningParts.push("OpenCodex is using an older Codex binary. Run ocx doctor for diagnosis and recovery.");
}
if (clampActive) {
warningParts.push(
`Catalog clamp removed: ${lastClamp!.removedEfforts.join(", ")}. Run ocx doctor for diagnosis and recovery.`,
);
}
const codexRuntime = {
path: displayCodexRuntimePath(resolvedRuntime.runtime.command),
version: resolvedRuntime.runtime.version,
source: resolvedRuntime.runtime.source,
newerAvailable: resolvedRuntime.newerAvailable
? {
path: displayCodexRuntimePath(resolvedRuntime.newerAvailable.command),
version: resolvedRuntime.newerAvailable.version,
}
: null,
warning: warningParts.length > 0 ? warningParts.join(" ") : null,
catalogClamp: {
active: clampActive,
removedEfforts: lastClamp?.removedEfforts ?? [],
},
};
const proxyLabel = pid && health.ok
? `running (PID ${pid})`
: pid
Expand Down Expand Up @@ -176,6 +216,7 @@ export async function collectStatus(): Promise<CliStatusView> {
service: { summary: serviceSummary },
codexShim: { summary: codexShimSummary },
codexPlugins,
codexRuntime,
},
};
}
9 changes: 8 additions & 1 deletion src/cli/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, trans

import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
import { loadConfig, saveConfig } from "../config";
import { resolveAndPersistCodexRuntime } from "../codex/runtime";

export interface V2CliDeps {
execFile?: (file: string, args: string[], options?: SpawnInvocation["options"]) => void;
Expand All @@ -35,7 +36,13 @@ export function codexFeaturesInvocation(
platform: NodeJS.Platform = process.platform,
deps: Parameters<typeof commandInvocation>[3] = {},
): SpawnInvocation {
const command = (deps.env ?? process.env).CODEX_CLI_PATH?.trim() || "codex";
const envPath = (deps.env ?? process.env).CODEX_CLI_PATH?.trim();
const command = envPath
|| resolveAndPersistCodexRuntime({
env: deps.env ?? process.env,
platform,
}).runtime.command
|| "codex";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return commandInvocation(command, ["features", action, "multi_agent_v2"], platform, deps);
}

Expand Down
32 changes: 15 additions & 17 deletions src/codex/catalog/bundled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";

import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing";
import type { RawCatalog, RawEntry } from "./parsing";
import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation";
import { resolveAndPersistCodexRuntime } from "../runtime";
import type { EffortClampDiagnostic } from "../runtime";

export { isSpawnableCodexCandidate, codexExecInvocation } from "../exec-invocation";

export const BUNDLED_CATALOG_CACHE_MS = 60_000;

Expand All @@ -58,6 +63,7 @@ export type ExecFile = (
export interface BundledCatalogDeps {
commandCandidates?: () => string[];
execFileSync?: ExecFile;
onEffortClamp?: (diagnostic: EffortClampDiagnostic) => void;
}

export function unique(values: string[]): string[] {
Expand All @@ -77,11 +83,6 @@ export function codexCommandCandidates(): string[] {
return unique(candidates);
}

export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
if (platform !== "win32") return true;
return /\.(cmd|bat|exe|com)$/i.test(path);
}

export function codexShimCommandCandidates(): string[] {
try {
const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
Expand All @@ -105,16 +106,6 @@ export function codexShimCommandCandidates(): string[] {
}
}

export function codexExecInvocation(
command: string,
platform: NodeJS.Platform = process.platform,
): { file: string; shell: boolean } {
if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
return { file: `"${command.replace(/"/g, "")}"`, shell: true };
}
return { file: command, shell: false };
}

export function runCodexDebugModels(command: string, execFile: ExecFile): string {
const args = ["debug", "models", "--bundled"];
const invocation = codexExecInvocation(command);
Expand All @@ -132,9 +123,16 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal
if (useCache && bundledCatalogCache && bundledCatalogCache.expiresAt > Date.now()) {
return bundledCatalogCache.value;
}
const candidates = deps.commandCandidates?.() ?? codexCommandCandidates();
const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
for (const command of candidates) {
// Prefer the single resolved runtime so sync/clamp never probe a different binary
// than OpenCodex will launch. Tests may inject commandCandidates to stub probing.
const candidates = deps.commandCandidates?.() ?? (() => {
const resolved = resolveAndPersistCodexRuntime({
execFileSync: execFile as never,
});
return [resolved.runtime.command];
})();
Comment thread
Wibias marked this conversation as resolved.
Resolved
for (const command of unique(candidates)) {
try {
const catalog = parseCatalogJson(runCodexDebugModels(command, execFile));
if (catalog && findNativeTemplate(catalog)) {
Expand Down
Loading
Loading