Skip to content

Commit 7885e2b

Browse files
committed
Fix: resolve shell option on Windows to support .cmd/.bat shims
On Windows, Node.js spawn with shell:false cannot execute .cmd/.bat shims — only native .exe binaries. The previous commit forced shell:false for gh CLI invocations to prevent command injection, but this regressed Windows setups where gh is installed as a .cmd shim (e.g., via Scoop or corporate package managers). Add resolveShellOption() in processRunner.ts that, on Windows, probes PATH for the first matching file: if it's a .cmd/.bat shim, shell:true is used (required for cmd.exe to interpret the shim); if it's an .exe, shell:false is preserved (preventing injection). On non-Windows platforms, the caller's explicit value is used (defaulting to false).
1 parent 1fd6244 commit 7885e2b

File tree

1 file changed

+42
-1
lines changed

1 file changed

+42
-1
lines changed

apps/server/src/processRunner.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { type ChildProcess as ChildProcessHandle, spawn, spawnSync } from "node:child_process";
2+
import { accessSync } from "node:fs";
3+
import { delimiter, extname, join } from "node:path";
24

35
export interface ProcessRunOptions {
46
cwd?: string | undefined;
@@ -81,6 +83,45 @@ function normalizeBufferError(
8183

8284
const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
8385

86+
/**
87+
* On Windows, `.cmd` / `.bat` shims (created by npm, Scoop, etc.) can only be
88+
* executed by `spawn` when `shell: true`. When the caller explicitly requests
89+
* `shell: false` we still need to honour that for `.exe` binaries, but we must
90+
* fall back to `shell: true` when the resolved command is a script shim.
91+
*/
92+
function resolveShellOption(command: string, explicit: boolean | undefined): boolean {
93+
if (process.platform !== "win32") return explicit ?? false;
94+
if (explicit === true) return true;
95+
96+
if (extname(command)) {
97+
const ext = extname(command).toLowerCase();
98+
return ext === ".cmd" || ext === ".bat";
99+
}
100+
101+
const pathEnv = process.env["PATH"] ?? process.env["Path"] ?? "";
102+
const dirs = pathEnv.split(delimiter).filter(Boolean);
103+
for (const dir of dirs) {
104+
for (const ext of [".cmd", ".bat"]) {
105+
try {
106+
accessSync(join(dir, `${command}${ext}`));
107+
return true;
108+
} catch {
109+
// not found, continue
110+
}
111+
}
112+
for (const ext of [".exe", ".com", ""]) {
113+
try {
114+
accessSync(join(dir, `${command}${ext}`));
115+
return false;
116+
} catch {
117+
// not found, continue
118+
}
119+
}
120+
}
121+
122+
return explicit ?? false;
123+
}
124+
84125
/**
85126
* On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
86127
* wrapper, leaving the actual command running. Use `taskkill /T` to kill the
@@ -140,7 +181,7 @@ export async function runProcess(
140181
cwd: options.cwd,
141182
env: options.env,
142183
stdio: "pipe",
143-
shell: options.shell ?? (process.platform === "win32"),
184+
shell: resolveShellOption(command, options.shell),
144185
});
145186

146187
let stdout = "";

0 commit comments

Comments
 (0)