authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 04:17:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 04:17:42-07:00
logc08093c2b136353bc755459febf3e8afd46bf50e
treecaf38ab6b1a6bc72110baa52e250a80eb2de62a6
parentf5b5f9dd7be29c4f55f4687dda15e4105cf966fc
signature Signed by PGP key B5690EEEBB952194

fix(shared): Windows .cmd shims no longer print DEP0190 on every spawn (#19385)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

8 files changed, 89 insertions(+), 75 deletions(-)

apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+5-2
......@@ -693,8 +693,11 @@ describe("CodexAdapterV2 process spawning", () => {
693693 if (!ChildProcess.isStandardCommand(command)) {
694694 return;
695695 }
696 assert.equal(command.command, '^"C:\\npm\\codex.cmd^"');
697 assert.deepEqual(command.args, ['^"app-server^"', '^"argument^ with^ spaces^"']);
696 assert.equal(
697 command.command,
698 '^"C:\\npm\\codex.cmd^" ^"app-server^" ^"argument^ with^ spaces^"',
699 );
700 assert.deepEqual(command.args, []);
698701 assert.equal(command.options.shell, true);
699702 assert.equal(command.options.cwd, "C:\\workspace");
700703 assert.deepEqual(command.options.env, { CUSTOM: "1" });
apps/server/src/process/externalLauncher.test.ts+29-18
......@@ -153,11 +153,15 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
153153 );
154154
155155 assert.ok(spawned);
156 assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"');
157 assert.deepEqual(spawned.args, [
158 '^"--goto^"',
159 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
160 ]);
156 assert.equal(
157 spawned.command,
158 [
159 '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"',
160 '^"--goto^"',
161 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
162 ].join(" "),
163 );
164 assert.deepEqual(spawned.args, []);
161165 assert.equal(spawned.options.shell, true);
162166 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
163167);
......@@ -241,12 +245,16 @@ it.effect("launches Cursor in classic IDE mode through the Windows command shim"
241245 );
242246
243247 assert.ok(spawned);
244 assert.equal(spawned.command, '^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"');
245 assert.deepEqual(spawned.args, [
246 '^"--classic^"',
247 '^"--goto^"',
248 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
249 ]);
248 assert.equal(
249 spawned.command,
250 [
251 '^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"',
252 '^"--classic^"',
253 '^"--goto^"',
254 '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
255 ].join(" "),
256 );
257 assert.deepEqual(spawned.args, []);
250258 assert.equal(spawned.options.shell, true);
251259 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
252260);
......@@ -1033,12 +1041,13 @@ for (const { platform, installPath, editor, args } of [
10331041 ),
10341042 );
10351043 assert.ok(spawned);
1044 const shim = executable.endsWith(".cmd");
10361045 assert.equal(
10371046 spawned.command,
1038 executable.endsWith(".cmd") ? `^"${executable.replaceAll(" ", "^ ")}^"` : executable,
1047 shim ? [`^"${executable.replaceAll(" ", "^ ")}^"`, ...args].join(" ") : executable,
10391048 );
1040 assert.deepEqual(spawned.args, args);
1041 assert.equal(spawned.options.shell, executable.endsWith(".cmd"));
1049 assert.deepEqual(spawned.args, shim ? [] : args);
1050 assert.equal(spawned.options.shell, shim);
10421051 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
10431052 );
10441053}
......@@ -1476,10 +1485,12 @@ it.effect("starts a Windows command shim for unicode paths with cmd metacharacte
14761485 assert.isTrue(Exit.isSuccess(exit));
14771486 assert.ok(spawned);
14781487 assert.equal(spawned.options.shell, true);
1479 assert.deepEqual(spawned.args, [
1480 '^"--goto^"',
1481 '^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"',
1482 ]);
1488 assert.deepEqual(spawned.args, []);
1489 assert.isTrue(
1490 spawned.command.endsWith(
1491 ' ^"--goto^" ^"C:\\Users\\jö\\R^&D^ ^(100^%^)\\naïve^ ^^file^!.ts:3:7^"',
1492 ),
1493 );
14831494 }),
14841495);
14851496
apps/server/src/processRunner.test.ts+11-8
......@@ -127,14 +127,17 @@ describe("runProcess", () => {
127127 it.effect("resolves and escapes Windows command shims before spawning", () => {
128128 const spawner = makeSpawner((command) =>
129129 Effect.sync(() => {
130 expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"');
131 expect(command.args).toEqual([
132 '^"repos^"',
133 '^"pr^"',
134 '^"list^"',
135 '^"--source-branch^"',
136 '^"feature^ ^&^ release^"',
137 ]);
130 expect(command.command).toBe(
131 [
132 '^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"',
133 '^"repos^"',
134 '^"pr^"',
135 '^"list^"',
136 '^"--source-branch^"',
137 '^"feature^ ^&^ release^"',
138 ].join(" "),
139 );
140 expect(command.args).toEqual([]);
138141 expect(command.options.shell).toBe(true);
139142 return makeHandle({ stdout: "[]" });
140143 }),
apps/server/src/provider/providerMaintenanceRunner.test.ts+6-11
......@@ -943,21 +943,16 @@ describe("providerMaintenanceRunner", () => {
943943 const result = yield* runner.updateProvider(CODEX_DRIVER);
944944
945945 // On win32, resolveSpawnCommand resolves `npm` to the `.cmd` shim and
946 // routes the spawn through cmd.exe (shell: true), escaping every arg.
946 // routes the spawn through cmd.exe (shell: true) as one escaped line.
947947 assert.strictEqual(captured.length, 1);
948948 const call = captured[0];
949949 assert.ok(call, "expected the spawner to be invoked once");
950 // The resolved command is the escaped `.cmd` path. Asserting the precise
951 // escaped string is brittle, so verify it carries the resolved shim and
952 // that shell mode was used.
953 assert.match(call.command, /npm\.cmd/i);
950 assert.strictEqual(
951 call.command,
952 '^"C:\\fake\\npm\\npm.cmd^" ^"install^" ^"-g^" ^"@openai/codex@latest^"',
953 );
954 assert.deepEqual(call.args, []);
954955 assert.strictEqual(call.shell, true);
955 // Args are escaped for cmd.exe shell mode (each quoted) but still carry
956 // the original install command (`install -g @openai/codex@latest`) in order.
957 assert.strictEqual(call.args.length, 3);
958 assert.match(call.args[0] ?? "", /install/);
959 assert.match(call.args[1] ?? "", /-g/);
960 assert.match(call.args[2] ?? "", /@openai\/codex@latest/);
961956 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
962957 }).pipe(
963958 Effect.provide(
packages/shared/src/shell.test.ts+25-11
......@@ -681,11 +681,19 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
681681 }),
682682 );
683683
684 it.effect("escapes the executable and arguments for Windows command shims", () =>
684 it.effect("builds one escaped cmd.exe line for Windows command shims", () =>
685685 Effect.gen(function* () {
686686 const command = yield* resolveSpawnCommand(
687687 "vp",
688 ["run", "value & calc", "%PATH%", 'quote"value'],
688 [
689 "run",
690 "value & calc",
691 "%PATH%",
692 'quote"value',
693 "C:\\dir with space\\",
694 'slash\\"quote',
695 "x|y<z>^",
696 ],
689697 { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
690698 ).pipe(
691699 Effect.provideService(HostProcess.Platform, "win32"),
......@@ -696,15 +704,21 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
696704 ),
697705 );
698706
699 expect(command.shell).toBe(true);
700 expect(command.command).not.toContain(" & ");
701 expect(command.command).toContain("^&");
702 expect(command.args).toEqual([
703 '^"run^"',
704 '^"value^ ^&^ calc^"',
705 '^"^%PATH^%^"',
706 '^"quote\\^"value^"',
707 ]);
707 // No args alongside `shell: true`: Node prints DEP0190 for that pairing.
708 expect(command).toEqual({
709 command: [
710 String.raw`^"C:\Program^ Files\npm^ ^&^ tools\vp.cmd^"`,
711 String.raw`^"run^"`,
712 String.raw`^"value^ ^&^ calc^"`,
713 String.raw`^"^%PATH^%^"`,
714 String.raw`^"quote\^"value^"`,
715 String.raw`^"C:\dir^ with^ space\\^"`,
716 String.raw`^"slash\\\^"quote^"`,
717 String.raw`^"x^|y^<z^>^^^"`,
718 ].join(" "),
719 args: [],
720 shell: true,
721 });
708722 }),
709723 );
710724
packages/shared/src/shell.ts+9-18
......@@ -46,10 +46,10 @@ const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;
4646
4747/**
4848 * Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })`
49 * on Windows). Node joins the command and arguments with spaces and hands the
50 * resulting string to `cmd.exe` without any quoting, so every dynamic argument
51 * must be escaped to survive both cmd.exe parsing and the target program's
52 * `CommandLineToArgvW` parsing. Mirrors cross-spawn's argument escaping.
49 * on Windows). Node hands the command string to `cmd.exe /d /s /c "<command>"`
50 * without any quoting, so every dynamic argument must be escaped to survive both
51 * cmd.exe parsing and the target program's `CommandLineToArgvW` parsing.
52 * Mirrors cross-spawn's argument escaping.
5353 */
5454function escapeWindowsShellArg(arg: string): string {
5555 // Double up backslashes that precede a double quote, then escape the quote
......@@ -63,18 +63,6 @@ function escapeWindowsShellArg(arg: string): string {
6363 return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1");
6464}
6565
66/**
67 * Escapes arguments for shell-mode spawns: applies {@link escapeWindowsShellArg}
68 * when the platform is `win32` (where `shell: true` routes through `cmd.exe`)
69 * and returns the arguments untouched everywhere else.
70 */
71function sanitizeShellModeArgsForPlatform(
72 args: ReadonlyArray<string>,
73 platform: NodeJS.Platform,
74): Array<string> {
75 return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args];
76}
77
7866export interface ResolvedSpawnCommand {
7967 readonly command: string;
8068 readonly args: ReadonlyArray<string>;
......@@ -773,9 +761,12 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
773761 return { command: resolvedCommand, args: [...args], shell: false };
774762 }
775763
764 // Node runs a shell spawn as `cmd.exe /d /s /c "<command> <args...>"`, but
765 // passing args with `shell: true` prints DEP0190. Build the same escaped line
766 // here and pass no args.
776767 return {
777 command: escapeWindowsShellArg(resolvedCommand),
778 args: sanitizeShellModeArgsForPlatform(args, platform),
768 command: [resolvedCommand, ...args].map(escapeWindowsShellArg).join(" "),
769 args: [],
779770 shell: true,
780771 };
781772});
scripts/dev-runner.ts+3-6
......@@ -791,15 +791,12 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
791791 }
792792 }
793793
794 const spawnCommand = yield* resolveSpawnCommand(
795 "vp",
796 [...MODE_ARGS[input.mode], ...input.runArgs],
797 { env },
798 );
794 const args = [...MODE_ARGS[input.mode], ...input.runArgs];
795 const spawnCommand = yield* resolveSpawnCommand("vp", args, { env });
799796 const processContext = {
800797 mode: input.mode,
801798 executable: "vp" as const,
802 argumentCount: spawnCommand.args.length,
799 argumentCount: args.length,
803800 shell: spawnCommand.shell,
804801 } as const;
805802 const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, {
scripts/mobile-native-static-check.ts+1-1
......@@ -127,7 +127,7 @@ export const runCommand = Effect.fn("runCommand")(function* (
127127 const spawnCommand = yield* resolveSpawnCommand(command, args);
128128 const processContext = {
129129 command,
130 argumentCount: spawnCommand.args.length,
130 argumentCount: args.length,
131131 cwd,
132132 shell: spawnCommand.shell,
133133 } as const;