Skip to content

Commit 091c50c

Browse files
committed
fix: route Windows .cmd shims through shell and default windowsHide to true
- Add WINDOWS_CMD_SHIMS allowlist + shouldUseShellForCommand helper so pnpm/npm/npx hit a shell on win32 (Node post-CVE-2024-27980 rejects direct .cmd/.bat spawn) while native exes like git keep shell:false. - Apply the helper to packages/server provider runtime, scripts/shared process utilities, and publish-cli; drop the dead resolveSpawnCommand passthrough. - Default windowsHide to true everywhere so background child processes don't flash a console window. - Fix CI Windows runtime lane referencing the renamed test file. - Cover the new behavior with unit tests.
1 parent a267597 commit 091c50c

7 files changed

Lines changed: 83 additions & 35 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ jobs:
8282
run: pnpm --filter @coder-studio/providers exec vitest run src/claude/definition.test.ts src/codex/definition.test.ts
8383

8484
- name: Run targeted Windows server tests
85-
run: pnpm --filter @coder-studio/server exec vitest run src/__tests__/provider-runtime/command-check.test.ts src/__tests__/provider-runtime/exec-file.test.ts src/__tests__/provider-runtime/install-manager.test.ts src/__tests__/workspace/runtime-check.test.ts src/git/cli.windows.test.ts src/supervisor/evaluator.windows.test.ts src/__tests__/server-provider-install-wiring.test.ts src/__tests__/session-commands.test.ts src/__tests__/session-integration.test.ts
85+
run: pnpm --filter @coder-studio/server exec vitest run src/__tests__/provider-runtime/command-check.test.ts src/__tests__/provider-runtime/command-runner.test.ts src/__tests__/provider-runtime/install-manager.test.ts src/__tests__/workspace/runtime-check.test.ts src/git/cli.windows.test.ts src/supervisor/evaluator.windows.test.ts src/__tests__/server-provider-install-wiring.test.ts src/__tests__/session-commands.test.ts src/__tests__/session-integration.test.ts
8686

8787
- name: Run targeted Windows CLI tests
8888
run: pnpm --filter @spencer-kit/coder-studio exec vitest run src/browser.test.ts src/bin.test.ts src/pm2-control.test.ts src/server-control.test.ts

packages/server/src/__tests__/provider-runtime/command-runner.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Buffer } from "node:buffer";
22
import { EventEmitter } from "node:events";
3-
import { describe, expect, it, vi } from "vitest";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
44

