mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(cli): probe PATH for claude.exe/codex.exe on Windows before falling back to the .cmd shim (#9454)
Closes #9454
This commit is contained in:
committed by
GitHub
parent
d2a9378afb
commit
e64eecf852
@@ -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<string|null>} 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<string|null> }} [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",
|
||||
|
||||
@@ -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<string|null>} 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<string|null> }} [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",
|
||||
|
||||
1
changelog.d/fixes/9454-launch-claude-exe-windows.md
Normal file
1
changelog.d/fixes/9454-launch-claude-exe-windows.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): probe PATH for claude.exe/codex.exe on Windows before falling back to the .cmd shim (#9454)
|
||||
83
tests/unit/cli/launch-claude-exe-windows-9454.test.ts
Normal file
83
tests/unit/cli/launch-claude-exe-windows-9454.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user