fix(ai-tools): detect AI CLIs on Windows through PATHEXT

which() looked for the bare name only, so on Windows every tool (claude.exe,
codex.cmd, ...) read installed: false. It now tries each PATHEXT extension
(default .EXE;.CMD;.BAT), the bare name only when it already carries one -
npm's extensionless shim beside codex.cmd is a sh script Windows cannot run.

spawnTarget() gives a .cmd/.bat shell: true and a quoted path, used by the
install/update job runner and the npm/version probes, which Node otherwise
refuses with EINVAL (CVE-2024-27980). Catalog argv only.
This commit is contained in:
2026-10-05 22:05:33 +07:00
parent 094d9c4418
commit b0cf2132d1
7 changed files with 112 additions and 12 deletions
+62
View File
@@ -330,3 +330,65 @@ describe("install/update fields and trust tiers", () => {
});
});
});
describe("which() on Windows", () => {
const realPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const realPathext = process.env.PATHEXT;
const winDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-win-"));
// Executable bits only for the Linux host running the suite: Windows'
// X_OK check is plain existence.
for (const f of ["claude.exe", "codex.cmd"])
fs.writeFileSync(path.join(winDir, f), "", { mode: 0o755 });
const setPlatform = (p) => Object.defineProperty(process, "platform", { value: p });
after(() => {
Object.defineProperty(process, "platform", realPlatform);
if (realPathext === undefined) delete process.env.PATHEXT;
else process.env.PATHEXT = realPathext;
});
it("tries each PATHEXT extension after the bare name", () => {
setPlatform("win32");
process.env.PATHEXT = ".EXE;.CMD";
assert.equal(aiTools.which("claude", winDir), path.join(winDir, "claude.exe"));
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
});
it("skips npm's extensionless sh shim, which Windows cannot run", () => {
setPlatform("win32");
process.env.PATHEXT = ".EXE;.CMD";
fs.writeFileSync(path.join(winDir, "codex"), "#!/bin/sh\n", { mode: 0o755 });
try {
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
assert.equal(aiTools.which("claude.exe", winDir), path.join(winDir, "claude.exe"));
} finally {
fs.unlinkSync(path.join(winDir, "codex"));
}
});
it("falls back to .EXE;.CMD;.BAT without PATHEXT", () => {
setPlatform("win32");
delete process.env.PATHEXT;
assert.equal(aiTools.which("codex", winDir), path.join(winDir, "codex.cmd"));
});
it("runs a .cmd/.bat through cmd.exe, its path quoted for Program Files", () => {
setPlatform("win32");
const npm = "C:\\Program Files\\nodejs\\npm.cmd";
assert.deepEqual(aiTools.spawnTarget(npm), { file: `"${npm}"`, shell: true });
assert.deepEqual(aiTools.spawnTarget("C:\\x\\claude.exe"), {
file: "C:\\x\\claude.exe",
shell: false,
});
setPlatform("linux");
assert.deepEqual(aiTools.spawnTarget("/usr/bin/npm.cmd"), {
file: "/usr/bin/npm.cmd",
shell: false,
});
});
it("adds no extension anywhere else", () => {
setPlatform("linux");
process.env.PATHEXT = ".EXE;.CMD";
assert.equal(aiTools.which("claude", winDir), null);
});
});
+3 -1
View File
@@ -125,7 +125,9 @@ function runArgv(j, argv) {
resolve(code == null ? 1 : code);
};
try {
child = spawnImpl(argv[0], argv.slice(1), {
const { file, shell } = aiTools.spawnTarget(argv[0]);
child = spawnImpl(file, argv.slice(1), {
shell,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, PATH: versions.jobPath() },
detached: true, // its own process group, so a timeout reaches grandchildren
+4 -2
View File
@@ -37,8 +37,10 @@ function npmBin() {
return (sp && aiTools.which("npm", sp)) || aiTools.which("npm");
}
const run = (file, args) =>
exec(file, args, { timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
const run = (bin, args) => {
const { file, shell } = aiTools.spawnTarget(bin);
return exec(file, args, { shell, timeout: TIMEOUT_MS, env: { ...process.env, PATH: jobPath() } });
};
const parseVersion = (out) => (String(out || "").match(/\d+\.\d+\.\d+/) || [null])[0];
+38 -7
View File
@@ -260,19 +260,49 @@ const has = (id) => typeof id === "string" && cat().has(id);
function which(name, pathStr = process.env.PATH || "") {
for (const dir of pathStr.split(path.delimiter)) {
if (!dir) continue;
const p = path.join(dir, name);
try {
if (fs.statSync(p).isFile()) {
fs.accessSync(p, fs.constants.X_OK);
return p;
for (const candidate of executableNames(name)) {
const p = path.join(dir, candidate);
try {
if (fs.statSync(p).isFile()) {
fs.accessSync(p, fs.constants.X_OK);
return p;
}
} catch {
/* not here */
}
} catch {
/* not here */
}
}
return null;
}
/**
* The file names `name` can run as. On Windows only a `PATHEXT` extension
* makes a file runnable (`claude.exe`, `codex.cmd`): the bare name is tried
* only when it already carries one, because npm's extensionless `codex` next
* to `codex.cmd` is a POSIX sh shim Windows cannot start.
*/
function executableNames(name) {
if (process.platform !== "win32") return [name];
const exts = (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean);
const lower = name.toLowerCase();
if (exts.some((e) => lower.endsWith(e.toLowerCase()))) return [name];
return exts.map((e) => name + e.toLowerCase());
}
/**
* How to start `file` with spawn/execFile: on Windows a `.cmd`/`.bat` (npm,
* an npm-installed CLI) runs only through cmd.exe — Node refuses it otherwise
* (EINVAL, CVE-2024-27980) — so it gets `shell: true` and a quoted path, as
* `C:\Program Files\nodejs\npm.cmd` holds a space. Only for argv built from
* release catalog files, never from a request: cmd.exe re-parses the args.
*/
function spawnTarget(file) {
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
return { file: `"${file}"`, shell: true };
}
return { file, shell: false };
}
/** `{installed, path, command}` for one tool, cached until refresh() unless `fresh`. */
function detect(id, { fresh = false } = {}) {
if (!fresh && detected.has(id)) return detected.get(id);
@@ -426,6 +456,7 @@ module.exports = {
view,
validate,
which,
spawnTarget,
siteOf,
ROUTER_CAPABLE,
CONFIG_SECRET_RE,