55
const { spawnMock } = vi.hoisted(() => ({
66
spawnMock: vi.fn(),
@@ -29,6 +29,14 @@ function createChildProcessMock() {
2929
}
3030

3131
describe("runCommandAsString", () => {
32+
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
33+
34+
afterEach(() => {
35+
if (originalPlatform) {
36+
Object.defineProperty(process, "platform", originalPlatform);
37+
}
38+
});
39+
3240
it("normalizes stdout and stderr to strings", async () => {
3341
spawnMock.mockImplementation(
3442
(_file: string, _args: string[], _options: { windowsHide?: boolean }) => {
@@ -50,4 +58,53 @@ describe("runCommandAsString", () => {
5058
windowsHide: true,
5159
});
5260
});
61+
62+
it("defaults windowsHide to true when caller omits the option", async () => {
63+
spawnMock.mockImplementation(() => {
64+
const child = createChildProcessMock();
65+
queueMicrotask(() => child.emit("close", 0));
66+
return child;
67+
});
68+
69+
await runCommandAsString("demo", []);
70+
71+
expect(spawnMock).toHaveBeenCalledWith("demo", [], {
72+
shell: false,
73+
windowsHide: true,
74+
});
75+
});
76+
77+
it("routes Windows .cmd shims through a shell so Node does not reject them", async () => {
78+
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
79+
spawnMock.mockImplementation(() => {
80+
const child = createChildProcessMock();
81+
queueMicrotask(() => child.emit("close", 0));
82+
return child;
83+
});
84+
85+
await runCommandAsString("npm", ["install", "-g", "@openai/codex"]);
86+
87+
expect(spawnMock).toHaveBeenCalledWith(
88+
"npm",
89+
["install", "-g", "@openai/codex"],
90+
expect.objectContaining({ shell: true, windowsHide: true })
91+
);
92+
});
93+
94+
it("keeps native Windows executables off the shell path", async () => {
95+
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
96+
spawnMock.mockImplementation(() => {
97+
const child = createChildProcessMock();
98+
queueMicrotask(() => child.emit("close", 0));
99+
return child;
100+
});
101+
102+
await runCommandAsString("git", ["--version"]);
103+
104+
expect(spawnMock).toHaveBeenCalledWith(
105+
"git",
106+
["--version"],
107+
expect.objectContaining({ shell: false, windowsHide: true })
108+
);
109+
});
53110
});

packages/server/src/__tests__/server-provider-install-wiring.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ describe("createServer provider install wiring", () => {
151151
expect(spawnMock).toHaveBeenCalledWith(
152152
"npm",
153153
["install", "-g", "@openai/codex"],
154+
expect.objectContaining({ shell: true, windowsHide: true })
155+
);
156+
expect(spawnMock).toHaveBeenCalledWith(
157+
"winget",
158+
["install", "--id", "OpenJS.NodeJS.LTS", "--exact", "--silent"],
154159
expect.objectContaining({ shell: false, windowsHide: true })
155160
);
156161
});

packages/server/src/provider-runtime/command-runner.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,24 @@ export type CommandRunner = (
1313
options?: CommandRunnerOptions
1414
) => Promise<CommandRunnerResult>;
1515

16+
// Windows ships these as .cmd shims that Node refuses to spawn directly post
17+
// CVE-2024-27980. Routing them through cmd.exe via shell:true is the only
18+
// approach that survives both ENOENT (bare name) and EINVAL (full .cmd path).
19+
const WINDOWS_CMD_SHIMS = new Set(["pnpm", "npm", "npx"]);
20+
21+
function shouldUseShellForCommand(file: string, platform: NodeJS.Platform): boolean {
22+
return platform === "win32" && WINDOWS_CMD_SHIMS.has(file.toLowerCase());
23+
}
24+
1625
export async function runCommandAsString(
1726
file: string,
1827
args: string[],
1928
options?: CommandRunnerOptions
2029
): Promise<CommandRunnerResult> {
2130
return new Promise((resolve, reject) => {
2231
const child = spawn(file, args, {
23-
shell: false,
24-
windowsHide: options?.windowsHide ?? false,
32+
shell: shouldUseShellForCommand(file, process.platform),
33+
windowsHide: options?.windowsHide ?? true,
2534
});
2635

2736
const stdoutChunks: Buffer[] = [];

scripts/publish-cli.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
1010
import { dirname, resolve } from "node:path";
1111
import { build } from "./build.js";
1212
import { CLI_DIR, error, info, step, success } from "./shared/index.js";
13-
import { isDirectExecution, resolveSpawnCommand } from "./shared/process.js";
13+
import { isDirectExecution, shouldUseShellForCommand } from "./shared/process.js";
1414

1515
export interface PublishCliOptions {
1616
access: string;
@@ -262,11 +262,12 @@ export async function execCommand(
262262
): Promise<ExecResult> {
263263
return new Promise((resolvePromise, reject) => {
264264
const stdio = options.stdio ?? "inherit";
265-
const child = spawn(resolveSpawnCommand(command), args, {
265+
const child = spawn(command, args, {
266266
cwd: options.cwd,
267267
env: process.env,
268-
shell: false,
268+
shell: shouldUseShellForCommand(command),
269269
stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : "inherit",
270+
windowsHide: true,
270271
});
271272

272273
let stdout = "";

scripts/shared/process.test.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { isDirectExecution, resolveSpawnCommand, shouldUseShellForCommand } from "./process.js";
2+
import { isDirectExecution, shouldUseShellForCommand } from "./process.js";
33

44
describe("isDirectExecution", () => {
55
it("matches direct execution for POSIX script paths", () => {
@@ -17,20 +17,6 @@ describe("isDirectExecution", () => {
1717
});
1818
});
1919

20-
describe("resolveSpawnCommand", () => {
21-
it("keeps pnpm unresolved on Windows so cmd can resolve the shim", () => {
22-
expect(resolveSpawnCommand("pnpm", "win32")).toBe("pnpm");
23-
});
24-
25-
it("does not rewrite native Windows executables like git", () => {
26-
expect(resolveSpawnCommand("git", "win32")).toBe("git");
27-
});
28-
29-
it("leaves commands unchanged on POSIX platforms", () => {
30-
expect(resolveSpawnCommand("pnpm", "linux")).toBe("pnpm");
31-
});
32-
});
33-
3420
describe("shouldUseShellForCommand", () => {
3521
it("uses a shell for pnpm on Windows because pnpm.cmd is not directly executable", () => {
3622
expect(shouldUseShellForCommand("pnpm", "win32")).toBe(true);

scripts/shared/process.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,6 @@ export function isDirectExecution(moduleUrl: string, argv1: string | undefined =
7979
return modulePath === normalizeArgvPath(argv1);
8080
}
8181

82-
export function resolveSpawnCommand(
83-
command: string,
84-
_platform: NodeJS.Platform = process.platform
85-
): string {
86-
return command;
87-
}
88-
8982
export function shouldUseShellForCommand(
9083
command: string,
9184
platform: NodeJS.Platform = process.platform
@@ -103,6 +96,7 @@ function createSpawnOptions({
10396
env: options.env ?? process.env,
10497
stdio: options.stdio ?? "inherit",
10598
shell: shouldUseShellForCommand(command, platform),
99+
windowsHide: true,
106100
};
107101
}
108102

@@ -115,11 +109,7 @@ export function run(
115109
options: ProcessOptions = {}
116110
): Promise<void> {
117111
return new Promise((resolve, reject) => {
118-
const child = spawn(
119-
resolveSpawnCommand(command),
120-
args,
121-
createSpawnOptions({ command, options })
122-
);
112+
const child = spawn(command, args, createSpawnOptions({ command, options }));
123113

124114
child.on("close", (code) => {
125115
if (code === 0) {
@@ -143,7 +133,7 @@ export function runBackground(
143133
args: string[] = [],
144134
options: ProcessOptions = {}
145135
): ChildProcess {
146-
const child = spawn(resolveSpawnCommand(command), args, createSpawnOptions({ command, options }));
136+
const child = spawn(command, args, createSpawnOptions({ command, options }));
147137

148138
return child;
149139
}

0 commit comments

Comments
 (0)