diff --git a/bin/cli/commands/launch-codex.mjs b/bin/cli/commands/launch-codex.mjs index f00cae7d2b..88e678c56a 100644 --- a/bin/cli/commands/launch-codex.mjs +++ b/bin/cli/commands/launch-codex.mjs @@ -1,8 +1,37 @@ -import { spawn } from "node:child_process"; +import { spawn, execFileSync } from "node:child_process"; import { t } from "../i18n.mjs"; import { resolveActiveContext } from "../contexts.mjs"; import { quoteShellArgs } from "../utils/winShellArgs.mjs"; +/** + * Probe PATH for a Windows executable via `where.exe`, preferring a `.exe` over + * a `.cmd`/`.bat` shim. Returns the absolute path to the preferred binary, or + * `null` when `where.exe` finds nothing (or cannot run). Mirrors the same probe + * in launch.mjs and `locateCommand()` in `src/shared/services/cliRuntime.ts`. + * + * @param {string} command bare command name to look up + * @returns {Promise} absolute path to the preferred match, or null + */ +function probeWindowsBinary(command) { + try { + const out = execFileSync("where.exe", [command], { + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }); + const lines = out + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length === 0) return null; + const winExt = /\.(exe|cmd|bat|com)$/i; + return lines.find((l) => winExt.test(l)) || null; + } catch { + return null; + } +} + /** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url * in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors * free-claude-code's codex adapter. NOTE: this does NOT silence codex's @@ -23,11 +52,25 @@ const NO_AUTH_SENTINEL = "omniroute-no-auth"; // On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve // without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263): // spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere. -export function resolveCodexSpawn(platform) { - if (platform === "win32") { - return { command: "codex.cmd", shell: true }; +// +// #9454: the native codex installer may ship a real `codex.exe` instead of the +// npm `.cmd` shim. Probe PATH for `codex` first: when `where.exe` resolves a +// `.exe`, spawn it directly (no shell — cmd.exe would split an absolute path +// with spaces); otherwise fall back to `codex.cmd` + shell. Off Windows the bare +// binary is spawned unchanged (no shell, no probe). +/** + * @param {NodeJS.Platform|string} platform + * @param {{ probe?: (command: string) => Promise }} [opts] injectable probe for tests + * @returns {Promise<{ command: string, shell: true|undefined }>} + */ +export async function resolveCodexSpawn(platform, opts = {}) { + if (platform !== "win32") return { command: "codex", shell: undefined }; + const probe = opts.probe ?? probeWindowsBinary; + const located = await probe("codex"); + if (located && /\.exe$/i.test(located)) { + return { command: located, shell: undefined }; } - return { command: "codex", shell: undefined }; + return { command: "codex.cmd", shell: true }; } /** @@ -169,8 +212,9 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) { const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs]; const env = buildCodexEnv(process.env, authToken); + const { command: codexLaunch, shell: shellValue } = await resolveCodexSpawn(process.platform); + return await new Promise((resolve) => { - const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform); const child = spawn(codexLaunch, quoteCodexArgs(extraArgs, process.platform), { env, stdio: "inherit", diff --git a/bin/cli/commands/launch.mjs b/bin/cli/commands/launch.mjs index 78016257f1..e1b7aca47d 100644 --- a/bin/cli/commands/launch.mjs +++ b/bin/cli/commands/launch.mjs @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, execFileSync } from "node:child_process"; import { join } from "node:path"; import os from "node:os"; import { t } from "../i18n.mjs"; @@ -92,17 +92,61 @@ export function resolveLaunchTarget(opts = {}) { } /** - * #8246: on Windows, npm installs claude as a `.cmd` shim — spawn() without a - * shell cannot resolve PATHEXT shims (and Node refuses to exec `.cmd` directly - * since CVE-2024-27980), so the Windows path must go through cmd.exe. + * Probe PATH for a Windows executable via `where.exe`, preferring a `.exe` over + * a `.cmd`/`.bat` shim. Returns the absolute path to the preferred binary, or + * `null` when `where.exe` finds nothing (or cannot run). + * + * The native Anthropic installer (#9454) creates only `claude.exe` (no npm + * `.cmd` shim), so the launcher must look for the real PE and spawn it without + * a shell. Mirrors the existing `locateCommand()` probe in + * `src/shared/services/cliRuntime.ts`. + * + * @param {string} command bare command name to look up + * @returns {Promise} absolute path to the preferred match, or null + */ +function probeWindowsBinary(command) { + try { + const out = execFileSync("where.exe", [command], { + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }); + const lines = out + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + if (lines.length === 0) return null; + const winExt = /\.(exe|cmd|bat|com)$/i; + return lines.find((l) => winExt.test(l)) || null; + } catch { + return null; + } +} + +/** + * #8246 / #9454: on Windows, npm installs claude as a `.cmd` shim — spawn() + * without a shell cannot resolve PATHEXT shims (and Node refuses to exec `.cmd` + * directly since CVE-2024-27980), so the npm-shim path must go through cmd.exe. + * But the native installer creates only `claude.exe`, which is a real PE that + * must NOT go through a shell (cmd.exe would split an absolute path with spaces). + * + * So probe PATH for `claude` first: when `where.exe` resolves a `.exe`, spawn it + * directly (no shell); otherwise fall back to the npm `claude.cmd` + shell. Off + * Windows the bare binary is spawned unchanged (no shell, no probe). * * @param {NodeJS.Platform|string} platform - * @returns {{ command: string, shell: true|undefined }} + * @param {{ probe?: (command: string) => Promise }} [opts] injectable probe for tests + * @returns {Promise<{ command: string, shell: true|undefined }>} */ -export function resolveClaudeSpawn(platform) { - return platform === "win32" - ? { command: "claude.cmd", shell: true } - : { command: "claude", shell: undefined }; +export async function resolveClaudeSpawn(platform, opts = {}) { + if (platform !== "win32") return { command: "claude", shell: undefined }; + const probe = opts.probe ?? probeWindowsBinary; + const located = await probe("claude"); + if (located && /\.exe$/i.test(located)) { + return { command: located, shell: undefined }; + } + return { command: "claude.cmd", shell: true }; } /** @@ -148,8 +192,9 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) { : undefined; const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir }); + const { command, shell } = await resolveClaudeSpawn(process.platform); + return await new Promise((resolve) => { - const { command, shell } = resolveClaudeSpawn(process.platform); const child = spawn(command, quoteClaudeArgs(claudeArgs, process.platform), { env, stdio: "inherit", diff --git a/changelog.d/fixes/9454-launch-claude-exe-windows.md b/changelog.d/fixes/9454-launch-claude-exe-windows.md new file mode 100644 index 0000000000..c4abdf25ab --- /dev/null +++ b/changelog.d/fixes/9454-launch-claude-exe-windows.md @@ -0,0 +1 @@ +- fix(cli): probe PATH for claude.exe/codex.exe on Windows before falling back to the .cmd shim (#9454) diff --git a/tests/unit/cli/launch-claude-exe-windows-9454.test.ts b/tests/unit/cli/launch-claude-exe-windows-9454.test.ts new file mode 100644 index 0000000000..fbfccdac29 --- /dev/null +++ b/tests/unit/cli/launch-claude-exe-windows-9454.test.ts @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveClaudeSpawn } from "../../../bin/cli/commands/launch.mjs"; +import { resolveCodexSpawn } from "../../../bin/cli/commands/launch-codex.mjs"; + +// #9454: the native Anthropic installer creates only claude.exe (no .cmd shim), +// so hardcoding claude.cmd on win32 fails for native installs. The resolver must +// probe PATH for the .exe first and spawn it without a shell (a real PE doesn't +// need cmd.exe), falling back to the npm .cmd shim only when no .exe is found. + +test("resolveClaudeSpawn: win32 prefers claude.exe (no shell) when the native binary is on PATH", async () => { + const exePath = "C:\\Users\\me\\.local\\bin\\claude.exe"; + const probe = async () => exePath; + const { command, shell } = await resolveClaudeSpawn("win32", { probe }); + assert.equal(command, exePath); + assert.equal(shell, undefined, "a real PE binary does not need cmd.exe"); +}); + +test("resolveClaudeSpawn: win32 falls back to claude.cmd + shell when only the npm shim exists", async () => { + const probe = async () => "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"; + const { command, shell } = await resolveClaudeSpawn("win32", { probe }); + assert.equal(command, "claude.cmd"); + assert.equal(shell, true, "npm .cmd shim still needs cmd.exe"); +}); + +test("resolveClaudeSpawn: win32 falls back to claude.cmd + shell when where.exe finds nothing", async () => { + const probe = async () => null; + const { command, shell } = await resolveClaudeSpawn("win32", { probe }); + assert.equal(command, "claude.cmd"); + assert.equal(shell, true, "unknown install shape defaults to the npm shim path"); +}); + +test("resolveClaudeSpawn: non-Windows is unchanged (bare binary, no shell, no probe call)", async () => { + let called = 0; + const probe = async () => { + called++; + return null; + }; + for (const platform of ["linux", "darwin", "freebsd"]) { + const { command, shell } = await resolveClaudeSpawn(platform, { probe }); + assert.equal(command, "claude", `${platform} command`); + assert.equal(shell, undefined, `${platform} shell`); + } + assert.equal(called, 0, "where.exe probe must NEVER run off Windows"); +}); + +// Same regression for the codex launcher: codex ships a native build too. +test("resolveCodexSpawn: win32 prefers codex.exe (no shell) when a native binary is on PATH", async () => { + const exePath = "C:\\Users\\me\\.local\\bin\\codex.exe"; + const probe = async () => exePath; + const { command, shell } = await resolveCodexSpawn("win32", { probe }); + assert.equal(command, exePath); + assert.equal(shell, undefined); +}); + +test("resolveCodexSpawn: win32 falls back to codex.cmd + shell when only the npm shim exists", async () => { + const probe = async () => "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd"; + const { command, shell } = await resolveCodexSpawn("win32", { probe }); + assert.equal(command, "codex.cmd"); + assert.equal(shell, true); +}); + +test("resolveCodexSpawn: win32 falls back to codex.cmd + shell when where.exe finds nothing", async () => { + const probe = async () => null; + const { command, shell } = await resolveCodexSpawn("win32", { probe }); + assert.equal(command, "codex.cmd"); + assert.equal(shell, true); +}); + +test("resolveCodexSpawn: non-Windows is unchanged (bare binary, no shell, no probe call)", async () => { + let called = 0; + const probe = async () => { + called++; + return null; + }; + for (const platform of ["linux", "darwin", "freebsd"]) { + const { command, shell } = await resolveCodexSpawn(platform, { probe }); + assert.equal(command, "codex", `${platform} command`); + assert.equal(shell, undefined, `${platform} shell`); + } + assert.equal(called, 0, "where.exe probe must NEVER run off Windows"); +}); diff --git a/tests/unit/cli/launch-codex-windows-spawn-args.test.ts b/tests/unit/cli/launch-codex-windows-spawn-args.test.ts index c76ff58577..92d3296282 100644 --- a/tests/unit/cli/launch-codex-windows-spawn-args.test.ts +++ b/tests/unit/cli/launch-codex-windows-spawn-args.test.ts @@ -13,15 +13,18 @@ import { const isWindows = process.platform === "win32"; -test("resolveCodexSpawn: win32 spawns codex.cmd through a shell", () => { - const { command, shell } = resolveCodexSpawn("win32"); +// #9454: the resolver now probes PATH for a `.exe` before falling back to the +// npm `.cmd` shim. With no probe injected it runs `where.exe`; pin the fallback +// (no .exe found → codex.cmd + shell) here. +test("resolveCodexSpawn: win32 falls back to codex.cmd + shell when no .exe is on PATH", async () => { + const { command, shell } = await resolveCodexSpawn("win32", { probe: async () => null }); assert.equal(command, "codex.cmd"); assert.equal(shell, true); }); -test("resolveCodexSpawn: non-Windows platforms spawn the bare binary without a shell", () => { +test("resolveCodexSpawn: non-Windows platforms spawn the bare binary without a shell", async () => { for (const platform of ["linux", "darwin", "freebsd"]) { - const { command, shell } = resolveCodexSpawn(platform); + const { command, shell } = await resolveCodexSpawn(platform); assert.equal(command, "codex", `${platform} command`); assert.equal(shell, undefined, `${platform} shell`); } diff --git a/tests/unit/cli/launch-windows-spawn-args.test.ts b/tests/unit/cli/launch-windows-spawn-args.test.ts index 52ef3a0629..d75abed6ea 100644 --- a/tests/unit/cli/launch-windows-spawn-args.test.ts +++ b/tests/unit/cli/launch-windows-spawn-args.test.ts @@ -11,15 +11,19 @@ const isWindows = process.platform === "win32"; // Regression guard for #8246: on Windows the `claude` binary is an npm `.cmd` // shim that spawn() cannot resolve without a shell (bare "claude" -> ENOENT). -test("resolveClaudeSpawn: win32 spawns claude.cmd through a shell", () => { - const { command, shell } = resolveClaudeSpawn("win32"); +// #9454: the native installer ships only `claude.exe`, so the resolver now +// probes PATH first. With no probe injected (the production path runs +// `where.exe`), the default on a non-Windows CI host finds nothing and falls +// back to the npm `.cmd` shim + shell — pinning that fallback contract here. +test("resolveClaudeSpawn: win32 falls back to claude.cmd + shell when no .exe is on PATH", async () => { + const { command, shell } = await resolveClaudeSpawn("win32", { probe: async () => null }); assert.equal(command, "claude.cmd"); assert.equal(shell, true); }); -test("resolveClaudeSpawn: non-Windows platforms spawn the bare binary without a shell", () => { +test("resolveClaudeSpawn: non-Windows platforms spawn the bare binary without a shell", async () => { for (const platform of ["linux", "darwin", "freebsd"]) { - const { command, shell } = resolveClaudeSpawn(platform); + const { command, shell } = await resolveClaudeSpawn(platform); assert.equal(command, "claude", `${platform} command`); assert.equal(shell, undefined, `${platform} shell`); }