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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user