Skip to content
Merged
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
50 changes: 49 additions & 1 deletion src/lib/codex-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,8 +697,56 @@ 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` and require the
// output to actually look like a codex version banner, because at least
// one shim (cmux) prints "Error: codex not found in PATH" and still exits
// 0 — exit code alone is not enough. A 3s cap keeps a hung shim from
// stalling setup.
try {
const probe = Bun.spawnSync({
cmd: ["codex", "--version"],
stdout: "pipe",
stderr: "pipe",
timeout: 3000,
});
const stdout = probe.stdout ? new TextDecoder().decode(probe.stdout) : "";
codexCliAvailabilityMemo = probe.exitCode === 0 && looksLikeCodexVersion(stdout);
} catch {
codexCliAvailabilityMemo = false;
}
return codexCliAvailabilityMemo;
}

/** Real `codex --version` prints a line like `codex-cli 0.15.2` or
* `codex 0.15.2` — a leading `codex` token followed by a semver-ish
* number. Shims that swallow the invocation with an error message do
* not match. Kept as a fragment match so a future banner prefix (e.g.
* `codex 0.16.0 (release build)`) still passes. Exported for direct
* unit tests because the surrounding probe (spawn + Bun.which) reads
* PATH via a boot-time snapshot that tests cannot override. */
export function looksLikeCodexVersion(output: string): boolean {
return /\bcodex[\w-]*\s+\d+\.\d+/i.test(output);
}

const REQUIRED_SOURCE_ARTIFACTS = [
Expand Down
38 changes: 35 additions & 3 deletions src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,9 +1334,30 @@ async function cmdStatus(sourceDir: string): Promise<number> {
return 0; // status is informational; never fail
}

function resolveInstallTarget(target: InstallTarget): Exclude<InstallTarget, "auto"> {
/**
* Auto-detect used to silently pick `both` whenever a `codex` binary was on
* PATH — surprise-installing cc-settings for Codex when the user only wanted
* Claude, and hard-failing when the binary was a proxy shim (cmux drops one
* under $TMPDIR/cmux-cli-shims that exits 0 while printing "codex not found
* in PATH"). The Codex install is now opt-in on the `auto` path:
* ask, with the probe result as the default. Non-interactive callers (CI,
* piped input) fall through to the default silently — never Codex without
* explicit consent.
*
* Explicit `--target=claude|codex|both` bypasses the prompt entirely.
*/
async function resolveInstallTarget(
target: InstallTarget,
): Promise<Exclude<InstallTarget, "auto">> {
if (target !== "auto") return target;
return hasCommand("codex") ? "both" : "claude";
const codexLooksInstalled = codexCliAvailable();
const wantsCodex = await promptYn(
codexLooksInstalled
? "Codex detected. Install cc-settings for Codex too?"
: "Also install cc-settings for the Codex CLI? (only if you use Codex)",
codexLooksInstalled,
);
return wantsCodex ? "both" : "claude";
}

function includesTarget(
Expand Down Expand Up @@ -1928,7 +1949,7 @@ async function main(): Promise<number> {
for (const message of args.errors) error(message);
return 1;
}
let target = resolveInstallTarget(args.target);
let target = await resolveInstallTarget(args.target);
if (includesTarget(target, "codex")) {
await validateProductRootDisjointness(CLAUDE_DIR);
}
Expand Down Expand Up @@ -2314,6 +2335,17 @@ if (import.meta.main) {
.join("\n")
: String(err);
error(`Setup failed: ${detail}`);
// AggregateError.errors carries the real causes — without unwrapping,
// the outer wrapper message swallows them and leaves the operator with
// no way to see what actually failed (e.g. restoreCombinedAfterClaudeFailure
// wraps the underlying Claude install error plus any restore failures).
if (err instanceof AggregateError && Array.isArray(err.errors)) {
for (const [i, cause] of err.errors.entries()) {
const causeDetail =
cause instanceof Error ? (cause.stack ?? cause.message) : String(cause);
error(` cause[${i}]: ${causeDetail}`);
}
}
process.exit(1);
});
}
97 changes: 97 additions & 0 deletions tests/codex-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3830,3 +3830,100 @@ 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("shim that exits 0 with a bogus stdout is treated as unavailable", async () => {
// Real cmux shim behavior observed on macOS: `codex --version` prints
// "Error: codex not found in PATH" to STDOUT and exits 0. Exit code alone
// said "installed", the version-shape check now catches it.
const { codexCliAvailable, resetCodexCliAvailabilityMemoForTests } = await import(
"../src/lib/codex-install.ts"
);
const shimDir = await mkdtemp(join(tmpdir(), "cc-codex-shim-exit0-"));
try {
const shim = join(shimDir, "codex");
await writeFile(shim, "#!/bin/sh\necho 'Error: codex not found in PATH'\nexit 0\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 });
}
});

// Direct pure-function coverage for the version-shape check, because the
// enclosing spawn+Bun.which path reads PATH from a boot-time snapshot that
// tests cannot repoint at a fixture stub. Without this, no test would
// catch a future over-tightening of the regex that rejects real versions.
test("looksLikeCodexVersion accepts real banners, rejects shim errors", async () => {
const { looksLikeCodexVersion } = await import("../src/lib/codex-install.ts");
// Real banner shapes seen in the wild.
expect(looksLikeCodexVersion("codex-cli 0.15.2\n")).toBe(true);
expect(looksLikeCodexVersion("codex 0.16.0")).toBe(true);
expect(looksLikeCodexVersion("codex 0.16.0 (release build)\n")).toBe(true);
// The cmux shim behavior: prints an error to stdout and exits 0.
expect(looksLikeCodexVersion("Error: codex not found in PATH\n")).toBe(false);
// Empty / unrelated output.
expect(looksLikeCodexVersion("")).toBe(false);
expect(looksLikeCodexVersion("bash: codex: command not found\n")).toBe(false);
});

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