diff --git a/src/lib/codex-install.ts b/src/lib/codex-install.ts index 867b98b..2ae09a8 100644 --- a/src/lib/codex-install.ts +++ b/src/lib/codex-install.ts @@ -697,8 +697,42 @@ function testCodexCommand(): string[] | null { return parsed as string[]; } +// Memoized probe result. `null` = not yet probed; `boolean` = final answer for +// this process. Cleared via `resetCodexCliAvailabilityMemoForTests()` between +// test scenarios that install/uninstall codex under a synthetic PATH. +let codexCliAvailabilityMemo: boolean | null = null; + +/** Test-only: reset the memoized probe so PATH changes take effect. */ +export function resetCodexCliAvailabilityMemoForTests(): void { + codexCliAvailabilityMemo = null; +} + export function codexCliAvailable(): boolean { - return testCodexCommand() !== null || hasCommand("codex"); + // Test escape hatch: an explicit fixture command bypasses PATH resolution + // entirely and is always considered available. + if (testCodexCommand() !== null) return true; + if (codexCliAvailabilityMemo !== null) return codexCliAvailabilityMemo; + if (!hasCommand("codex")) { + codexCliAvailabilityMemo = false; + return false; + } + // A binary named `codex` is on PATH, but proxy shims (e.g. cmux CLI shims + // at $TMPDIR/cmux-cli-shims/.../codex) can pass the existence check while + // failing on invocation. Probe with `codex --version` before trusting the + // CLI for real plugin/marketplace queries. A 3s cap keeps a hung shim + // from stalling setup. + try { + const probe = Bun.spawnSync({ + cmd: ["codex", "--version"], + stdout: "ignore", + stderr: "ignore", + timeout: 3000, + }); + codexCliAvailabilityMemo = probe.exitCode === 0; + } catch { + codexCliAvailabilityMemo = false; + } + return codexCliAvailabilityMemo; } const REQUIRED_SOURCE_ARTIFACTS = [ diff --git a/tests/codex-install.test.ts b/tests/codex-install.test.ts index 1908ab2..89fff39 100644 --- a/tests/codex-install.test.ts +++ b/tests/codex-install.test.ts @@ -3830,3 +3830,55 @@ exit 0 240_000, ); }); + +describe.skipIf(process.platform === "win32")("codexCliAvailable — shim detection", () => { + test("PATH entry named 'codex' that exits non-zero is treated as unavailable", async () => { + // Simulates the cmux CLI shim case: a binary named `codex` is on PATH, + // but invocation fails. Bun.which passes; the version probe must not. + const { codexCliAvailable, resetCodexCliAvailabilityMemoForTests } = await import( + "../src/lib/codex-install.ts" + ); + const shimDir = await mkdtemp(join(tmpdir(), "cc-codex-shim-")); + try { + const shim = join(shimDir, "codex"); + // POSIX shim that mimics the cmux behavior: exits non-zero with a + // "codex not found" stderr message on every invocation. + await writeFile(shim, "#!/bin/sh\necho 'Error: codex not found in PATH' >&2\nexit 1\n"); + await chmod(shim, 0o755); + + const originalPath = process.env.PATH; + process.env.PATH = prependTestPath(shimDir); + resetCodexCliAvailabilityMemoForTests(); + try { + expect(codexCliAvailable()).toBe(false); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + resetCodexCliAvailabilityMemoForTests(); + } + } finally { + await rm(shimDir, { recursive: true, force: true }); + } + }); + + test("no codex on PATH → unavailable (fast path, no spawn)", async () => { + const { codexCliAvailable, resetCodexCliAvailabilityMemoForTests } = await import( + "../src/lib/codex-install.ts" + ); + const emptyDir = await mkdtemp(join(tmpdir(), "cc-codex-empty-")); + try { + const originalPath = process.env.PATH; + process.env.PATH = emptyDir; + resetCodexCliAvailabilityMemoForTests(); + try { + expect(codexCliAvailable()).toBe(false); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + resetCodexCliAvailabilityMemoForTests(); + } + } finally { + await rm(emptyDir, { recursive: true, force: true }); + } + }); +});