Skip to content

Commit 0d6b849

Browse files
committed
fix: unwrap windows npm cmd-shims before pty.spawn so codex starts
node-pty calls Win32 CreateProcess directly, which only auto-appends .exe and cannot run .cmd/.bat shims. Tools installed by npm (codex, aider, ...) ship as cmd-shims that wrap a `node <entry.js>` invocation, so launching them through the terminal failed with "Cannot create process, error code: 2". Add a windows-only resolver that walks PATH+PATHEXT and parses the two standard npm cmd-shim templates into their underlying node argv. Plug it into NodePtyHost.spawn so the existing terminal flow recovers the same semantics POSIX gets via shebang. Linux/macOS short-circuit back to the original argv. Native .exe installs (claude, future provider binaries) just resolve to an absolute path and pass through.
1 parent 92fa992 commit 0d6b849

4 files changed

Lines changed: 467 additions & 1 deletion

File tree

packages/server/src/terminal/pty-host.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { chmodSync, existsSync, statSync } from "node:fs";
88
import { createRequire } from "node:module";
99
import path from "node:path";
10+
import { resolveSpawnArgv } from "@coder-studio/utils";
1011
import type * as NodePty from "node-pty";
1112
import type { PtyHost, PtyProcess, PtySpawnOptions } from "./types.js";
1213

@@ -200,7 +201,18 @@ export class NodePtyHost implements PtyHost {
200201
throw new Error(`node-pty native module not available. ${message}`);
201202
}
202203

203-
const [command, ...args] = argv;
204+
if (argv.length === 0) {
205+
throw new Error("PTY spawn requires a command");
206+
}
207+
208+
// On Windows, node-pty calls Win32 CreateProcess directly and cannot run
209+
// .cmd/.bat shims. resolveSpawnArgv walks PATH+PATHEXT and unwraps
210+
// npm-style cmd-shims into a `node <entry.js>` invocation. On non-win32
211+
// platforms this returns argv unchanged.
212+
const [command, ...args] = resolveSpawnArgv(argv, {
213+
pathEnv: options.env.Path ?? options.env.PATH,
214+
pathExt: options.env.PATHEXT,
215+
});
204216
if (command === undefined) {
205217
throw new Error("PTY spawn requires a command");
206218
}

