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 d0926a061e5..b157cbdbda6 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 { 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"; +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,31 +80,254 @@ 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" + | "config-not-found" + | "config-unreadable" + | "model-not-selected" + | "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; + 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; } -/** 8 MiB is far above any real prompt and far below anything that hurts the server. */ +/** 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 { + let descriptor: number | undefined; + try { + 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; + 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 */ } + } + } +} + +function readBasePrompt(codexHome: string): BasePromptText { + const configPath = join(codexHome, "config.toml"); + let configText: string; + try { + configText = readBoundedPromptSource(configPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + return { + text: null, + reason: code === "ENOENT" ? "config-not-found" : code === "EFBIG" ? "config-too-large" : "config-unreadable", + bytes: 0, + model: null, + modelSource: configPath, + sourcePath: null, + representation: "unavailable", + catalogVersion: null, + effectiveSourcePath: null, + effectiveSourceKind: "catalog-default", + effectiveTextAvailable: false, + }; + } + + 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; + 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"], + 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 = readBoundedPromptSource(overridePath); + if (text.trim().length === 0) { + return unavailable("override-empty", overridePath, overridePath, "model-instructions-file"); + } + 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 (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + return unavailable( + code === "ENOENT" ? "override-not-found" : code === "EFBIG" ? "override-too-large" : "override-unreadable", + overridePath, + overridePath, + "model-instructions-file", + ); + } + } + + if (!catalog) return unavailable(catalogReason, catalogPath); + 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; + 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, + }; +} + interface ProbeCommand { binary: string; args: string[]; @@ -111,7 +339,7 @@ interface ProbeCommand { interface PromptProbeFlight { key: string; controller: AbortController; - result: Promise; + result: Promise; closed: Promise; waiters: number; joinable: boolean; @@ -120,13 +348,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 +377,72 @@ 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 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|path does not exist/.test(representativeReason) + ? "program-not-found" + : /failed --version|probe sandbox unavailable/.test(representativeReason) + ? "execution-failed" + : "program-not-found"; + return executionFailure( + { binary: representative?.command ?? 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 +460,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 +496,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 +507,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 +525,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 +560,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 +604,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 +632,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 +702,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, probeVersion: false }); + const runtime = resolved?.runtime; + const binary = probeCommandForTests?.binary ?? 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 +737,16 @@ export async function probePromptText( }; const outcome = await runSharedPromptProbe(command, signal); if (outcome.kind !== "output") { + 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, - layers: {}, + layers: { "base-instructions": baseLayer }, + base, + ...(runtime ? { runtime } : {}), + ...(failure ? { failure } : {}), detail: signal?.aborted ? "prompt probe cancelled" : outcome.kind === "busy" @@ -419,7 +759,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 +793,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 +801,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..dbf6120cde7 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, rmSync, unlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; @@ -10,6 +10,7 @@ export type CodexRuntimeSource = | "environment" | "configured" | "shim" + | "installed" | "path" | "fallback"; @@ -74,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 { @@ -88,6 +94,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 => { @@ -111,6 +118,7 @@ function isCodexRuntimeSource(value: unknown): value is CodexRuntimeSource { return value === "environment" || value === "configured" || value === "shim" + || value === "installed" || value === "path" || value === "fallback"; } @@ -313,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; @@ -322,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 @@ -350,6 +359,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})` }; @@ -401,6 +413,40 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] { return [...new Set(out)]; } +/** Codex installs that are not necessarily exposed through PATH. */ +function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] { + 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 { command: string; source: CodexRuntimeSource; @@ -484,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"; @@ -495,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. @@ -522,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. */ @@ -567,6 +621,10 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { path: env.PATH ?? "", 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), }); @@ -577,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); } @@ -623,6 +700,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(); @@ -644,7 +724,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/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"); diff --git a/tests/codex-integration/codex-prompt-text-probe.test.ts b/tests/codex-integration/codex-prompt-text-probe.test.ts index 32b01eac3c9..45dccb63e50 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 { chmodSync, 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 }] }]); @@ -79,6 +80,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 +186,331 @@ describe("section extraction", () => { }); }); +describe("base prompt source", () => { + test("distinguishes missing, unreadable, and unselected config states", 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"); + 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; + } + }); + + 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"); + 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.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", + 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."); + }); + }); + + 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; + } + } + }); + + 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; + } + } + }); + + 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; + } + }); + + 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", () => { 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..1db4638ac76 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, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { @@ -466,6 +466,185 @@ 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("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("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("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("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({ diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 5daaa02ec9c..3e1e6c218d8 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%"');