Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/lib/codex-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
52 changes: 52 additions & 0 deletions tests/codex-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
Loading