packages/utils/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
export { isDirectExecution } from "./direct-execution.js";
22
export { shouldUseShellForCommand } from "./windows-shim.js";
3+
export type {
4+
ParsedCmdShim,
5+
ResolveSpawnArgvDeps,
6+
} from "./windows-shim-resolver.js";
7+
export { parseCmdShim, resolveSpawnArgv } from "./windows-shim-resolver.js";
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { parseCmdShim, resolveSpawnArgv } from "./windows-shim-resolver.js";
3+
4+
const STANDARD_NPM_CMD_SHIM = String.raw`@SETLOCAL
5+
@IF EXIST "%~dp0\node.exe" (
6+
"%~dp0\node.exe" "%~dp0\..\@openai\codex\bin\codex.js" %*
7+
) ELSE (
8+
@SETLOCAL
9+
@SET PATHEXT=%PATHEXT:;.JS;=;%
10+
node "%~dp0\..\@openai\codex\bin\codex.js" %*
11+
)
12+
`;
13+
14+
const NPM_CMD_SHIM_VARIANT = String.raw`@ECHO off
15+
GOTO start
16+
:find_dp0
17+
SET dp0=%~dp0
18+
EXIT /b
19+
:start
20+
SETLOCAL
21+
CALL :find_dp0
22+
23+
IF EXIST "%dp0%\node.exe" (
24+
SET "_prog=%dp0%\node.exe"
25+
) ELSE (
26+
SET "_prog=node"
27+
SET PATHEXT=%PATHEXT:;.JS;=;%
28+
)
29+
30+
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\@openai\codex\bin\codex.js" %*
31+
`;
32+
33+
const UNRECOGNIZED_CMD_SHIM = String.raw`@echo off
34+
echo this shim is not standard
35+
exit /b 1
36+
`;
37+
38+
interface MockFsOptions {
39+
files: Record<string, string>;
40+
}
41+
42+
function createMockFs({ files }: MockFsOptions) {
43+
const normalize = (p: string) => p.replace(/\\/g, "/").toLowerCase();
44+
const map = new Map<string, string>(Object.entries(files).map(([k, v]) => [normalize(k), v]));
45+
46+
return {
47+
readFileSync: vi.fn((p: string) => {
48+
const value = map.get(normalize(p));
49+
if (value === undefined) {
50+
throw Object.assign(new Error(`ENOENT: no such file ${p}`), { code: "ENOENT" });
51+
}
52+
return value;
53+
}),
54+
existsSync: vi.fn((p: string) => map.has(normalize(p))),
55+
};
56+
}
57+
58+
describe("resolveSpawnArgv", () => {
59+
it("returns argv unchanged on linux", () => {
60+
const argv = ["codex", "--model", "gpt-4"];
61+
const out = resolveSpawnArgv(argv, { platform: "linux" });
62+
expect(out).toEqual(argv);
63+
expect(out).not.toBe(argv);
64+
});
65+
66+
it("returns argv unchanged on darwin", () => {
67+
const argv = ["codex", "--model", "gpt-4"];
68+
const out = resolveSpawnArgv(argv, { platform: "darwin" });
69+
expect(out).toEqual(argv);
70+
});
71+
72+
it("returns argv unchanged when argv is empty", () => {
73+
expect(resolveSpawnArgv([], { platform: "win32" })).toEqual([]);
74+
});
75+
76+
it("returns argv unchanged when command is not found in PATH", () => {
77+
const fs = createMockFs({ files: {} });
78+
const out = resolveSpawnArgv(["codex"], {
79+
platform: "win32",
80+
pathEnv: "C:\\bin",
81+
pathExt: ".COM;.EXE;.CMD",
82+
readFileSync: fs.readFileSync,
83+
existsSync: fs.existsSync,
84+
});
85+
expect(out).toEqual(["codex"]);
86+
});
87+
88+
it("returns absolute path when command resolves to a real .exe", () => {
89+
const fs = createMockFs({
90+
files: { "C:\\bin\\git.exe": "" },
91+
});
92+
const out = resolveSpawnArgv(["git", "status"], {
93+
platform: "win32",
94+
pathEnv: "C:\\bin",
95+
pathExt: ".COM;.EXE;.CMD",
96+
readFileSync: fs.readFileSync,
97+
existsSync: fs.existsSync,
98+
});
99+
expect(out).toEqual(["C:\\bin\\git.exe", "status"]);
100+
});
101+
102+
it("parses an npm-style .cmd shim and spawns node + entry directly", () => {
103+
const shimPath = "C:\\Users\\u\\AppData\\Local\\fnm\\codex.cmd";
104+
const fs = createMockFs({
105+
files: { [shimPath]: STANDARD_NPM_CMD_SHIM },
106+
});
107+
const out = resolveSpawnArgv(["codex", "--model", "gpt-4"], {
108+
platform: "win32",
109+
pathEnv: "C:\\Users\\u\\AppData\\Local\\fnm",
110+
pathExt: ".COM;.EXE;.CMD",
111+
readFileSync: fs.readFileSync,
112+
existsSync: fs.existsSync,
113+
});
114+
expect(out[0].toLowerCase()).toBe("c:\\users\\u\\appdata\\local\\fnm\\node.exe".toLowerCase());
115+
expect(out[1].toLowerCase()).toBe(
116+
"c:\\users\\u\\appdata\\local\\@openai\\codex\\bin\\codex.js".toLowerCase()
117+
);
118+
expect(out.slice(2)).toEqual(["--model", "gpt-4"]);
119+
});
120+
121+
it("parses the SETLOCAL/find_dp0 variant of cmd-shim", () => {
122+
const shimPath = "C:\\bin\\codex.cmd";
123+
const fs = createMockFs({
124+
files: { [shimPath]: NPM_CMD_SHIM_VARIANT },
125+
});
126+
const out = resolveSpawnArgv(["codex"], {
127+
platform: "win32",
128+
pathEnv: "C:\\bin",
129+
pathExt: ".COM;.EXE;.CMD",
130+
readFileSync: fs.readFileSync,
131+
existsSync: fs.existsSync,
132+
});
133+
expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase());
134+
expect(out[1].toLowerCase()).toBe(
135+
"c:\\bin\\node_modules\\@openai\\codex\\bin\\codex.js".toLowerCase()
136+
);
137+
});
138+
139+
it("falls back to cmd.exe wrapper when .cmd shim cannot be parsed", () => {
140+
const shimPath = "C:\\bin\\weird.cmd";
141+
const fs = createMockFs({
142+
files: { [shimPath]: UNRECOGNIZED_CMD_SHIM },
143+
});
144+
const out = resolveSpawnArgv(["weird", "arg1"], {
145+
platform: "win32",
146+
pathEnv: "C:\\bin",
147+
pathExt: ".COM;.EXE;.CMD",
148+
readFileSync: fs.readFileSync,
149+
existsSync: fs.existsSync,
150+
});
151+
expect(out).toEqual(["cmd.exe", "/d", "/s", "/c", shimPath, "arg1"]);
152+
});
153+
154+
it("respects PATHEXT order — picks .EXE before .CMD when both exist", () => {
155+
const fs = createMockFs({
156+
files: {
157+
"C:\\bin\\foo.cmd": STANDARD_NPM_CMD_SHIM,
158+
"C:\\bin\\foo.exe": "",
159+
},
160+
});
161+
const out = resolveSpawnArgv(["foo"], {
162+
platform: "win32",
163+
pathEnv: "C:\\bin",
164+
pathExt: ".COM;.EXE;.CMD",
165+
readFileSync: fs.readFileSync,
166+
existsSync: fs.existsSync,
167+
});
168+
expect(out).toEqual(["C:\\bin\\foo.exe"]);
169+
});
170+
171+
it("when argv[0] already has an extension, does not loop PATHEXT", () => {
172+
const fs = createMockFs({
173+
files: {
174+
"C:\\bin\\foo.cmd": STANDARD_NPM_CMD_SHIM,
175+
"C:\\bin\\foo.exe": "",
176+
},
177+
});
178+
const out = resolveSpawnArgv(["foo.cmd"], {
179+
platform: "win32",
180+
pathEnv: "C:\\bin",
181+
pathExt: ".COM;.EXE;.CMD",
182+
readFileSync: fs.readFileSync,
183+
existsSync: fs.existsSync,
184+
});
185+
// Should resolve via .cmd parse, not .exe
186+
expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase());
187+
});
188+
189+
it("when argv[0] is already an absolute .exe, returns it as-is", () => {
190+
const abs = "C:\\Program Files\\Git\\bin\\git.exe";
191+
const fs = createMockFs({ files: { [abs]: "" } });
192+
const out = resolveSpawnArgv([abs, "status"], {
193+
platform: "win32",
194+
pathEnv: "",
195+
pathExt: ".EXE",
196+
readFileSync: fs.readFileSync,
197+
existsSync: fs.existsSync,
198+
});
199+
expect(out).toEqual([abs, "status"]);
200+
});
201+
202+
it("when argv[0] is already an absolute .cmd, parses it directly", () => {
203+
const abs = "C:\\bin\\codex.cmd";
204+
const fs = createMockFs({ files: { [abs]: STANDARD_NPM_CMD_SHIM } });
205+
const out = resolveSpawnArgv([abs], {
206+
platform: "win32",
207+
pathEnv: "",
208+
pathExt: "",
209+
readFileSync: fs.readFileSync,
210+
existsSync: fs.existsSync,
211+
});
212+
expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase());
213+
expect(out[1].toLowerCase()).toBe("c:\\@openai\\codex\\bin\\codex.js".toLowerCase());
214+
});
215+
216+
it("searches multiple PATH entries in order", () => {
217+
const fs = createMockFs({
218+
files: { "D:\\tools\\codex.exe": "" },
219+
});
220+
const out = resolveSpawnArgv(["codex"], {
221+
platform: "win32",
222+
pathEnv: "C:\\nope;D:\\tools;E:\\also-nope",
223+
pathExt: ".EXE",
224+
readFileSync: fs.readFileSync,
225+
existsSync: fs.existsSync,
226+
});
227+
expect(out).toEqual(["D:\\tools\\codex.exe"]);
228+
});
229+
230+
it("ignores empty PATH segments", () => {
231+
const fs = createMockFs({
232+
files: { "C:\\bin\\codex.exe": "" },
233+
});
234+
const out = resolveSpawnArgv(["codex"], {
235+
platform: "win32",
236+
pathEnv: ";;C:\\bin;;",
237+
pathExt: ".EXE",
238+
readFileSync: fs.readFileSync,
239+
existsSync: fs.existsSync,
240+
});
241+
expect(out).toEqual(["C:\\bin\\codex.exe"]);
242+
});
243+
244+
it("matches PATHEXT case-insensitively", () => {
245+
const fs = createMockFs({
246+
files: { "C:\\bin\\codex.CMD": STANDARD_NPM_CMD_SHIM },
247+
});
248+
const out = resolveSpawnArgv(["codex"], {
249+
platform: "win32",
250+
pathEnv: "C:\\bin",
251+
pathExt: ".com;.exe;.cmd",
252+
readFileSync: fs.readFileSync,
253+
existsSync: fs.existsSync,
254+
});
255+
// Should still parse the shim (the .CMD on disk vs .cmd in PATHEXT)
256+
expect(out[0].toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase());
257+
});
258+
});
259+
260+
describe("parseCmdShim", () => {
261+
it("extracts node path and js entry from standard npm shim", () => {
262+
const result = parseCmdShim("C:\\bin\\codex.cmd", STANDARD_NPM_CMD_SHIM);
263+
expect(result).not.toBeNull();
264+
expect(result!.node.toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase());
265+
expect(result!.entry.toLowerCase()).toBe("c:\\@openai\\codex\\bin\\codex.js".toLowerCase());
266+
});
267+
268+
it("extracts from the find_dp0 variant", () => {
269+
const result = parseCmdShim("C:\\bin\\codex.cmd", NPM_CMD_SHIM_VARIANT);
270+
expect(result).not.toBeNull();
271+
expect(result!.node.toLowerCase()).toBe("c:\\bin\\node.exe".toLowerCase());
272+
expect(result!.entry.toLowerCase()).toBe(
273+
"c:\\bin\\node_modules\\@openai\\codex\\bin\\codex.js".toLowerCase()
274+
);
275+
});
276+
277+
it("returns null when shim does not contain a recognizable js entry", () => {
278+
expect(parseCmdShim("C:\\bin\\weird.cmd", UNRECOGNIZED_CMD_SHIM)).toBeNull();
279+
});
280+
281+
it("returns null on completely empty input", () => {
282+
expect(parseCmdShim("C:\\bin\\x.cmd", "")).toBeNull();
283+
});
284+
});

0 commit comments

Comments
 (0)