From c737ca35692c9e356463df2ca3c23c7e198df441 Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:22:53 +0800 Subject: [PATCH 01/12] fix(codex): read base prompt source on Windows --- src/codex/prompt-text-probe.ts | 366 +++++++++++++++--- src/codex/runtime.ts | 22 +- src/service.ts | 5 +- .../codex-prompt-text-probe.test.ts | 96 +++++ tests/codex-integration/codex-runtime.test.ts | 22 +- tests/service/service.test.ts | 18 +- 6 files changed, 477 insertions(+), 52 deletions(-) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index d0926a061e5..13a01bee58a 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -8,8 +8,9 @@ * * What this does NOT cover, stated rather than implied: * - * - `base-instructions` is absent. `prompt_debug.rs` returns `prompt.input` and - * discards `base_instructions`, so the base prompt never appears here. + * - `base-instructions` is absent from `prompt.input`. `prompt_debug.rs` discards + * `base_instructions`, so the base prompt is read separately from the selected + * model catalog below rather than being guessed from the debug output. * - World-state sections are DIFF-rendered (`add_section` registers state, it does * not emit text). A section that renders nothing on a first turn is missing * from this output even though its layer exists. @@ -17,10 +18,14 @@ * universal prompt. */ import { spawn } from "node:child_process"; -import { existsSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { expandUserPath } from "../config"; +import { readCodexCatalogPathForHome, readCatalog, type RawEntry } from "./catalog/parsing"; +import { codexExecInvocation } from "./exec-invocation"; import { resolveCodexHomeDir } from "./home"; +import { readRootTomlString } from "./paths"; +import { resolveCodexRuntime, type CodexRuntimeSource } from "./runtime"; /** * Layer id -> the tag Codex actually renders it under. @@ -75,26 +80,178 @@ export interface LayerText { /** Why the text is absent, when it is. */ reason: "ok" | "empty-source" | "not-rendered" | "not-exposed" | "unavailable"; bytes: number; + /** `expanded` is model-visible text; `template` still contains expansion placeholders. */ + representation?: "expanded" | "template"; /** For `empty-source`: the file that exists but has nothing in it. */ sourcePath?: string; } +export type PromptProbeFailureKind = + | "program-not-found" + | "command-unsupported" + | "execution-failed" + | "output-invalid"; + +export interface PromptProbeFailure { + kind: PromptProbeFailureKind; + command: string; + detail: string; +} + +export interface BasePromptText { + text: string | null; + reason: + | "ok" + | "model-not-selected" + | "model-not-found" + | "catalog-not-found" + | "catalog-unreadable" + | "not-published" + | "override-not-found" + | "override-unreadable"; + bytes: number; + model: string | null; + modelSource: string; + sourcePath: string | null; + representation: "expanded" | "template" | "unavailable"; + catalogVersion: string | null; + effectiveSourcePath: string | null; + effectiveSourceKind: "catalog-default" | "model-instructions-file"; + effectiveTextAvailable: boolean; +} + export interface PromptTextProbe { ok: boolean; /** The Codex home the probe reported on. */ codexHome: string; layers: Record; + base: BasePromptText; + runtime?: { command: string; version: string | null; source: CodexRuntimeSource }; + failure?: PromptProbeFailure; detail?: string; } -function resolveCodexBinary(): string | null { - const candidates = [ - join(homedir(), ".codex/packages/standalone/current/bin/codex"), - join(homedir(), ".local/bin/codex"), - "/usr/local/bin/codex", - "/opt/homebrew/bin/codex", - ]; - return candidates.find(path => existsSync(path)) ?? null; +function promptLayerForBase(base: BasePromptText): LayerText { + return { + text: base.text, + reason: base.reason === "ok" ? "ok" : "unavailable", + bytes: base.bytes, + representation: base.representation === "unavailable" ? undefined : base.representation, + ...(base.sourcePath ? { sourcePath: base.sourcePath } : {}), + }; +} + +function entryText(entry: RawEntry | null, key: string): string | null { + const value = entry?.[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function readBasePrompt(codexHome: string): BasePromptText { + const configPath = join(codexHome, "config.toml"); + let configText: string; + try { + configText = readFileSync(configPath, "utf8"); + } catch { + return { + text: null, + reason: "model-not-selected", + bytes: 0, + model: null, + modelSource: configPath, + sourcePath: null, + representation: "unavailable", + catalogVersion: null, + effectiveSourcePath: null, + effectiveSourceKind: "catalog-default", + effectiveTextAvailable: false, + }; + } + + const model = readRootTomlString(configText, "model"); + const catalogPath = readCodexCatalogPathForHome(codexHome); + const catalog = readCatalog(catalogPath); + const catalogVersion = typeof catalog?.client_version === "string" ? catalog.client_version : null; + const unavailable = ( + reason: BasePromptText["reason"], + sourcePath: string | null = null, + effectiveSourcePath: string | null = sourcePath, + effectiveSourceKind: BasePromptText["effectiveSourceKind"] = "catalog-default", + ): BasePromptText => ({ + text: null, + reason, + bytes: 0, + model, + modelSource: configPath, + sourcePath, + representation: "unavailable", + catalogVersion, + effectiveSourcePath, + effectiveSourceKind, + effectiveTextAvailable: false, + }); + + if (!model) return unavailable("model-not-selected"); + + const configuredOverride = readRootTomlString(configText, "model_instructions_file"); + if (configuredOverride) { + let overridePath: string; + try { + overridePath = resolve(dirname(configPath), expandUserPath(configuredOverride)); + } catch { + return unavailable("override-unreadable", configuredOverride, configuredOverride, "model-instructions-file"); + } + try { + const text = readFileSync(overridePath, "utf8"); + return { + text, + reason: "ok", + bytes: Buffer.byteLength(text, "utf8"), + model, + modelSource: configPath, + sourcePath: overridePath, + representation: "expanded", + catalogVersion, + effectiveSourcePath: overridePath, + effectiveSourceKind: "model-instructions-file", + effectiveTextAvailable: true, + }; + } catch { + return unavailable( + existsSync(overridePath) ? "override-unreadable" : "override-not-found", + overridePath, + overridePath, + "model-instructions-file", + ); + } + } + + if (!catalog) return unavailable(existsSync(catalogPath) ? "catalog-unreadable" : "catalog-not-found", catalogPath); + const entry = catalog.models?.find(candidate => candidate.slug === model || candidate.id === model) ?? null; + if (!entry) return unavailable("model-not-found", catalogPath); + const topLevel = entryText(entry, "base_instructions"); + const modelMessages = entry.model_messages; + const template = modelMessages && typeof modelMessages === "object" && !Array.isArray(modelMessages) + ? entryText(modelMessages as RawEntry, "instructions_template") + : null; + const text = topLevel ?? template; + if (!text) return unavailable("not-published", catalogPath); + // `base_instructions` is the catalog's model-visible field. The nested + // `instructions_template` is explicitly a template, even when it currently + // has no placeholders. + const representation = topLevel ? "expanded" : "template"; + return { + text, + reason: "ok", + bytes: Buffer.byteLength(text, "utf8"), + model, + modelSource: configPath, + sourcePath: catalogPath, + representation, + catalogVersion, + effectiveSourcePath: catalogPath, + effectiveSourceKind: "catalog-default", + effectiveTextAvailable: true, + }; } /** 8 MiB is far above any real prompt and far below anything that hurts the server. */ @@ -111,7 +268,7 @@ interface ProbeCommand { interface PromptProbeFlight { key: string; controller: AbortController; - result: Promise; + result: Promise; closed: Promise; waiters: number; joinable: boolean; @@ -120,13 +277,18 @@ interface PromptProbeFlight { } interface PromptProbeExecution { - result: Promise; + result: Promise; closed: Promise; } +interface PromptProbeExecutionResult { + raw: string | null; + failure: PromptProbeFailure | null; +} + type SharedPromptProbeOutcome = | { kind: "output"; raw: string } - | { kind: "failed" } + | { kind: "failed"; failure?: PromptProbeFailure } | { kind: "busy" }; let activePromptProbe: PromptProbeFlight | null = null; @@ -144,24 +306,67 @@ function commandKey(command: ProbeCommand): string { ]); } -function completedExecution(value: string | null): PromptProbeExecution { +function completedExecution(value: PromptProbeExecutionResult | null): PromptProbeExecution { return { result: Promise.resolve(value), closed: Promise.resolve() }; } +function commandDescription(command: ProbeCommand): string { + return [command.binary, ...command.args].join(" "); +} + +function errorDescription(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).trim().slice(0, 512); +} + +function executionFailure( + command: ProbeCommand, + detail: string, + kind: PromptProbeFailureKind = "execution-failed", +): PromptProbeFailure { + return { kind, command: commandDescription(command), detail: detail || "unknown process error" }; +} + +function classifyProcessFailure(command: ProbeCommand, code: number | null, stderr: string): PromptProbeFailure { + const detail = stderr.trim().slice(0, 512) || `process exited with code ${code ?? "unknown"}`; + const lower = detail.toLowerCase(); + const unsupported = code === 2 && ( + (/unknown|unrecognized|unexpected|invalid/.test(lower) && /command|subcommand|argument|option|prompt-input|debug/.test(lower)) + || /usage:/.test(lower) + ); + return executionFailure(command, detail, unsupported ? "command-unsupported" : "execution-failed"); +} + +function classifyRuntimeFailure(runtime: ReturnType): PromptProbeFailure { + const detail = runtime.failures.length > 0 + ? runtime.failures.map(item => `${item.source}: ${item.reason}`).join("; ").slice(0, 512) + : "no usable Codex runtime was found"; + const lower = detail.toLowerCase(); + const kind = /not a spawnable|unrecognized --version output/.test(lower) + ? "command-unsupported" + : /failed --version|probe sandbox unavailable/.test(lower) + ? "execution-failed" + : "program-not-found"; + return executionFailure( + { binary: runtime.runtime.command, args: [], cwd: "", timeoutMs: 0, promptStateFingerprint: null }, + detail, + kind, + ); +} + function runProbe( command: ProbeCommand, signal: AbortSignal, onStopping: () => void, ): PromptProbeExecution { - if (signal.aborted) return completedExecution(null); - let resolveResult!: (value: string | null) => void; + if (signal.aborted) return completedExecution({ raw: null, failure: null }); + let resolveResult!: (value: PromptProbeExecutionResult | null) => void; let resolveClosed!: () => void; - const result = new Promise(resolve => { resolveResult = resolve; }); + const result = new Promise(resolve => { resolveResult = resolve; }); const closed = new Promise(resolve => { resolveClosed = resolve; }); let resultSettled = false; let closeSettled = false; - const finishResult = (value: string | null) => { + const finishResult = (value: PromptProbeExecutionResult | null) => { if (resultSettled) return; resultSettled = true; resolveResult(value); @@ -179,22 +384,32 @@ function runProbe( let child: ReturnType; try { if (probeCommandForTests) probeSpawnAttemptsForTests += 1; - child = spawn(command.binary, command.args, { + const invocation = codexExecInvocation(command.binary, command.args); + child = spawn(invocation.file, invocation.args, { cwd: command.cwd, - stdio: ["ignore", "pipe", "ignore"], + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + ...invocation.options, + env: { ...process.env, CODEX_HOME: command.cwd }, + }); + } catch (error) { + const detail = errorDescription(error); + finishResult({ + raw: null, + failure: executionFailure(command, detail, /ENOENT|not found/i.test(detail) ? "program-not-found" : "execution-failed"), }); - } catch { - finishResult(null); finishClosed(); return { result, closed }; } const chunks: Buffer[] = []; + const errorChunks: Buffer[] = []; let size = 0; let settled = false; let stopping = false; + let stoppingFailure: PromptProbeFailure | null = null; let timer: ReturnType | undefined; - const finish = (value: string | null) => { + const finish = (value: PromptProbeExecutionResult) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); @@ -205,9 +420,10 @@ function runProbe( // Keep the flight admitted until `close`: kill() only requests termination // and does not prove the exact child has released its process and stdio. - const terminate = () => { + const terminate = (failure = executionFailure(command, "probe process was terminated")) => { if (settled || stopping) return; stopping = true; + stoppingFailure = failure; onStopping(); if (timer) clearTimeout(timer); signal.removeEventListener("abort", onAbort); @@ -215,7 +431,7 @@ function runProbe( // The caller is bounded even if OS termination later fails. Admission is // retained separately by `closed`, and later probes fail soft while this // exact child remains unproven terminal. - finishResult(null); + finishResult({ raw: null, failure }); if (child.exitCode !== null || child.signalCode !== null) return; try { child.kill("SIGKILL"); @@ -233,18 +449,34 @@ function runProbe( if (size > MAX_PROBE_OUTPUT_BYTES) { terminate(); return; } chunks.push(chunk); }); - child.on("error", () => { + child.stderr?.on("data", (chunk: Buffer) => { + if (Buffer.concat(errorChunks).length < 64 * 1024) errorChunks.push(chunk); + }); + child.on("error", error => { // No PID means spawn itself failed, so there is no live child to drain. if (child.pid === undefined) { - finish(null); + const detail = errorDescription(error); + finish({ + raw: null, + failure: executionFailure(command, detail, /ENOENT|not found/i.test(detail) ? "program-not-found" : "execution-failed"), + }); } - else terminate(); + else terminate(executionFailure(command, errorDescription(error))); }); child.on("close", code => { // Decode once, at the end: `String(chunk)` per chunk corrupts any UTF-8 // character that straddles a chunk boundary. const recordClose = () => { - finish(!stopping && code === 0 ? Buffer.concat(chunks).toString("utf8") : null); + if (stopping) { + finish({ raw: null, failure: stoppingFailure ?? executionFailure(command, "probe process was terminated") }); + } else if (code === 0) { + finish({ raw: Buffer.concat(chunks).toString("utf8"), failure: null }); + } else { + finish({ + raw: null, + failure: classifyProcessFailure(command, code, Buffer.concat(errorChunks).toString("utf8")), + }); + } }; const barrier = probeCloseBarrierForTests; if (barrier) void barrier.then(recordClose, recordClose); @@ -252,8 +484,8 @@ function runProbe( }); // Close the race between the pre-spawn check and listener registration. if (signal.aborted) terminate(); - } catch { - finishResult(null); + } catch (error) { + finishResult({ raw: null, failure: executionFailure(command, errorDescription(error)) }); finishClosed(); } return { result, closed }; @@ -296,19 +528,26 @@ async function runSharedPromptProbe( if (signal?.aborted) return { kind: "failed" }; const active = activePromptProbe; if (!active) { - const raw = await waitForPromptProbeFlight(startPromptProbeFlight(command), signal); - return raw === null ? { kind: "failed" } : { kind: "output", raw }; + const result = await waitForPromptProbeFlight(startPromptProbeFlight(command), signal); + if (!result) return { kind: "failed" }; + if (result.failure) return { kind: "failed", failure: result.failure }; + return result.raw === null ? { kind: "failed" } : { kind: "output", raw: result.raw }; } if (active.key === key && active.joinable && !active.controller.signal.aborted) { - const raw = await waitForPromptProbeFlight(active, signal); - return raw === null ? { kind: "failed" } : { kind: "output", raw }; + const result = await waitForPromptProbeFlight(active, signal); + if (!result) return { kind: "failed" }; + if (result.failure) return { kind: "failed", failure: result.failure }; + return result.raw === null ? { kind: "failed" } : { kind: "output", raw: result.raw }; } // A different or terminating flight still owns the sole process slot. Never // wait unboundedly for an unproven close and never launch beside it. return { kind: "busy" }; } -async function waitForPromptProbeFlight(flight: PromptProbeFlight, signal?: AbortSignal): Promise { +async function waitForPromptProbeFlight( + flight: PromptProbeFlight, + signal?: AbortSignal, +): Promise { if (signal?.aborted) { if (flight.waiters === 0 && !flight.settled) flight.controller.abort(); return null; @@ -317,7 +556,7 @@ async function waitForPromptProbeFlight(flight: PromptProbeFlight, signal?: Abor let onAbort: (() => void) | undefined; try { if (!signal) return await flight.result; - const aborted = new Promise(resolve => { + const aborted = new Promise(resolve => { onAbort = () => resolve(null); signal.addEventListener("abort", onAbort, { once: true }); if (signal.aborted) onAbort(); @@ -387,12 +626,31 @@ export async function probePromptText( // and it also described a prompt that depends on where Codex happened to run. // The global home is the one context this page can honestly report on. const codexHome = resolveCodexHomeDir(); + const base = readBasePrompt(codexHome); + const baseLayer = promptLayerForBase(base); if (signal?.aborted) { - return { ok: false, codexHome, layers: {}, detail: "prompt probe cancelled" }; + return { ok: false, codexHome, layers: { "base-instructions": baseLayer }, base, detail: "prompt probe cancelled" }; } - const binary = probeCommandForTests?.binary ?? resolveCodexBinary(); + const resolved = probeCommandForTests ? null : resolveCodexRuntime({ discoverAlternatives: false }); + const runtime = resolved?.runtime; + const binary = probeCommandForTests?.binary ?? (runtime?.version ? runtime.command : null); if (!binary) { - return { ok: false, codexHome, layers: {}, detail: "codex binary not found" }; + const failure = resolved + ? classifyRuntimeFailure(resolved) + : executionFailure( + { binary: "codex", args: ["debug", "prompt-input"], cwd: codexHome, timeoutMs, promptStateFingerprint }, + "Codex runtime was not provided", + "program-not-found", + ); + return { + ok: false, + codexHome, + layers: { "base-instructions": baseLayer }, + base, + ...(runtime ? { runtime } : {}), + failure, + detail: failure.detail, + }; } const command: ProbeCommand = { binary, @@ -403,10 +661,14 @@ export async function probePromptText( }; const outcome = await runSharedPromptProbe(command, signal); if (outcome.kind !== "output") { + const failure = outcome.kind === "failed" ? outcome.failure : undefined; return { ok: false, codexHome, - layers: {}, + layers: { "base-instructions": baseLayer }, + base, + ...(runtime ? { runtime } : {}), + ...(failure ? { failure } : {}), detail: signal?.aborted ? "prompt probe cancelled" : outcome.kind === "busy" @@ -419,7 +681,16 @@ export async function probePromptText( if (sections.size === 0) { // Zero sections from a zero-exit probe means the output did not parse, which // is a failed read - not fifteen layers that each chose to send nothing. - return { ok: false, codexHome, layers: {}, detail: "prompt output could not be parsed" }; + const failure = executionFailure(command, "codex debug prompt-input returned no recognized sections", "output-invalid"); + return { + ok: false, + codexHome, + layers: { "base-instructions": baseLayer }, + base, + ...(runtime ? { runtime } : {}), + failure, + detail: failure.detail, + }; } const layers: Record = {}; for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) { @@ -444,8 +715,7 @@ export async function probePromptText( // An unreadable file stays "not-rendered": we cannot claim it is empty. } } - // The base prompt travels outside prompt.input and cannot be read this way. - layers["base-instructions"] = { text: null, reason: "not-exposed", bytes: 0 }; + layers["base-instructions"] = baseLayer; // Layers whose rendered tag we have not confirmed against live output. Leaving // them absent made the GUI fall through to "unavailable", which claims the probe @@ -453,7 +723,7 @@ export async function probePromptText( for (const id of UNMAPPED_LAYER_IDS) { layers[id] ??= { text: null, reason: "not-exposed", bytes: 0 }; } - return { ok: true, codexHome, layers }; + return { ok: true, codexHome, layers, base, ...(runtime ? { runtime } : {}) }; } /** Test-only command seam; production always resolves the installed Codex binary. */ diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 576f454c9c2..7b5e8a4871f 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; @@ -10,6 +10,7 @@ export type CodexRuntimeSource = | "environment" | "configured" | "shim" + | "installed" | "path" | "fallback"; @@ -111,6 +112,7 @@ function isCodexRuntimeSource(value: unknown): value is CodexRuntimeSource { return value === "environment" || value === "configured" || value === "shim" + || value === "installed" || value === "path" || value === "fallback"; } @@ -401,6 +403,21 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] { return [...new Set(out)]; } +/** Windows Codex installs use a changing directory name under this stable product root. */ +function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] { + if ((deps.platform ?? process.platform) !== "win32") return []; + const localAppData = (deps.env ?? process.env).LOCALAPPDATA?.trim(); + if (!localAppData) return []; + const root = join(localAppData, "OpenAI", "Codex", "bin"); + try { + return readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => join(root, entry.name, "codex.exe")); + } catch { + return []; + } +} + interface RankedCandidate { command: string; source: CodexRuntimeSource; @@ -623,6 +640,9 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv for (const command of pathCandidates(deps)) { ordered.push({ command, source: "path" }); } + for (const command of installedCodexCandidates(deps)) { + ordered.push({ command, source: "installed" }); + } ordered.push({ command: "codex", source: "fallback" }); const seen = new Set(); diff --git a/src/service.ts b/src/service.ts index 08e04ef49c6..1f5146d5621 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2157,6 +2157,7 @@ export function buildWindowsServiceScript( // resolve differently from the binary the caller actually baked. const { bun, bunRuntimeSource, cli } = entry; const path = process.env.PATH ?? ""; + const pathLine = windowsBatchSet("PATH", path, "pathList"); const lines = [ "@echo off", "setlocal", @@ -2166,7 +2167,9 @@ export function buildWindowsServiceScript( windowsBatchSet("OCX_SERVICE", "1"), windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"), - windowsBatchSet("PATH", path, "pathList"), + // Keep the PATH captured at install time for the bundled launcher, then inherit the + // current user's PATH so a later Codex App install remains discoverable by the probe. + pathLine ? `${pathLine.slice(0, -1)};%PATH%"` : 'set "PATH=%PATH%"', windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 32b01eac3c9..1b27a793a91 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -79,6 +79,24 @@ function root(): string { return path; } +async function withPromptHome( + model: string, + catalog: Record, + run: (home: string) => Promise, +): Promise { + const home = root(); + writeFileSync(join(home, "config.toml"), `model = "${model}"\nmodel_catalog_json = "catalog.json"\n`); + writeFileSync(join(home, "catalog.json"), JSON.stringify(catalog)); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + try { + await run(home); + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } +} + afterEach(async () => { await resetPromptTextProbeForTests(); while (lifecycleRoots.length) removeTreeWithRetry(lifecycleRoots.pop()!); @@ -167,6 +185,84 @@ describe("section extraction", () => { }); }); +describe("base prompt source", () => { + test("reads the selected model's published base instructions as expanded text", async () => { + await withPromptHome("gpt-test", { + client_version: "catalog-test-1", + models: [{ slug: "gpt-test", base_instructions: "Complete base prompt." }], + }, async (home) => { + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + const result = await probePromptText(2_000); + expect(result.ok).toBe(true); + expect(result.base).toMatchObject({ + text: "Complete base prompt.", + reason: "ok", + model: "gpt-test", + sourcePath: join(home, "catalog.json"), + representation: "expanded", + catalogVersion: "catalog-test-1", + effectiveSourceKind: "catalog-default", + effectiveTextAvailable: true, + }); + expect(result.layers["base-instructions"]).toMatchObject({ + text: "Complete base prompt.", + reason: "ok", + representation: "expanded", + }); + }); + }); + + test("labels a nested instructions template instead of presenting it as expanded", async () => { + await withPromptHome("gpt-template", { + models: [{ slug: "gpt-template", model_messages: { instructions_template: "Template {{model}}." } }], + }, async () => { + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + const result = await probePromptText(2_000); + expect(result.base).toMatchObject({ + text: "Template {{model}}.", + reason: "ok", + representation: "template", + effectiveSourceKind: "catalog-default", + }); + expect(result.layers["base-instructions"]?.representation).toBe("template"); + }); + }); +}); + +describe("probe failure attribution", () => { + test.each([ + ["program-not-found", "missing Codex program", (home: string) => ({ binary: join(home, "missing-codex.exe"), args: [] })], + ["command-unsupported", "unknown command: prompt-input", (_home: string) => ({ + binary: process.execPath, + args: ["-e", "process.stderr.write('unknown command: prompt-input'); process.exit(2)"], + })], + ["execution-failed", "probe failed", (_home: string) => ({ + binary: process.execPath, + args: ["-e", "process.stderr.write('probe failed'); process.exit(1)"], + })], + ["output-invalid", "empty probe output", (_home: string) => ({ + binary: process.execPath, + args: ["-e", "process.stdout.write('{}')"], + })], + ] as const)("reports %s distinctly", async (kind, _label, commandFor) => { + await withPromptHome("gpt-test", { + models: [{ slug: "gpt-test", base_instructions: "Base." }], + }, async (home) => { + setPromptTextProbeCommandForTests(commandFor(home)); + const result = await probePromptText(2_000); + expect(result.ok).toBe(false); + expect(result.failure?.kind).toBe(kind); + expect(result.base.text).toBe("Base."); + }); + }); +}); + describe("prompt probe process lifecycle", () => { test("a pre-aborted caller starts no child", async () => { const marker = join(root(), "started.txt"); diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index e413994ce63..3c7947d515c 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -15,7 +15,7 @@ import { describe, expect, test } from "bun:test"; * qualification (#1691). */ const NO_CODEX_PATH = "/usr/bin:/bin"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { @@ -466,6 +466,26 @@ describe("resolveCodexRuntime", () => { expect(result.runtime.source).not.toBe("environment"); }); + test("discovers Codex App installs below the stable Windows root", () => { + const localAppData = tempConfigDir(); + const installed = join(localAppData, "OpenAI", "Codex", "bin", "changing-hash", "codex.exe"); + mkdirSync(dirname(installed), { recursive: true }); + writeFileSync(installed, ""); + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { LOCALAPPDATA: localAppData, PATH: NO_CODEX_PATH }, + platform: "win32", + existsSync: path => String(path) === installed, + execFileSync: file => { + expect(String(file)).toBe(installed); + return "codex-cli 0.154.0-alpha.6.2"; + }, + }); + expect(result.runtime.command).toBe(installed); + expect(result.runtime.source).toBe("installed"); + expect(result.runtime.version).toBe("0.154.0-alpha.6.2"); + }); + test("valid configured runtime beats shim and PATH", () => { const configDir = tempConfigDir(); persistCodexRuntime({ diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 71666961f6a..0707f77a880 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -133,6 +133,22 @@ describe("service listen-port bake", () => { expect(buildPlist()).toContain("start --port 13337"); expect(buildUnit()).toContain("start --port 13337"); }); + + test("Windows service wrapper preserves the user's current PATH", () => { + const previousPath = process.env.PATH; + try { + process.env.PATH = "C:\\OpenCodex\\bin"; + const script = buildWindowsServiceScript({ + bun: "C:\\OpenCodex\\bun.exe", + bunRuntimeSource: "bundled", + cli: "C:\\OpenCodex\\cli.ts", + }); + expect(script).toContain('set "PATH=C:\\OpenCodex\\bin;%PATH%"'); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + } + }); }); describe("systemd service unit", () => { @@ -1203,7 +1219,7 @@ describe("Windows service task", () => { process.env.OPENCODEX_HOME = 'C:\\ocx" & del C:\\important & rem "'; process.env.OPENCODEX_API_AUTH_TOKEN = 'token" & echo LEAK & rem "'; const script = buildWindowsServiceScript(); - expect(script).toContain('set "PATH=C:\\safe & echo PWNED & rem "'); + expect(script).toContain('set "PATH=C:\\safe & echo PWNED & rem ;%PATH%"'); expect(script).toContain('set "OPENCODEX_HOME=C:\\ocx & del C:\\important & rem "'); expect(script).toContain('set "OCX_API_TOKEN_FILE='); expect(script).toContain('set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"'); From 3cff79bad43bc3aface28cf8b65768ab53fff3bc Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:26:34 +0800 Subject: [PATCH 02/12] fix(codex): make Windows prompt runtime selection deterministic --- src/codex/prompt-text-probe.ts | 4 ++- src/codex/runtime.ts | 15 ++++++++--- .../codex-prompt-text-probe.test.ts | 18 +++++++++++++ tests/codex-integration/codex-runtime.test.ts | 27 ++++++++++++++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 13a01bee58a..2f2449f18da 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -102,6 +102,8 @@ export interface BasePromptText { text: string | null; reason: | "ok" + | "config-not-found" + | "config-unreadable" | "model-not-selected" | "model-not-found" | "catalog-not-found" @@ -154,7 +156,7 @@ function readBasePrompt(codexHome: string): BasePromptText { } catch { return { text: null, - reason: "model-not-selected", + reason: existsSync(configPath) ? "config-unreadable" : "config-not-found", bytes: 0, model: null, modelSource: configPath, diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 7b5e8a4871f..2f5cb05d563 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, unlinkSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; @@ -412,7 +412,16 @@ function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] { try { return readdirSync(root, { withFileTypes: true }) .filter(entry => entry.isDirectory()) - .map(entry => join(root, entry.name, "codex.exe")); + .map(entry => { + const directory = join(root, entry.name); + try { + return { directory, name: entry.name, mtimeMs: statSync(directory).mtimeMs }; + } catch { + return { directory, name: entry.name, mtimeMs: -Infinity }; + } + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name)) + .map(entry => join(entry.directory, "codex.exe")); } catch { return []; } @@ -664,7 +673,7 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv }; } - // Prefer first valid in priority order (environment → configured → shim → path → fallback). + // Prefer first valid in priority order (environment → configured → shim → path → installed → fallback). let selected = valid[0]!; let replacedConfigured: ResolveCodexRuntimeResult["replacedConfigured"]; diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 1b27a793a91..2594ce039a3 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -186,6 +186,24 @@ describe("section extraction", () => { }); describe("base prompt source", () => { + test("distinguishes a missing config from a config without a selected model", async () => { + const home = root(); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + try { + expect((await probePromptText(2_000)).base.reason).toBe("config-not-found"); + writeFileSync(join(home, "config.toml"), "model_catalog_json = \"catalog.json\"\n"); + expect((await probePromptText(2_000)).base.reason).toBe("model-not-selected"); + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); + test("reads the selected model's published base instructions as expanded text", async () => { await withPromptHome("gpt-test", { client_version: "catalog-test-1", diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index 3c7947d515c..51b90d05ca2 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -15,7 +15,7 @@ import { describe, expect, test } from "bun:test"; * qualification (#1691). */ const NO_CODEX_PATH = "/usr/bin:/bin"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { @@ -486,6 +486,31 @@ describe("resolveCodexRuntime", () => { expect(result.runtime.version).toBe("0.154.0-alpha.6.2"); }); + test("prefers the newest Codex App install when several version directories exist", () => { + const localAppData = tempConfigDir(); + const older = join(localAppData, "OpenAI", "Codex", "bin", "older", "codex.exe"); + const newer = join(localAppData, "OpenAI", "Codex", "bin", "newer", "codex.exe"); + mkdirSync(dirname(older), { recursive: true }); + mkdirSync(dirname(newer), { recursive: true }); + writeFileSync(older, ""); + writeFileSync(newer, ""); + utimesSync(dirname(older), new Date("2020-01-01T00:00:00Z"), new Date("2020-01-01T00:00:00Z")); + utimesSync(dirname(newer), new Date("2024-01-01T00:00:00Z"), new Date("2024-01-01T00:00:00Z")); + const probed: string[] = []; + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { LOCALAPPDATA: localAppData, PATH: NO_CODEX_PATH }, + platform: "win32", + existsSync: path => path === older || path === newer, + execFileSync: file => { + probed.push(String(file)); + return "codex-cli 0.154.0-alpha.6.2"; + }, + }); + expect(result.runtime.command).toBe(newer); + expect(probed.slice(0, 2)).toEqual([newer, older]); + }); + test("valid configured runtime beats shim and PATH", () => { const configDir = tempConfigDir(); persistCodexRuntime({ From bcdfd15ab31a9edb76dfdce9d195ca06821e793d Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:39:43 +0800 Subject: [PATCH 03/12] fix(codex): classify prompt config read failures --- src/codex/prompt-text-probe.ts | 4 ++-- tests/codex-integration/codex-prompt-text-probe.test.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 2f2449f18da..b0d2f04c61d 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -153,10 +153,10 @@ function readBasePrompt(codexHome: string): BasePromptText { let configText: string; try { configText = readFileSync(configPath, "utf8"); - } catch { + } catch (error) { return { text: null, - reason: existsSync(configPath) ? "config-unreadable" : "config-not-found", + reason: (error as NodeJS.ErrnoException).code === "ENOENT" ? "config-not-found" : "config-unreadable", bytes: 0, model: null, modelSource: configPath, diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 2594ce039a3..e3fbb527b98 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -7,7 +7,7 @@ * that attribution to a user as an explanation. */ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -186,7 +186,7 @@ describe("section extraction", () => { }); describe("base prompt source", () => { - test("distinguishes a missing config from a config without a selected model", async () => { + test("distinguishes missing, unreadable, and unselected config states", async () => { const home = root(); const previousHome = process.env.CODEX_HOME; process.env.CODEX_HOME = home; @@ -198,6 +198,10 @@ describe("base prompt source", () => { expect((await probePromptText(2_000)).base.reason).toBe("config-not-found"); writeFileSync(join(home, "config.toml"), "model_catalog_json = \"catalog.json\"\n"); expect((await probePromptText(2_000)).base.reason).toBe("model-not-selected"); + const unreadableHome = root(); + mkdirSync(join(unreadableHome, "config.toml")); + process.env.CODEX_HOME = unreadableHome; + expect((await probePromptText(2_000)).base.reason).toBe("config-unreadable"); } finally { if (previousHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousHome; From b0503f6b92c35cb4579ab9972818f714f1a4bd7a Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:58:11 +0800 Subject: [PATCH 04/12] fix(codex): classify prompt probe edge cases --- src/codex/prompt-text-probe.ts | 6 ++ src/codex/runtime.ts | 4 ++ .../codex-prompt-text-probe.test.ts | 65 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index b0d2f04c61d..165a7400094 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -109,6 +109,7 @@ export interface BasePromptText { | "catalog-not-found" | "catalog-unreadable" | "not-published" + | "override-empty" | "override-not-found" | "override-unreadable"; bytes: number; @@ -204,6 +205,9 @@ function readBasePrompt(codexHome: string): BasePromptText { } try { const text = readFileSync(overridePath, "utf8"); + if (text.trim().length === 0) { + return unavailable("override-empty", overridePath, overridePath, "model-instructions-file"); + } return { text, reason: "ok", @@ -345,6 +349,8 @@ function classifyRuntimeFailure(runtime: ReturnType) const lower = detail.toLowerCase(); const kind = /not a spawnable|unrecognized --version output/.test(lower) ? "command-unsupported" + : /enoent|not found/.test(lower) + ? "program-not-found" : /failed --version|probe sandbox unavailable/.test(lower) ? "execution-failed" : "program-not-found"; diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 2f5cb05d563..658c4923873 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -89,6 +89,7 @@ const PERSIST_FILE = "codex-runtime.json"; const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json"; /** Probe rejection for an absolute candidate whose file is gone. Matched when retiring a dead pin (#4035). */ const PATH_MISSING_REASON = "path does not exist"; +const PROGRAM_NOT_FOUND_REASON = "program not found (ENOENT)"; function cloneAndDeepFreeze(value: T): DeepReadonly { const clone = (current: unknown): unknown => { @@ -352,6 +353,9 @@ function probeVersion( return { ok: true, version }; } catch (error) { if (!probeHome) return { ok: false, reason: "probe sandbox unavailable" }; + if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") { + return { ok: false, reason: PROGRAM_NOT_FOUND_REASON }; + } const message = error instanceof Error ? error.message : String(error); const redacted = redactUserPath(redactSecretString(message)).slice(0, 160); return { ok: false, reason: `failed --version (${redacted})` }; diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index e3fbb527b98..b0e3b98ee40 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -208,6 +208,39 @@ describe("base prompt source", () => { } }); + test("reports an empty model instruction override as unavailable", async () => { + const home = root(); + const overridePath = join(home, "empty.md"); + writeFileSync(join(home, "config.toml"), "model = \"gpt-test\"\nmodel_instructions_file = \"empty.md\"\n"); + writeFileSync(overridePath, " \n\t", "utf8"); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + try { + const result = await probePromptText(2_000); + expect(result.base).toMatchObject({ + text: null, + reason: "override-empty", + model: "gpt-test", + sourcePath: overridePath, + effectiveSourcePath: overridePath, + effectiveSourceKind: "model-instructions-file", + representation: "unavailable", + effectiveTextAvailable: false, + }); + expect(result.layers["base-instructions"]).toMatchObject({ + text: null, + reason: "unavailable", + }); + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); + test("reads the selected model's published base instructions as expanded text", async () => { await withPromptHome("gpt-test", { client_version: "catalog-test-1", @@ -283,6 +316,38 @@ describe("probe failure attribution", () => { expect(result.base.text).toBe("Base."); }); }); + + test("classifies a missing PATH fallback as program-not-found", async () => { + const codexHome = root(); + const opencodexHome = root(); + const localAppData = root(); + const isolatedPath = root(); + const previous = { + CODEX_HOME: process.env.CODEX_HOME, + OPENCODEX_HOME: process.env.OPENCODEX_HOME, + CODEX_CLI_PATH: process.env.CODEX_CLI_PATH, + LOCALAPPDATA: process.env.LOCALAPPDATA, + PATH: process.env.PATH, + }; + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + delete process.env.CODEX_CLI_PATH; + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = isolatedPath; + setPromptTextProbeCommandForTests(null); + try { + const result = await probePromptText(2_000); + expect(result.ok).toBe(false); + expect(result.runtime?.source).toBe("fallback"); + expect(result.failure?.kind).toBe("program-not-found"); + expect(result.failure?.detail).toContain("fallback"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); }); describe("prompt probe process lifecycle", () => { From 68d608b45448356c27abb11ed6b14c4f599407af Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:10:52 +0800 Subject: [PATCH 05/12] fix(codex): bound prompt source reads --- src/codex/prompt-text-probe.ts | 48 +++++++++++++++---- .../codex-prompt-text-probe.test.ts | 35 +++++++++++++- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 165a7400094..9465905ff07 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -108,9 +108,12 @@ export interface BasePromptText { | "model-not-found" | "catalog-not-found" | "catalog-unreadable" + | "catalog-too-large" | "not-published" + | "config-too-large" | "override-empty" | "override-not-found" + | "override-too-large" | "override-unreadable"; bytes: number; model: string | null; @@ -149,15 +152,29 @@ function entryText(entry: RawEntry | null, key: string): string | null { return typeof value === "string" && value.length > 0 ? value : null; } +/** Keep local source reads bounded to the same size as the subprocess response. */ +const MAX_PROBE_OUTPUT_BYTES = 8 * 1024 * 1024; + +function readBoundedPromptSource(path: string): string { + const metadata = statSync(path); + if (metadata.isFile() && metadata.size > MAX_PROBE_OUTPUT_BYTES) { + const error = new Error(`prompt source exceeds ${MAX_PROBE_OUTPUT_BYTES} bytes`) as NodeJS.ErrnoException; + error.code = "EFBIG"; + throw error; + } + return readFileSync(path, "utf8"); +} + function readBasePrompt(codexHome: string): BasePromptText { const configPath = join(codexHome, "config.toml"); let configText: string; try { - configText = readFileSync(configPath, "utf8"); + configText = readBoundedPromptSource(configPath); } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; return { text: null, - reason: (error as NodeJS.ErrnoException).code === "ENOENT" ? "config-not-found" : "config-unreadable", + reason: code === "ENOENT" ? "config-not-found" : code === "EFBIG" ? "config-too-large" : "config-unreadable", bytes: 0, model: null, modelSource: configPath, @@ -172,7 +189,15 @@ function readBasePrompt(codexHome: string): BasePromptText { const model = readRootTomlString(configText, "model"); const catalogPath = readCodexCatalogPathForHome(codexHome); - const catalog = readCatalog(catalogPath); + const catalogTooLarge = (() => { + try { + const metadata = statSync(catalogPath); + return metadata.isFile() && metadata.size > MAX_PROBE_OUTPUT_BYTES; + } catch { + return false; + } + })(); + const catalog = catalogTooLarge ? null : readCatalog(catalogPath); const catalogVersion = typeof catalog?.client_version === "string" ? catalog.client_version : null; const unavailable = ( reason: BasePromptText["reason"], @@ -204,7 +229,7 @@ function readBasePrompt(codexHome: string): BasePromptText { return unavailable("override-unreadable", configuredOverride, configuredOverride, "model-instructions-file"); } try { - const text = readFileSync(overridePath, "utf8"); + const text = readBoundedPromptSource(overridePath); if (text.trim().length === 0) { return unavailable("override-empty", overridePath, overridePath, "model-instructions-file"); } @@ -221,9 +246,10 @@ function readBasePrompt(codexHome: string): BasePromptText { effectiveSourceKind: "model-instructions-file", effectiveTextAvailable: true, }; - } catch { + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; return unavailable( - existsSync(overridePath) ? "override-unreadable" : "override-not-found", + code === "ENOENT" ? "override-not-found" : code === "EFBIG" ? "override-too-large" : "override-unreadable", overridePath, overridePath, "model-instructions-file", @@ -231,7 +257,12 @@ function readBasePrompt(codexHome: string): BasePromptText { } } - if (!catalog) return unavailable(existsSync(catalogPath) ? "catalog-unreadable" : "catalog-not-found", catalogPath); + if (!catalog) { + return unavailable( + catalogTooLarge ? "catalog-too-large" : existsSync(catalogPath) ? "catalog-unreadable" : "catalog-not-found", + catalogPath, + ); + } const entry = catalog.models?.find(candidate => candidate.slug === model || candidate.id === model) ?? null; if (!entry) return unavailable("model-not-found", catalogPath); const topLevel = entryText(entry, "base_instructions"); @@ -260,9 +291,6 @@ function readBasePrompt(codexHome: string): BasePromptText { }; } -/** 8 MiB is far above any real prompt and far below anything that hurts the server. */ -const MAX_PROBE_OUTPUT_BYTES = 8 * 1024 * 1024; - interface ProbeCommand { binary: string; args: string[]; diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index b0e3b98ee40..9dde6ddbff4 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -7,7 +7,7 @@ * that attribution to a user as an explanation. */ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -27,6 +27,7 @@ const VALID_PROBE_OUTPUT = JSON.stringify([{ role: "developer", content: [{ type: "input_text", text: "Skill text." }], }]); +const OVERSIZED_SOURCE_BYTES = 8 * 1024 * 1024 + 1; function message(text: string): string { return JSON.stringify([{ type: "message", role: "developer", content: [{ type: "input_text", text }] }]); @@ -241,6 +242,38 @@ describe("base prompt source", () => { } }); + test.each([ + ["catalog", "model_catalog_json", "catalog.json", "catalog-too-large"], + ["override", "model_instructions_file", "override.md", "override-too-large"], + ] as const)("reports an oversized %s source as unavailable", async (_label, key, fileName, reason) => { + const home = root(); + const sourcePath = join(home, fileName); + writeFileSync(join(home, "config.toml"), `model = "gpt-test"\n${key} = "${fileName}"\n`); + writeFileSync(sourcePath, "", "utf8"); + truncateSync(sourcePath, OVERSIZED_SOURCE_BYTES); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + try { + const result = await probePromptText(2_000); + expect(result.base).toMatchObject({ + text: null, + reason, + model: "gpt-test", + sourcePath, + effectiveSourcePath: sourcePath, + representation: "unavailable", + effectiveTextAvailable: false, + }); + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); + test("reads the selected model's published base instructions as expanded text", async () => { await withPromptHome("gpt-test", { client_version: "catalog-test-1", From 82c1ce8a0f0be284ad01701305bd339e160ff655 Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:31:55 +0800 Subject: [PATCH 06/12] fix(codex): harden prompt source probing --- src/codex/catalog/parsing.ts | 6 +- src/codex/prompt-text-probe.ts | 72 ++++++++++++------- src/service.ts | 4 +- .../codex-prompt-text-probe.test.ts | 41 ++++++++++- tests/service/service.test.ts | 4 +- 5 files changed, 93 insertions(+), 34 deletions(-) diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index e1629a0ed58..206dc3fbc87 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -256,11 +256,11 @@ export function readCodexCatalogPath(): string { } /** Resolve the configured catalog without consulting ambient CODEX_HOME again. */ -export function readCodexCatalogPathForHome(codexHome: string): string { +export function readCodexCatalogPathForHome(codexHome: string, configText?: string): string { try { const configPath = join(codexHome, "config.toml"); - if (existsSync(configPath)) { - const toml = readFileSync(configPath, "utf-8"); + if (configText !== undefined || existsSync(configPath)) { + const toml = configText ?? readFileSync(configPath, "utf-8"); const path = readRootTomlString(toml, "model_catalog_json"); if (path) return resolve(codexHome, path); } diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index 9465905ff07..e2f440be064 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -18,10 +18,10 @@ * universal prompt. */ import { spawn } from "node:child_process"; -import { existsSync, readFileSync, statSync } from "node:fs"; +import { closeSync, existsSync, fstatSync, openSync, readSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { expandUserPath } from "../config"; -import { readCodexCatalogPathForHome, readCatalog, type RawEntry } from "./catalog/parsing"; +import { parseCatalogJson, readCodexCatalogPathForHome, type RawEntry } from "./catalog/parsing"; import { codexExecInvocation } from "./exec-invocation"; import { resolveCodexHomeDir } from "./home"; import { readRootTomlString } from "./paths"; @@ -156,13 +156,33 @@ function entryText(entry: RawEntry | null, key: string): string | null { const MAX_PROBE_OUTPUT_BYTES = 8 * 1024 * 1024; function readBoundedPromptSource(path: string): string { - const metadata = statSync(path); - if (metadata.isFile() && metadata.size > MAX_PROBE_OUTPUT_BYTES) { - const error = new Error(`prompt source exceeds ${MAX_PROBE_OUTPUT_BYTES} bytes`) as NodeJS.ErrnoException; - error.code = "EFBIG"; - throw error; + let descriptor: number | undefined; + try { + descriptor = openSync(path, "r"); + const metadata = fstatSync(descriptor); + if (!metadata.isFile()) { + const error = new Error("prompt source is not a regular file") as NodeJS.ErrnoException; + error.code = "EFTYPE"; + throw error; + } + const buffer = Buffer.allocUnsafe(MAX_PROBE_OUTPUT_BYTES + 1); + let bytesRead = 0; + while (bytesRead < buffer.length) { + const read = readSync(descriptor, buffer, bytesRead, buffer.length - bytesRead, bytesRead); + if (read === 0) break; + bytesRead += read; + } + if (bytesRead > MAX_PROBE_OUTPUT_BYTES) { + const error = new Error(`prompt source exceeds ${MAX_PROBE_OUTPUT_BYTES} bytes`) as NodeJS.ErrnoException; + error.code = "EFBIG"; + throw error; + } + return buffer.subarray(0, bytesRead).toString("utf8"); + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* best effort */ } + } } - return readFileSync(path, "utf8"); } function readBasePrompt(codexHome: string): BasePromptText { @@ -188,16 +208,16 @@ function readBasePrompt(codexHome: string): BasePromptText { } const model = readRootTomlString(configText, "model"); - const catalogPath = readCodexCatalogPathForHome(codexHome); - const catalogTooLarge = (() => { - try { - const metadata = statSync(catalogPath); - return metadata.isFile() && metadata.size > MAX_PROBE_OUTPUT_BYTES; - } catch { - return false; - } - })(); - const catalog = catalogTooLarge ? null : readCatalog(catalogPath); + const catalogPath = readCodexCatalogPathForHome(codexHome, configText); + let catalog: ReturnType = null; + let catalogReason: "catalog-not-found" | "catalog-unreadable" | "catalog-too-large" = "catalog-not-found"; + try { + catalog = parseCatalogJson(readBoundedPromptSource(catalogPath)); + if (!catalog) catalogReason = "catalog-unreadable"; + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + catalogReason = code === "ENOENT" ? "catalog-not-found" : code === "EFBIG" ? "catalog-too-large" : "catalog-unreadable"; + } const catalogVersion = typeof catalog?.client_version === "string" ? catalog.client_version : null; const unavailable = ( reason: BasePromptText["reason"], @@ -257,12 +277,7 @@ function readBasePrompt(codexHome: string): BasePromptText { } } - if (!catalog) { - return unavailable( - catalogTooLarge ? "catalog-too-large" : existsSync(catalogPath) ? "catalog-unreadable" : "catalog-not-found", - catalogPath, - ); - } + if (!catalog) return unavailable(catalogReason, catalogPath); const entry = catalog.models?.find(candidate => candidate.slug === model || candidate.id === model) ?? null; if (!entry) return unavailable("model-not-found", catalogPath); const topLevel = entryText(entry, "base_instructions"); @@ -375,11 +390,14 @@ function classifyRuntimeFailure(runtime: ReturnType) ? runtime.failures.map(item => `${item.source}: ${item.reason}`).join("; ").slice(0, 512) : "no usable Codex runtime was found"; const lower = detail.toLowerCase(); - const kind = /not a spawnable|unrecognized --version output/.test(lower) + const representative = runtime.failures.find(item => !/enoent|not found|path does not exist/.test(item.reason.toLowerCase())) + ?? runtime.failures[0]; + const representativeReason = representative?.reason.toLowerCase() ?? lower; + const kind = /not a spawnable|unrecognized --version output/.test(representativeReason) ? "command-unsupported" - : /enoent|not found/.test(lower) + : /enoent|not found|path does not exist/.test(representativeReason) ? "program-not-found" - : /failed --version|probe sandbox unavailable/.test(lower) + : /failed --version|probe sandbox unavailable/.test(representativeReason) ? "execution-failed" : "program-not-found"; return executionFailure( diff --git a/src/service.ts b/src/service.ts index 1f5146d5621..bfc9007459e 100644 --- a/src/service.ts +++ b/src/service.ts @@ -2169,7 +2169,9 @@ export function buildWindowsServiceScript( windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"), // Keep the PATH captured at install time for the bundled launcher, then inherit the // current user's PATH so a later Codex App install remains discoverable by the probe. - pathLine ? `${pathLine.slice(0, -1)};%PATH%"` : 'set "PATH=%PATH%"', + // `%PATH%` is expanded when this wrapper runs; strip embedded quotes before that value + // enters the quoted assignment so the inherited value cannot close the assignment. + pathLine ? `${pathLine.slice(0, -1)};%PATH:"=%"` : 'set "PATH=%PATH:"=%"', windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), windowsBatchSet("CODEX_SQLITE_HOME", currentCodexSqliteHomeAbsolute("windows"), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 9dde6ddbff4..065e298078b 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -7,7 +7,7 @@ * that attribution to a user as an explanation. */ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, truncateSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, truncateSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -381,6 +381,45 @@ describe("probe failure attribution", () => { } } }); + + test("prefers a concrete runtime failure over fallback not-found", async () => { + const codexHome = root(); + const opencodexHome = root(); + const localAppData = root(); + const isolatedPath = root(); + const launcherRoot = root(); + const launcher = process.platform === "win32" ? join(launcherRoot, "broken.cmd") : join(launcherRoot, "broken"); + if (process.platform === "win32") { + writeFileSync(launcher, "@echo off\r\nexit /b 1\r\n", "utf8"); + } else { + writeFileSync(launcher, "#!/bin/sh\nexit 1\n", "utf8"); + chmodSync(launcher, 0o755); + } + const previous = { + CODEX_HOME: process.env.CODEX_HOME, + OPENCODEX_HOME: process.env.OPENCODEX_HOME, + CODEX_CLI_PATH: process.env.CODEX_CLI_PATH, + LOCALAPPDATA: process.env.LOCALAPPDATA, + PATH: process.env.PATH, + }; + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + process.env.CODEX_CLI_PATH = launcher; + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = isolatedPath; + setPromptTextProbeCommandForTests(null); + try { + const result = await probePromptText(2_000); + expect(result.ok).toBe(false); + expect(result.failure?.kind).toBe("execution-failed"); + expect(result.failure?.detail).toContain("environment"); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); }); describe("prompt probe process lifecycle", () => { diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 0707f77a880..ea01ce951cc 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -143,7 +143,7 @@ describe("service listen-port bake", () => { bunRuntimeSource: "bundled", cli: "C:\\OpenCodex\\cli.ts", }); - expect(script).toContain('set "PATH=C:\\OpenCodex\\bin;%PATH%"'); + expect(script).toContain('set "PATH=C:\\OpenCodex\\bin;%PATH:"=%"'); } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; @@ -1219,7 +1219,7 @@ describe("Windows service task", () => { process.env.OPENCODEX_HOME = 'C:\\ocx" & del C:\\important & rem "'; process.env.OPENCODEX_API_AUTH_TOKEN = 'token" & echo LEAK & rem "'; const script = buildWindowsServiceScript(); - expect(script).toContain('set "PATH=C:\\safe & echo PWNED & rem ;%PATH%"'); + expect(script).toContain('set "PATH=C:\\safe & echo PWNED & rem ;%PATH:"=%"'); expect(script).toContain('set "OPENCODEX_HOME=C:\\ocx & del C:\\important & rem "'); expect(script).toContain('set "OCX_API_TOKEN_FILE='); expect(script).toContain('set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"'); From 635adce55cc0fa7f76b21e0cbe81843046d087bb Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:49:29 +0800 Subject: [PATCH 07/12] fix(codex): handle prompt source edge cases --- src/codex/prompt-text-probe.ts | 11 +++++---- .../codex-prompt-text-probe.test.ts | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index e2f440be064..f57d1ee3b72 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -18,7 +18,7 @@ * universal prompt. */ import { spawn } from "node:child_process"; -import { closeSync, existsSync, fstatSync, openSync, readSync, statSync } from "node:fs"; +import { closeSync, constants, existsSync, fstatSync, openSync, readSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { expandUserPath } from "../config"; import { parseCatalogJson, readCodexCatalogPathForHome, type RawEntry } from "./catalog/parsing"; @@ -158,7 +158,7 @@ const MAX_PROBE_OUTPUT_BYTES = 8 * 1024 * 1024; function readBoundedPromptSource(path: string): string { let descriptor: number | undefined; try { - descriptor = openSync(path, "r"); + descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); const metadata = fstatSync(descriptor); if (!metadata.isFile()) { const error = new Error("prompt source is not a regular file") as NodeJS.ErrnoException; @@ -278,7 +278,10 @@ function readBasePrompt(codexHome: string): BasePromptText { } if (!catalog) return unavailable(catalogReason, catalogPath); - const entry = catalog.models?.find(candidate => candidate.slug === model || candidate.id === model) ?? null; + const entry = catalog.models?.find(candidate => ( + candidate !== null && typeof candidate === "object" && + (candidate.slug === model || candidate.id === model) + )) ?? null; if (!entry) return unavailable("model-not-found", catalogPath); const topLevel = entryText(entry, "base_instructions"); const modelMessages = entry.model_messages; @@ -401,7 +404,7 @@ function classifyRuntimeFailure(runtime: ReturnType) ? "execution-failed" : "program-not-found"; return executionFailure( - { binary: runtime.runtime.command, args: [], cwd: "", timeoutMs: 0, promptStateFingerprint: null }, + { binary: representative?.command ?? runtime.runtime.command, args: [], cwd: "", timeoutMs: 0, promptStateFingerprint: null }, detail, kind, ); diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 065e298078b..40fbaa9541c 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -420,6 +420,30 @@ describe("probe failure attribution", () => { } } }); + + test("keeps a malformed catalog row unavailable instead of throwing", async () => { + const home = root(); + writeFileSync(join(home, "config.toml"), "model = \"gpt-test\"\nmodel_catalog_json = \"catalog.json\"\n"); + writeFileSync(join(home, "catalog.json"), JSON.stringify({ models: [null] }), "utf8"); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + try { + const result = await probePromptText(2_000); + expect(result.base).toMatchObject({ + text: null, + reason: "model-not-found", + sourcePath: join(home, "catalog.json"), + effectiveTextAvailable: false, + }); + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); }); describe("prompt probe process lifecycle", () => { From 5e74bc77099069652d8365d0aea4203b349305c6 Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:54:31 +0800 Subject: [PATCH 08/12] fix(codex): redact prompt probe diagnostics --- src/server/management/codex-prompt-routes.ts | 10 +++++++++- .../codex-prompt-route.test.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/server/management/codex-prompt-routes.ts b/src/server/management/codex-prompt-routes.ts index 878dc604fea..0b8b1befce0 100644 --- a/src/server/management/codex-prompt-routes.ts +++ b/src/server/management/codex-prompt-routes.ts @@ -326,7 +326,15 @@ export async function handleCodexPromptRoutes(ctx: ManagementContext): Promise { expect(probe).toContain("Buffer.concat(chunks).toString(\"utf8\")"); }); + test("26b. the text route omits raw process diagnostics", async () => { + const fx = fixture(""); + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", "process.stderr.write('oauth-secret=do-not-return'); process.exit(1)"], + }); + const res = await call("GET", "/api/codex-prompt/text", fx); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + ok: false, + detail: "codex debug prompt-input failed", + failure: { kind: "execution-failed", command: expect.any(String) }, + }); + expect(res.body.failure).not.toHaveProperty("detail"); + expect(JSON.stringify(res.body)).not.toContain("oauth-secret=do-not-return"); + }); + test("27. the text route forwards live request cancellation to its exact child", async () => { const fx = fixture(""); const pidPath = join(fx.decoyHome, "probe-pid.txt"); From e809149ae74569da95606a7bdaf0b74f5e6fb3f6 Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:10:11 +0800 Subject: [PATCH 09/12] test(codex): cover nonblocking prompt sources --- .../codex-prompt-text-probe.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 40fbaa9541c..7b3d4370f72 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -444,6 +444,38 @@ describe("probe failure attribution", () => { else process.env.CODEX_HOME = previousHome; } }); + + test.skipIf(process.platform === "win32")("rejects a FIFO prompt source without blocking", async () => { + const home = root(); + const fifo = join(home, "prompt.fifo"); + const created = Bun.spawnSync(["mkfifo", fifo]); + expect(created.exitCode).toBe(0); + writeFileSync(join(home, "config.toml"), [ + "model = \"gpt-test\"", + "model_catalog_json = \"catalog.json\"", + "model_instructions_file = \"prompt.fifo\"", + "", + ].join("\n")); + writeFileSync(join(home, "catalog.json"), JSON.stringify({ models: [{ slug: "gpt-test", base_instructions: "Base." }] }), "utf8"); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + try { + const result = await probePromptText(2_000); + expect(result.base).toMatchObject({ + text: null, + reason: "override-unreadable", + sourcePath: fifo, + effectiveTextAvailable: false, + }); + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); }); describe("prompt probe process lifecycle", () => { From d08afed41c516a04a7c765cc5b85df50444e16f9 Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:29:56 +0800 Subject: [PATCH 10/12] fix(codex): retain Unix runtime discovery --- src/codex/runtime.ts | 52 +++++++++++-------- tests/codex-integration/codex-runtime.test.ts | 18 +++++++ 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index 658c4923873..a0c4439a45d 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; @@ -407,28 +407,38 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] { return [...new Set(out)]; } -/** Windows Codex installs use a changing directory name under this stable product root. */ +/** Codex installs that are not necessarily exposed through PATH. */ function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] { - if ((deps.platform ?? process.platform) !== "win32") return []; - const localAppData = (deps.env ?? process.env).LOCALAPPDATA?.trim(); - if (!localAppData) return []; - const root = join(localAppData, "OpenAI", "Codex", "bin"); - try { - return readdirSync(root, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => { - const directory = join(root, entry.name); - try { - return { directory, name: entry.name, mtimeMs: statSync(directory).mtimeMs }; - } catch { - return { directory, name: entry.name, mtimeMs: -Infinity }; - } - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name)) - .map(entry => join(entry.directory, "codex.exe")); - } catch { - return []; + const platform = deps.platform ?? process.platform; + const env = deps.env ?? process.env; + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA?.trim(); + if (!localAppData) return []; + const root = join(localAppData, "OpenAI", "Codex", "bin"); + try { + return readdirSync(root, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => { + const directory = join(root, entry.name); + try { + return { directory, name: entry.name, mtimeMs: statSync(directory).mtimeMs }; + } catch { + return { directory, name: entry.name, mtimeMs: -Infinity }; + } + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name)) + .map(entry => join(entry.directory, "codex.exe")); + } catch { + return []; + } } + const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir(); + return [ + join(home, ".codex", "packages", "standalone", "current", "bin", "codex"), + join(home, ".local", "bin", "codex"), + "/usr/local/bin/codex", + "/opt/homebrew/bin/codex", + ]; } interface RankedCandidate { diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index 51b90d05ca2..187448da235 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -511,6 +511,24 @@ describe("resolveCodexRuntime", () => { expect(probed.slice(0, 2)).toEqual([newer, older]); }); + test("restores the established Unix Codex install locations", () => { + const home = tempConfigDir(); + const installed = join(home, ".codex", "packages", "standalone", "current", "bin", "codex"); + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { HOME: home, PATH: NO_CODEX_PATH }, + platform: "linux", + existsSync: path => String(path) === installed, + execFileSync: file => { + expect(String(file)).toBe(installed); + return "codex-cli 0.154.0-alpha.6.2"; + }, + }); + expect(result.runtime.command).toBe(installed); + expect(result.runtime.source).toBe("installed"); + expect(result.runtime.version).toBe("0.154.0-alpha.6.2"); + }); + test("valid configured runtime beats shim and PATH", () => { const configDir = tempConfigDir(); persistCodexRuntime({ From cfebd17387049f27ea94d011007f6d6228ea4530 Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:13:02 +0800 Subject: [PATCH 11/12] fix(codex): keep prompt probe nonblocking --- src/codex/prompt-text-probe.ts | 27 +++++++++++++-- src/codex/runtime.ts | 9 ++++- .../codex-prompt-text-probe.test.ts | 33 +++++++++++++++++++ tests/codex-integration/codex-runtime.test.ts | 18 ++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/codex/prompt-text-probe.ts b/src/codex/prompt-text-probe.ts index f57d1ee3b72..b157cbdbda6 100644 --- a/src/codex/prompt-text-probe.ts +++ b/src/codex/prompt-text-probe.ts @@ -207,6 +207,25 @@ function readBasePrompt(codexHome: string): BasePromptText { }; } + try { + const parsed = Bun.TOML.parse(configText); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid TOML root"); + } catch { + return { + text: null, + reason: "config-unreadable", + bytes: 0, + model: null, + modelSource: configPath, + sourcePath: null, + representation: "unavailable", + catalogVersion: null, + effectiveSourcePath: null, + effectiveSourceKind: "catalog-default", + effectiveTextAvailable: false, + }; + } + const model = readRootTomlString(configText, "model"); const catalogPath = readCodexCatalogPathForHome(codexHome, configText); let catalog: ReturnType = null; @@ -688,9 +707,9 @@ export async function probePromptText( if (signal?.aborted) { return { ok: false, codexHome, layers: { "base-instructions": baseLayer }, base, detail: "prompt probe cancelled" }; } - const resolved = probeCommandForTests ? null : resolveCodexRuntime({ discoverAlternatives: false }); + const resolved = probeCommandForTests ? null : resolveCodexRuntime({ discoverAlternatives: false, probeVersion: false }); const runtime = resolved?.runtime; - const binary = probeCommandForTests?.binary ?? (runtime?.version ? runtime.command : null); + const binary = probeCommandForTests?.binary ?? runtime?.command ?? null; if (!binary) { const failure = resolved ? classifyRuntimeFailure(resolved) @@ -718,7 +737,9 @@ export async function probePromptText( }; const outcome = await runSharedPromptProbe(command, signal); if (outcome.kind !== "output") { - const failure = outcome.kind === "failed" ? outcome.failure : undefined; + const failure = outcome.kind === "failed" && outcome.failure && runtime + ? { ...outcome.failure, detail: `${runtime.source}: ${outcome.failure.detail}` } + : outcome.kind === "failed" ? outcome.failure : undefined; return { ok: false, codexHome, diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index a0c4439a45d..cec546b9040 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -75,6 +75,11 @@ export interface ResolveCodexRuntimeDeps { * newerAvailable discovery). Use for hot UI/status paths. */ discoverAlternatives?: boolean; + /** + * When false, select a candidate without synchronously running `--version`. + * The caller must validate the selected command asynchronously. + */ + probeVersion?: boolean; } export interface PersistedCodexRuntimeState { @@ -316,7 +321,7 @@ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): function probeVersion( command: string, deps: ResolveCodexRuntimeDeps, -): { ok: true; version: string } | { ok: false; reason: string } { +): { ok: true; version: string | null } | { ok: false; reason: string } { const platform = deps.platform ?? process.platform; if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) { const exists = deps.existsSync ?? existsSync; @@ -325,6 +330,7 @@ function probeVersion( return { ok: false, reason: "not a spawnable Codex launcher on this platform" }; } } + if (deps.probeVersion === false) return { ok: true, version: null }; const execFile = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile); // Sandbox the probe's CODEX_HOME: a real Codex CLI creates state (tmp/, logs) under // CODEX_HOME even for `--version`, and the probe inherits the caller's env — so a @@ -607,6 +613,7 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { path: env.PATH ?? "", platform: deps.platform ?? process.platform, discover: deps.discoverAlternatives !== false, + probeVersion: deps.probeVersion !== false, home: process.env.OPENCODEX_HOME ?? "", persisted: persistedRuntimeCacheStamp(deps), }); diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 7b3d4370f72..45dccb63e50 100644 --- a/tests/codex-integration/codex-prompt-text-probe.test.ts +++ b/tests/codex-integration/codex-prompt-text-probe.test.ts @@ -209,6 +209,39 @@ describe("base prompt source", () => { } }); + test("rejects malformed TOML before extracting a model or catalog", async () => { + const home = root(); + writeFileSync(join(home, "config.toml"), [ + 'model = "gpt-test"', + 'model_catalog_json = "catalog.json"', + "broken = [", + "", + ].join("\n")); + writeFileSync(join(home, "catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-test", base_instructions: "Should not be shown." }], + })); + const previousHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + setPromptTextProbeCommandForTests({ + binary: process.execPath, + args: ["-e", `process.stdout.write(${JSON.stringify(VALID_PROBE_OUTPUT)})`], + }); + try { + const result = await probePromptText(2_000); + expect(result.base).toMatchObject({ + text: null, + reason: "config-unreadable", + model: null, + representation: "unavailable", + effectiveTextAvailable: false, + }); + expect(result.layers["base-instructions"]).toMatchObject({ text: null, reason: "unavailable" }); + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousHome; + } + }); + test("reports an empty model instruction override as unavailable", async () => { const home = root(); const overridePath = join(home, "empty.md"); diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index 187448da235..a38b8d901f8 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -529,6 +529,24 @@ describe("resolveCodexRuntime", () => { expect(result.runtime.version).toBe("0.154.0-alpha.6.2"); }); + test("can select a runtime without synchronously probing its version", () => { + let probeCalls = 0; + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { CODEX_CLI_PATH: "C:\\codex\\codex.exe", PATH: "" }, + platform: "win32", + existsSync: () => true, + execFileSync: () => { + probeCalls += 1; + return "codex-cli 0.154.0"; + }, + probeVersion: false, + }); + expect(result.runtime.command).toBe("C:\\codex\\codex.exe"); + expect(result.runtime.version).toBeNull(); + expect(probeCalls).toBe(0); + }); + test("valid configured runtime beats shim and PATH", () => { const configDir = tempConfigDir(); persistCodexRuntime({ From e7d1ab7e58d6bdf1912676fbef8b64d9e37f986a Mon Sep 17 00:00:00 2001 From: Clive Rosfield <64878945+S0RYUASUKA@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:07:54 +0800 Subject: [PATCH 12/12] fix(codex): isolate nonblocking runtime resolution cache --- src/codex/runtime.ts | 30 ++++++ tests/codex-integration/codex-runtime.test.ts | 98 +++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index cec546b9040..dbf6120cde7 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -530,6 +530,12 @@ interface ResolveCacheMemo { readonly value: DeepReadonly; } +interface NonblockingResolveCacheMemo { + readonly key: string; + readonly at: number; + readonly value: DeepReadonly; +} + export type CodexRuntimeProcessCachePeek = | Readonly<{ kind: "available"; @@ -541,6 +547,7 @@ export type CodexRuntimeProcessCachePeek = let resolveCacheEpoch = 0; let resolveCache: ResolveCacheMemo | null = null; +let nonblockingResolveCache: NonblockingResolveCacheMemo | null = null; /** * Bumped whenever persisted runtime state is replaced or process authority is cleared. @@ -568,6 +575,7 @@ function publishResolveCache(key: string, at: number, value: ResolveCodexRuntime function clearResolveCache(): void { resolveCacheEpoch += 1; resolveCache = null; + nonblockingResolveCache = null; } /** Clear process-local runtime authority without resolving a replacement. */ @@ -614,6 +622,9 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { platform: deps.platform ?? process.platform, discover: deps.discoverAlternatives !== false, probeVersion: deps.probeVersion !== false, + localAppData: env.LOCALAPPDATA?.trim() ?? "", + homeDir: env.HOME?.trim() ?? "", + userProfile: env.USERPROFILE?.trim() ?? "", home: process.env.OPENCODEX_HOME ?? "", persisted: persistedRuntimeCacheStamp(deps), }); @@ -624,6 +635,25 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { */ export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult { const cacheKey = resolveCacheKey(deps); + // A prompt-only selection has no validated version and must not publish into runtime authority. + if (deps.probeVersion === false) { + if (cacheKey + && nonblockingResolveCache + && nonblockingResolveCache.key === cacheKey + && Date.now() - nonblockingResolveCache.at < RESOLVE_CACHE_MS) { + return cloneAndDeepFreeze(nonblockingResolveCache.value); + } + + const result = resolveCodexRuntimeUncached(deps); + if (!cacheKey) return cloneAndDeepFreeze(result); + nonblockingResolveCache = { + key: cacheKey, + at: Date.now(), + value: cloneAndDeepFreeze(result), + }; + return cloneAndDeepFreeze(nonblockingResolveCache.value); + } + if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) { return cloneAndDeepFreeze(resolveCache.value); } diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts index a38b8d901f8..1db4638ac76 100644 --- a/tests/codex-integration/codex-runtime.test.ts +++ b/tests/codex-integration/codex-runtime.test.ts @@ -547,6 +547,104 @@ describe("resolveCodexRuntime", () => { expect(probeCalls).toBe(0); }); + test("nonblocking resolution does not replace the authoritative process cache", () => { + const deps = { env: { PATH: "" }, discoverAlternatives: false }; + resetCodexRuntimeResolveCacheForTests(); + setCodexRuntimeResolveCacheForTests({ + runtime: { command: "validated-codex", version: "0.154.0", source: "path" }, + failures: [], + }, deps); + const before = peekCodexRuntimeProcessCache(); + expect(before.kind).toBe("available"); + + const selected = resolveCodexRuntime({ ...deps, probeVersion: false }); + expect(selected.runtime.version).toBeNull(); + expect(peekCodexRuntimeProcessCache()).toEqual(before); + }); + + test("nonblocking resolution separates installed-discovery environments", () => { + const previousOpenCodexHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = tempConfigDir(); + + const installWindows = (localAppData: string, name: string): string => { + const command = join(localAppData, "OpenAI", "Codex", "bin", name, "codex.exe"); + mkdirSync(dirname(command), { recursive: true }); + writeFileSync(command, ""); + return command; + }; + const installUnix = (home: string): string => { + const command = join(home, ".codex", "packages", "standalone", "current", "bin", "codex"); + mkdirSync(dirname(command), { recursive: true }); + writeFileSync(command, ""); + return command; + }; + + try { + const windowsRootA = tempConfigDir(); + const windowsRootB = tempConfigDir(); + const windowsA = installWindows(windowsRootA, "windows-a"); + const windowsB = installWindows(windowsRootB, "windows-b"); + const firstWindows = resolveCodexRuntime({ + env: { LOCALAPPDATA: windowsRootA, PATH: "" }, + platform: "win32", + discoverAlternatives: false, + probeVersion: false, + }); + const secondWindows = resolveCodexRuntime({ + env: { LOCALAPPDATA: windowsRootB, PATH: "" }, + platform: "win32", + discoverAlternatives: false, + probeVersion: false, + }); + expect(firstWindows.runtime.command).toBe(windowsA); + expect(secondWindows.runtime.command).toBe(windowsB); + + resetCodexRuntimeResolveCacheForTests(); + const homeA = tempConfigDir(); + const homeB = tempConfigDir(); + const unixA = installUnix(homeA); + const unixB = installUnix(homeB); + const firstHome = resolveCodexRuntime({ + env: { HOME: homeA, PATH: "" }, + platform: "linux", + discoverAlternatives: false, + probeVersion: false, + }); + const secondHome = resolveCodexRuntime({ + env: { HOME: homeB, PATH: "" }, + platform: "linux", + discoverAlternatives: false, + probeVersion: false, + }); + expect(firstHome.runtime.command).toBe(unixA); + expect(secondHome.runtime.command).toBe(unixB); + + resetCodexRuntimeResolveCacheForTests(); + const profileA = tempConfigDir(); + const profileB = tempConfigDir(); + const profileUnixA = installUnix(profileA); + const profileUnixB = installUnix(profileB); + const firstProfile = resolveCodexRuntime({ + env: { HOME: "", USERPROFILE: profileA, PATH: "" }, + platform: "linux", + discoverAlternatives: false, + probeVersion: false, + }); + const secondProfile = resolveCodexRuntime({ + env: { HOME: "", USERPROFILE: profileB, PATH: "" }, + platform: "linux", + discoverAlternatives: false, + probeVersion: false, + }); + expect(firstProfile.runtime.command).toBe(profileUnixA); + expect(secondProfile.runtime.command).toBe(profileUnixB); + } finally { + if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpenCodexHome; + resetCodexRuntimeResolveCacheForTests(); + } + }); + test("valid configured runtime beats shim and PATH", () => { const configDir = tempConfigDir(); persistCodexRuntime({