diff --git a/src/cli/codex-shim-readiness.ts b/src/cli/codex-shim-readiness.ts index 9abc759680b..37503c9cfd6 100644 --- a/src/cli/codex-shim-readiness.ts +++ b/src/cli/codex-shim-readiness.ts @@ -3,6 +3,8 @@ import { getCodexRoutingKind, type CodexRoutingKind, } from "../codex/inject"; +import { diagnoseCodexShim, type CodexShimDiagnostic } from "../codex/shim"; +import { findFirstCodexOnPath, type CodexPathCandidate } from "../codex/shim-path-resolution"; import { loadConfig, resolveEnvValue } from "../config"; const PROXY_ENV_KEYS = [ @@ -21,6 +23,82 @@ export interface CodexShimReadinessInputs { configuredProxyResolved: boolean; } +export type CodexConnectShimStatus = "ready" | "missing" | "unhealthy"; + +export interface CodexConnectShimReadiness { + status: CodexConnectShimStatus; + /** Secret-free, actionable text rendered by ocx connect. */ + message: string; +} + +export interface CodexConnectShimInspectionDeps { + diagnose?: () => Pick; + findOnPath?: () => CodexPathCandidate | null; +} + +const CODEX_TOKEN_NOTE = "The connected Codex config uses OPENCODEX_API_AUTH_TOKEN; " + + "without a working shim or when a PATH wrapper replaces it, Codex may fail with " + + '"Missing environment variable".'; +const CODEX_TOKEN_ACTION = `${CODEX_TOKEN_NOTE} Run 'ocx codex-shim install' to repair it.`; + +export function codexConnectShimReadiness(inputs: { + diagnosis: Pick; + commandPath: string | null; + commandIsShim?: boolean; +}): CodexConnectShimReadiness { + if (inputs.diagnosis.installed && !inputs.diagnosis.healthy) { + return { + status: "unhealthy", + message: `installed but unhealthy: ${inputs.diagnosis.summary}. ${CODEX_TOKEN_ACTION}`, + }; + } + if (inputs.diagnosis.healthy && inputs.commandPath && !inputs.commandIsShim) { + // The shim is installed and healthy, so reinstalling it cannot fix this: only PATH + // order (or the wrapper in front of it) can. + return { + status: "missing", + message: `not active; PATH resolves 'codex' to ${inputs.commandPath}, not an OpenCodex shim. ` + + `Shim state: ${inputs.diagnosis.summary}. Put the directory of the tracked shim ahead of that command on PATH, ` + + `or remove that wrapper; re-running 'ocx codex-shim install' will not change PATH order. ${CODEX_TOKEN_NOTE}`, + }; + } + if (inputs.diagnosis.installed && inputs.diagnosis.healthy) { + return { status: "ready", message: "installed and healthy" }; + } + if (inputs.commandPath) { + return { + status: "missing", + message: `not active; PATH resolves 'codex' to ${inputs.commandPath}, not an OpenCodex shim. ${CODEX_TOKEN_ACTION}`, + }; + } + return { + status: "missing", + message: `not installed and no 'codex' executable was found on PATH. ${CODEX_TOKEN_ACTION}`, + }; +} + +export function inspectCodexShimForConnect( + deps: CodexConnectShimInspectionDeps = {}, +): CodexConnectShimReadiness { + let diagnosis: Pick; + try { + diagnosis = (deps.diagnose ?? diagnoseCodexShim)(); + } catch { + diagnosis = { installed: true, healthy: false, summary: "diagnostic state could not be read" }; + } + let command: CodexPathCandidate | null = null; + try { + command = (deps.findOnPath ?? findFirstCodexOnPath)(); + } catch { + // The diagnosis above still gives the operator a repair path. + } + return codexConnectShimReadiness({ + diagnosis, + commandPath: command?.path ?? null, + commandIsShim: command?.isShim ?? false, + }); +} + function externalProviderLabel(provider: string | null): string { return provider ? `external model_provider ${JSON.stringify(provider)}` diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 04665ee6549..bb248d8c31c 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -40,6 +40,10 @@ import { terminalSafeText, type RuntimeApiDeps, } from "./runtime-api"; +import { + inspectCodexShimForConnect, + type CodexConnectShimReadiness, +} from "./codex-shim-readiness"; export interface ClientCommandDeps extends RuntimeApiDeps { lifecycleLockDeps?: ClientLifecycleLockDeps; @@ -277,6 +281,19 @@ export type ConnectCompletionReport = { readonly failure: string | null; }; +export function codexShimReadinessLine( + selectedClients: readonly OcxConnectedClientId[], + inspect: () => CodexConnectShimReadiness = inspectCodexShimForConnect, +): string | null { + if (!selectedClients.includes("codex")) return null; + return `Codex autostart shim: ${terminalSafeText(inspect().message)}`; +} + +function printCodexShimReadiness(selectedClients: readonly OcxConnectedClientId[]): void { + const line = codexShimReadinessLine(selectedClients); + if (line) console.log(line); +} + /** * What `ocx connect` says once the hub and the credential are settled, and whether the command * still fails (#4207). @@ -414,6 +431,7 @@ async function runHubConnect(argv: string[], deps: ClientCommandDeps): Promise//...`; root defaults to /mnt, configurable via + * /etc/wsl.conf [automount] root). + */ +export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean { + const root = automountRoot.replace(/\/+$/, ""); + const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir); +} + +export type CodexPathScanDeps = { + pathValue?: string; + wsl?: boolean; + /** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */ + posixPaths?: boolean; + automountRoot?: string; + exists?: (path: string) => boolean; + isShimFile?: (path: string) => boolean; + isDirectory?: (path: string) => boolean; + realpath?: (path: string) => string; +}; + +export type CodexPathCandidate = { + path: string; + isShim: boolean; +}; + +export function realIsDirectory(path: string): boolean { + try { + return lstatSync(path).isDirectory(); + } catch { + return true; // unreadable -> treat as unusable + } +} + +function isShimFile(path: string): boolean { + try { + return readFileSync(path, "utf8").includes(SHIM_MARKER); + } catch { + return false; + } +} + +function commandNames(name: string): string[] { + if (process.platform !== "win32") return [name]; + const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean); + return [name, ...exts.flatMap(ext => [`${name}${ext.toLowerCase()}`, `${name}${ext.toUpperCase()}`])]; +} + +/** The shell-local bin directory that fnm creates for one interactive shell. */ +export function isFnmMultishellPath(path: string, posixPaths: boolean): boolean { + const normalized = (posixPaths + ? posix.normalize(path) + : win32.normalize(path).replace(/\\/g, "/")).toLowerCase(); + return /(?:^|\/)fnm_multishells?(?:\/|$)/.test(normalized); +} + +/** Find the command the shell would actually use, including an OpenCodex shim. */ +export function findFirstCodexOnPath(deps: CodexPathScanDeps = {}): CodexPathCandidate | null { + const exists = deps.exists ?? existsSync; + const shimFile = deps.isShimFile ?? isShimFile; + const isDir = deps.isDirectory ?? realIsDirectory; + const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime()); + const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32"); + const joinPath = usePosix ? posix.join : win32.join; + const pathSep = usePosix ? ":" : delimiter; + const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt"); + const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"]; + + for (const dir of (deps.pathValue ?? process.env.PATH ?? "").split(pathSep).filter(Boolean)) { + if (wsl && isWindowsInteropDir(dir, automountRoot)) continue; + const names = isWindowsInteropDir(dir, automountRoot) ? interopNames : commandNames("codex"); + for (const name of names) { + const path = joinPath(dir, name); + if (!exists(path) || isDir(path)) continue; + return { path, isShim: shimFile(path) }; + } + } + return null; +} + +/** + * Resolve a Codex command discovered through fnm's temporary PATH entry. + * + * A temporary path is safe to use only when its physical target is outside the + * temporary tree. Returning null is deliberate: callers must not wrap the + * shell-local path or silently choose a lower-priority Codex installation. + */ +export function resolveStableFnmCodexPath( + path: string, + posixPaths: boolean, + realpath: ((path: string) => string) | undefined = realpathSync.native, +): string | null { + if (!isFnmMultishellPath(path, posixPaths)) return path; + try { + // fnm normally links the temporary directory to the durable installation, + // while npm may link the command file again into its package internals. + // Resolve only the directory so the command basename remains the stable + // installation entry that the installer is allowed to replace. + const pathTools = posixPaths ? posix : win32; + const resolvedParent = realpath(pathTools.dirname(path)); + if (!resolvedParent || isFnmMultishellPath(resolvedParent, posixPaths) || !pathTools.isAbsolute(resolvedParent)) return null; + return pathTools.join(resolvedParent, pathTools.basename(path)); + } catch { + return null; + } +} diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 2fa48ab88e8..e218fa500b8 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -8,7 +8,7 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { basename, delimiter, dirname, extname, join, posix } from "node:path"; +import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path"; import { durableBunRuntime } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; @@ -53,12 +53,20 @@ import { type UnixShimProbeResult, } from "./shim-probe"; import { tryAcquireShimRestoreLock } from "./shim-restore-lock"; +import { + isWindowsInteropDir, + realIsDirectory, + resolveStableFnmCodexPath, + type CodexPathScanDeps, +} from "./shim-path-resolution"; export { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "./shim-templates"; export { isVersionManagerOwnedCodexPath } from "./shim-fingerprint"; export { CODEX_SHIM_STATE_MAX_BYTES } from "./shim-state-file"; export { setCodexShimProbeHookForTests, setCodexShimProbeShellForTests, setCodexShimProbeObservationMsForTests } from "./shim-probe"; export type { CodexShimBackingForCommand } from "./shim-inspect"; +export type { CodexPathScanDeps } from "./shim-path-resolution"; +export { isWindowsInteropDir } from "./shim-path-resolution"; export { isLocalAbsoluteInspectionPath, inspectCodexShimBackingForCommand } from "./shim-inspect"; export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100; @@ -75,6 +83,17 @@ interface InstallCodexShimInternalOptions { beforeGuardedRefresh?: (wrapperPath: string, index: number) => void; } +function discoveredCodexPath(path: string, posixPaths: boolean, realpath?: (path: string) => string): string | null { + const resolved = resolveStableFnmCodexPath(path, posixPaths, realpath); + if (resolved !== null) return resolved; + lastShimDiscoveryError = truncateRetainedUtf8( + `Found codex at ${path} inside fnm's temporary multishell path, but it did not resolve to a durable Node installation. ` + + "Refusing to install a shim; retry after fnm selects a stable installation.", + MAX_DIAGNOSTIC_VALUE_BYTES, + ); + return null; +} + export type CodexShimAutoRestoreResult = | { status: "not-installed" | "healthy" | "disabled" } | { status: "ineligible" | "deferred"; message?: string } @@ -115,36 +134,6 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean { } } -/** - * A PATH entry that reaches Windows through WSL drive interop - * (`//...`; root defaults to /mnt, configurable via - * /etc/wsl.conf [automount] root). - */ -export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean { - const root = automountRoot.replace(/\/+$/, ""); - const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir); -} - -export type CodexPathScanDeps = { - pathValue?: string; - wsl?: boolean; - /** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */ - posixPaths?: boolean; - automountRoot?: string; - exists?: (path: string) => boolean; - isShimFile?: (path: string) => boolean; - isDirectory?: (path: string) => boolean; -}; - -function realIsDirectory(path: string): boolean { - try { - return lstatSync(path).isDirectory(); - } catch { - return true; // unreadable -> treat as unusable - } -} - export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null { lastShimDiscoveryError = null; const exists = deps.exists ?? existsSync; @@ -152,8 +141,10 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null { const isDir = deps.isDirectory ?? realIsDirectory; const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime()); const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32"); - const joinPath = usePosix ? posix.join : join; - const pathSep = usePosix ? ":" : delimiter; + // The injected path flavour governs join and separator alike; host-native `join` and + // `delimiter` made a Windows-flavoured scan split `C:\...` on a Linux host. + const joinPath = usePosix ? posix.join : win32.join; + const pathSep = usePosix ? ":" : win32.delimiter; const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt"); // Windows npm prefixes ship codex.exe/codex.cmd next to the extensionless sh launcher. const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"]; @@ -176,7 +167,11 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null { for (const name of names) { const path = joinPath(dir, name); if (!exists(path) || shimFile(path)) continue; - if (!isDir(path)) return path; + if (!isDir(path)) { + const resolved = discoveredCodexPath(path, usePosix, deps.realpath); + if (resolved !== null) return resolved; + return null; + } } } @@ -195,11 +190,13 @@ function findWindowsCodexTargets(): ShimFileState[] | null { lastShimDiscoveryError = null; for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { const exe = join(dir, "codex.exe"); - if (existsSync(exe) && !isShim(exe)) { + const stableExe = existsSync(exe) && !isShim(exe) ? discoveredCodexPath(exe, false) : exe; + if (stableExe === null) return null; + if (existsSync(stableExe) && !isShim(stableExe)) { try { - if (!lstatSync(exe).isDirectory()) { + if (!lstatSync(stableExe).isDirectory()) { lastShimDiscoveryError = truncateRetainedUtf8( - `Found codex.exe at ${exe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` + + `Found codex.exe at ${stableExe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` + "install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.", MAX_DIAGNOSTIC_VALUE_BYTES, ); @@ -216,9 +213,12 @@ function findWindowsCodexTargets(): ShimFileState[] | null { const targets: ShimFileState[] = []; for (const path of [cmd, ps1, gitBashLauncher]) { if (!existsSync(path) || isShim(path)) continue; + const stablePath = discoveredCodexPath(path, false); + if (stablePath === null) return null; + if (!existsSync(stablePath) || isShim(stablePath)) continue; try { - if (!lstatSync(path).isDirectory()) { - targets.push({ wrapperPath: path, originalPath: path, backupPath: backupPathFor(path) }); + if (!lstatSync(stablePath).isDirectory()) { + targets.push({ wrapperPath: stablePath, originalPath: stablePath, backupPath: backupPathFor(stablePath) }); } } catch { /* keep scanning */ } } diff --git a/structure/runtime.md b/structure/runtime.md index acfc118a3f9..836e7f28fa4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -141,7 +141,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes and route resolution. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | -| `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint. | +| `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint. Discovery resolves fnm's temporary `fnm_multishells` directory to the durable installation directory (`src/codex/shim-path-resolution.ts` takes the realpath of the directory only and keeps the command basename, so npm's command link is never followed into the package file) and refuses to shim when it cannot; the scan's injected path flavour governs join and separator alike. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. The scheduler wrapper logs transactional-backup recovery without interpolating backup names. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. The Task Scheduler batch wrapper (`src/service/windows-taskxml.ts`) checks bundled Bun against `REAL_BUN_MIN_BYTES` before each launch: npm's placeholder, or a file that vanished before its size was read, is logged and re-checked every five seconds instead of executed, while a Bun path missing at the exist check keeps the backup-restore then exit-3 path ([wrapper contract](ops/docs-and-release.md#windows-service-wrapper-and-incomplete-updates)). | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects @@ -354,7 +354,7 @@ The server exposes `POST /api/stop` which restores native Codex config (a siblin `src/cli/connect.ts` resolves only through the first valid local Codex runtime for catalog readiness, then reads that runtime's effort ladder without persisting its selection. Rejected preferred candidates still fall back in priority order. General `ocx status` retains full runtime -discovery and passes its resolved command into readiness, avoiding a second version probe without adding cache state. +discovery and passes its resolved command into readiness, avoiding a second version probe without adding cache state. `ocx connect` also prints a secret-free Codex shim readiness line from `src/cli/codex-shim-readiness.ts`: ready, unhealthy, or missing when the first `PATH` hit for `codex` is not an OpenCodex shim. `ocx config show` stays outside that lifecycle path. `src/cli/config-command.ts` reads the validated config snapshot and the bounded service-token observation needed for its `_remoteHub` annotation; diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts index 463e3d42b81..72c1970da4a 100644 --- a/tests/cli/cli-connect-readiness.test.ts +++ b/tests/cli/cli-connect-readiness.test.ts @@ -20,7 +20,9 @@ import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmColdSpawn } from "../helpers/cold import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; -import { connectCompletionReport } from "../../src/cli/connect"; +import { codexShimReadinessLine, connectCompletionReport } from "../../src/cli/connect"; +import { codexConnectShimReadiness } from "../../src/cli/codex-shim-readiness"; +import { findFirstCodexOnPath } from "../../src/codex/shim-path-resolution"; import { dispatchCommand } from "../../src/cli/dispatch"; import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { ClientCatalogIncompatibleError } from "../../src/client/catalog-compatibility"; @@ -626,6 +628,104 @@ describe("#4207 what ocx connect reports when the local CLI cannot use the catal }); }); +describe("Codex shim readiness on connect", () => { + test("a healthy shim is reported as ready", () => { + const readiness = codexConnectShimReadiness({ + diagnosis: { installed: true, healthy: true, summary: "unused" }, + commandPath: null, + }); + expect(readiness).toEqual({ status: "ready", message: "installed and healthy" }); + expect(codexShimReadinessLine(["codex"], () => readiness)).toBe("Codex autostart shim: installed and healthy"); + }); + + test("a healthy shim remains ready when it is first on PATH", () => { + const shim = "/home/u/.npm-global/bin/codex"; + const native = "/opt/homebrew/bin/codex"; + const candidate = findFirstCodexOnPath({ + pathValue: `/home/u/.npm-global/bin:/opt/homebrew/bin`, + wsl: false, + posixPaths: true, + exists: path => path === shim || path === native, + isShimFile: path => path === shim, + isDirectory: () => false, + }); + expect(candidate).toEqual({ path: shim, isShim: true }); + + const readiness = codexConnectShimReadiness({ + diagnosis: { installed: true, healthy: true, summary: "unused" }, + commandPath: candidate?.path ?? null, + commandIsShim: candidate?.isShim, + }); + expect(readiness).toEqual({ status: "ready", message: "installed and healthy" }); + }); + + test("a user wrapper before the shim is reported as shadowing it", () => { + const wrapper = "/home/u/.local/bin/codex"; + const shim = "/home/u/.npm-global/bin/codex"; + const candidate = findFirstCodexOnPath({ + pathValue: `/home/u/.local/bin:/home/u/.npm-global/bin`, + wsl: false, + posixPaths: true, + exists: path => path === wrapper || path === shim, + isShimFile: path => path === shim, + isDirectory: () => false, + }); + expect(candidate).toEqual({ path: wrapper, isShim: false }); + + const readiness = codexConnectShimReadiness({ + diagnosis: { installed: true, healthy: true, summary: "unused" }, + commandPath: candidate?.path ?? null, + commandIsShim: candidate?.isShim, + }); + expect(readiness.status).toBe("missing"); + expect(readiness.message).toContain(wrapper); + }); + + test("a missing shim or user PATH wrapper gives a secret-free repair hint", () => { + const readiness = codexConnectShimReadiness({ + diagnosis: { installed: false, healthy: false, summary: "Codex autostart shim is not installed." }, + commandPath: "/home/u/.local/bin/codex-wrapper", + }); + expect(readiness.status).toBe("missing"); + expect(readiness.message).toContain("codex-wrapper"); + expect(readiness.message).toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(readiness.message).toContain("Missing environment variable"); + expect(readiness.message).toContain("ocx codex-shim install"); + expect(readiness.message).not.toContain("ocx_data_"); + + const shadowed = codexConnectShimReadiness({ + diagnosis: { installed: true, healthy: true, summary: "unused" }, + commandPath: "/home/u/bin/codex-wrapper", + }); + expect(shadowed.status).toBe("missing"); + expect(shadowed.message).toContain("PATH resolves"); + // An installed, healthy shim cannot be repaired by reinstalling: say so and carry the + // diagnostic summary instead of the generic "run install" advice. + expect(shadowed.message).toContain("Shim state: unused"); + expect(shadowed.message).toContain("will not change PATH order"); + expect(shadowed.message).not.toContain("to repair it"); + }); + + test("an installed but unhealthy shim is actionable and does not print a token", () => { + const readiness = codexConnectShimReadiness({ + diagnosis: { installed: true, healthy: false, summary: "wrapper missing; original backup present" }, + commandPath: null, + }); + expect(readiness.status).toBe("unhealthy"); + expect(readiness.message).toContain("wrapper missing"); + expect(readiness.message).toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(readiness.message).toContain("ocx codex-shim install"); + expect(readiness.message).not.toContain("secret-token"); + }); + + test("Claude-only connect never inspects or reports Codex shim state", () => { + const line = codexShimReadinessLine(["claude"], () => { + throw new Error("Codex shim must not be inspected for Claude-only connect"); + }); + expect(line).toBeNull(); + }); +}); + /** * #4451 review: escaping first-time `ocx connect` left the routine path open. An already-connected * client refreshes with `ocx sync`, and that runner catches the same catalog-derived diff --git a/tests/codex-integration/codex-shim.test.ts b/tests/codex-integration/codex-shim.test.ts index 4c4c8fc728c..2ab8805c6bf 100644 --- a/tests/codex-integration/codex-shim.test.ts +++ b/tests/codex-integration/codex-shim.test.ts @@ -568,6 +568,56 @@ exit 126 }, ); + test.skipIf(process.platform === "win32")( + "Unix fnm multishell install records only the durable node installation target", + () => { + const root = mkdtempSync(join(tmpdir(), "ocx-shim-install-fnm-")); + const home = join(root, "opencodex-home"); + const multishellDir = join(root, "fnm_multishells", "619109"); + const multishellBin = join(multishellDir, "bin"); + const stableInstallation = join(root, "node-versions", "v24.20.0", "installation"); + const stableBin = join(stableInstallation, "bin"); + const packageBin = join(stableInstallation, "lib", "node_modules", "@openai", "codex", "bin"); + const stableCodex = join(stableBin, "codex"); + const packageCodex = join(packageBin, "codex.js"); + const multishellCodex = join(multishellBin, "codex"); + const oldPath = process.env.PATH; + const oldHome = process.env.OPENCODEX_HOME; + try { + mkdirSync(home, { recursive: true }); + mkdirSync(join(root, "fnm_multishells"), { recursive: true }); + mkdirSync(stableBin, { recursive: true }); + mkdirSync(packageBin, { recursive: true }); + writeFileSync(packageCodex, successfulLauncher("fnm-stable-codex"), "utf8"); + chmodSync(packageCodex, 0o755); + symlinkSync(stableInstallation, multishellDir); + symlinkSync("../lib/node_modules/@openai/codex/bin/codex.js", stableCodex); + process.env.PATH = prependPath(multishellBin, oldPath); + process.env.OPENCODEX_HOME = home; + + const installed = installCodexShim(); + const state = readFileSync(join(home, "codex-shim.json"), "utf8"); + + expect(installed.installed).toBe(true); + expect(installed.message).toContain(stableCodex); + expect(installed.message).not.toContain("fnm_multishells"); + expect(state).toContain(stableCodex); + expect(state).not.toContain("fnm_multishells"); + expect(state).not.toContain(packageCodex); + expect(readFileSync(stableCodex, "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(multishellCodex, "utf8")).toContain(SHIM_MARKER); + expect(readFileSync(packageCodex, "utf8")).toContain("fnm-stable-codex"); + expect(lstatSync(`${stableCodex}.opencodex-real`).isSymbolicLink()).toBe(true); + } finally { + if (oldPath === undefined) delete process.env.PATH; + else process.env.PATH = oldPath; + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(root); + } + }, + ); + test("Unix install rejects a launcher that leaves a background descendant", () => { if (process.platform === "win32") return; @@ -2211,6 +2261,72 @@ describe("WSL PATH interop guard", () => { }); expect(found).toBe(`${dir}/codex`); }); + + test("fnm multishell discovery resolves to the durable node installation", () => { + const multishell = "/home/u/.local/share/fnm/fnm_multishells/619109/bin"; + const stable = "/home/u/.local/share/fnm/node-versions/v24.20.0/installation/bin"; + const transientCodex = `${multishell}/codex`; + const stableCodex = `${stable}/codex`; + const found = findCodexOnPath({ + pathValue: multishell, + posixPaths: true, + exists: path => path === transientCodex, + isShimFile: () => false, + isDirectory: () => false, + realpath: path => path === multishell ? stable : path, + }); + expect(found).toBe(stableCodex); + expect(found).not.toContain("fnm_multishells"); + }); + + test("Windows-style fnm multishell discovery also resolves the durable target", () => { + const multishell = "C:\\Users\\u\\AppData\\Local\\fnm_multishells\\619109\\bin"; + const stable = "C:\\Users\\u\\AppData\\Local\\fnm\\node-versions\\v24.20.0\\installation\\bin"; + const transientCodex = `${multishell}\\codex`; + const stableCodex = `${stable}\\codex`; + const found = findCodexOnPath({ + pathValue: multishell, + posixPaths: false, + exists: path => path === transientCodex, + isShimFile: () => false, + isDirectory: () => false, + realpath: path => path === multishell ? stable : path, + }); + expect(found).toBe(stableCodex); + expect(found).not.toContain("fnm_multishells"); + }); + + test("fnm multishell discovery refuses an unresolved or still-temporary target", () => { + const multishell = "/home/u/.local/share/fnm/fnm_multishells/619109/bin"; + const transientCodex = `${multishell}/codex`; + const fallback = "/usr/local/bin/codex"; + const found = findCodexOnPath({ + pathValue: `${multishell}:/usr/local/bin`, + posixPaths: true, + exists: path => path === transientCodex || path === fallback, + isShimFile: () => false, + isDirectory: () => false, + realpath: path => path === multishell ? multishell : path, + }); + expect(found).toBeNull(); + expect(lastCodexDiscoveryError()).toContain("Refusing to install a shim"); + expect(lastCodexDiscoveryError()).toContain("durable Node installation"); + expect(lastCodexDiscoveryError()).not.toContain(fallback); + }); + + test("stable nvm installation paths are not treated as fnm temporary paths", () => { + const nvmBin = "/home/u/.nvm/versions/node/v24.20.0/bin"; + const nvmCodex = `${nvmBin}/codex`; + const found = findCodexOnPath({ + pathValue: nvmBin, + posixPaths: true, + exists: path => path === nvmCodex, + isShimFile: () => false, + isDirectory: () => false, + realpath: () => { throw new Error("non-fnm paths must not be realpathed"); }, + }); + expect(found).toBe(nvmCodex); + }); }); // #2412: a version-manager upgrade (mise/asdf/volta) rewrites its install tree