Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,15 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
);

assert.ok(spawned);
assert.equal(spawned.command, '^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"');
assert.deepEqual(spawned.args, [
'^"--goto^"',
'^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
]);
assert.equal(
spawned.command,
[
'^"C:\\Program^ Files\\Microsoft^ VS^ Code\\bin\\code.CMD^"',
'^"--goto^"',
'^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
].join(" "),
);
assert.deepEqual(spawned.args, []);
assert.equal(spawned.options.shell, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
Expand Down Expand Up @@ -238,12 +242,16 @@ it.effect("launches Cursor in classic IDE mode through the Windows command shim"
);

assert.ok(spawned);
assert.equal(spawned.command, '^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"');
assert.deepEqual(spawned.args, [
'^"--classic^"',
'^"--goto^"',
'^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
]);
assert.equal(
spawned.command,
[
'^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"',
'^"--classic^"',
'^"--goto^"',
'^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"',
].join(" "),
);
assert.deepEqual(spawned.args, []);
assert.equal(spawned.options.shell, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
Expand Down Expand Up @@ -1018,11 +1026,14 @@ for (const { platform, installPath, editor, args } of [
),
);
assert.ok(spawned);
assert.equal(
spawned.command,
executable.endsWith(".cmd") ? `^"${executable.replaceAll(" ", "^ ")}^"` : executable,
);
assert.deepEqual(spawned.args, args);
if (executable.endsWith(".cmd")) {
const escapedExecutable = `^"${executable.replaceAll(" ", "^ ")}^"`;
assert.equal(spawned.command, [escapedExecutable, ...args].join(" "));
assert.deepEqual(spawned.args, []);
} else {
assert.equal(spawned.command, executable);
assert.deepEqual(spawned.args, args);
}
assert.equal(spawned.options.shell, executable.endsWith(".cmd"));
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
Expand Down
19 changes: 11 additions & 8 deletions apps/server/src/processRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,17 @@ describe("runProcess", () => {
it.effect("resolves and escapes Windows command shims before spawning", () => {
const spawner = makeSpawner((command) =>
Effect.sync(() => {
expect(command.command).toBe('^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"');
expect(command.args).toEqual([
'^"repos^"',
'^"pr^"',
'^"list^"',
'^"--source-branch^"',
'^"feature^ ^&^ release^"',
]);
expect(command.command).toBe(
[
'^"C:\\Users\\tester\\AppData\\Roaming\\npm\\az.cmd^"',
'^"repos^"',
'^"pr^"',
'^"list^"',
'^"--source-branch^"',
'^"feature^ ^&^ release^"',
].join(" "),
);
expect(command.args).toEqual([]);
expect(command.options.shell).toBe(true);
return makeHandle({ stdout: "[]" });
}),
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeExecutable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export const ClaudeExecutableFileCheck = Context.Reference<ExecutableFileCheck>(
* PATHEXT resolution, so a bare command name like `claude` fails with
* "native binary not found" and an npm `claude.cmd` shim fails with
* `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can
* fall back to `shell: true`; the SDK offers no such escape hatch.
* fall back to `shell: true` with an empty args array; the SDK offers no
* such escape hatch.
*
* On Windows this resolves the command against PATH/PATHEXT and, when the
* result is an npm launcher shim, follows it to the real package entry
Expand Down
17 changes: 7 additions & 10 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,21 +862,18 @@ describe("providerMaintenanceRunner", () => {
const result = yield* runner.updateProvider(CODEX_DRIVER);

// On win32, resolveSpawnCommand resolves `npm` to the `.cmd` shim and
// routes the spawn through cmd.exe (shell: true), escaping every arg.
// folds the escaped command line into `command` with shell: true and
// empty args (Node 24 DEP0190 forbids spawn(file, args, { shell: true })).
assert.strictEqual(captured.length, 1);
const call = captured[0];
assert.ok(call, "expected the spawner to be invoked once");
// The resolved command is the escaped `.cmd` path. Asserting the precise
// escaped string is brittle, so verify it carries the resolved shim and
// that shell mode was used.
// The resolved command is the escaped `.cmd` path plus install args.
assert.match(call.command, /npm\.cmd/i);
assert.strictEqual(call.shell, true);
// Args are escaped for cmd.exe shell mode (each quoted) but still carry
// the original install command (`install -g @openai/codex@latest`) in order.
assert.strictEqual(call.args.length, 3);
assert.match(call.args[0] ?? "", /install/);
assert.match(call.args[1] ?? "", /-g/);
assert.match(call.args[2] ?? "", /@openai\/codex@latest/);
assert.deepStrictEqual(call.args, []);
assert.match(call.command, /install/);
assert.match(call.command, /-g/);
assert.match(call.command, /@openai\/codex@latest/);
assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
}).pipe(
Effect.provide(
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/provider/providerMaintenanceRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,10 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
// Resolve the executable for the host platform before spawning. On
// Windows the update tools are batch shims (e.g. `npm` -> `npm.cmd`),
// which a bare ChildProcess.spawn cannot launch (spawn npm ENOENT);
// resolveSpawnCommand finds the real `.cmd` and routes it through the
// shell. On Linux/macOS (incl. the WSL backend) this is a no-op.
// resolveSpawnCommand finds the real `.cmd` and folds the escaped
// command line into `command` with `shell: true` and empty `args`
// (Node 24 DEP0190 forbids passing args alongside shell: true).
// On Linux/macOS (incl. the WSL backend) this is a no-op.
const resolved = yield* resolveSpawnCommand(input.command, input.args);
const child = yield* input.spawner
.spawn(
Expand Down
37 changes: 28 additions & 9 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,15 +504,34 @@
),
);

expect(command.shell).toBe(true);
expect(command.command).not.toContain(" & ");
expect(command.command).toContain("^&");
expect(command.args).toEqual([
'^"run^"',
'^"value^ ^&^ calc^"',
'^"^%PATH^%^"',
'^"quote\\^"value^"',
]);
expect(command).toEqual({
command: [
'^"C:\\Program^ Files\\npm^ ^&^ tools\\vp.cmd^"',
'^"run^"',
'^"value^ ^&^ calc^"',
'^"^%PATH^%^"',
'^"quote\\^"value^"',
].join(" "),
args: [],
shell: true,
});
}),
);

it.effect("launches Windows .bat shims without passing args alongside shell: true", () =>
Effect.gen(function* () {
const command = yield* resolveSpawnCommand("tool", ["--flag"], {
env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
}).pipe(
Effect.provideService(HostProcessPlatform, "win32"),
Effect.provideService(SpawnExecutableResolution, () => "C:\\tools\\tool.bat"),
);

expect(command).toEqual({
command: '^"C:\\tools\\tool.bat^" ^"--flag^"',
args: [],
shell: true,
});
}),
);

Expand Down Expand Up @@ -609,7 +628,7 @@
? {
PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",

Check failure on line 631 in packages/shared/src/shell.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/shell.test.ts > resolveSpawnCommand > launches Windows .bat shims without passing args alongside shell: true

ReferenceError: HostProcessPlatform is not defined ❯ src/shell.test.ts:631:31 ❯ IteratorImpl.~effect/Effect/successCont ../../node_modules/.pnpm/effect@4.0.2_patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688/node_modules/effect/src/internal/effect.ts:1421:28 ❯ IteratorImpl.~effect/Effect/evaluate ../../node_modules/.pnpm/effect@4.0.2_patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688/node_modules/effect/src/internal/effect.ts:1433:25 ❯ FiberImpl.runLoop ../../node_modules/.pnpm/effect@4.0.2_patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688/node_modules/effect/src/internal/effect.ts:653:41 ❯ FiberImpl.evaluate ../../node_modules/.pnpm/effect@4.0.2_patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688/node_modules/effect/src/internal/effect.ts:594:23 ❯ ../../node_modules/.pnpm/effect@4.0.2_patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688/node_modules/effect/src/internal/effect.ts:5681:9 ❯ ../../node_modules/.pnpm/effect@4.0.2_patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688/node_modules/effect/src/internal/effect.ts:5759:19 ❯ Module.<anonymous> ../../node_modules/.pnpm/effect@4.0.2_patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688/node_modules/effect/src/internal/effect.ts:5778:5
}
: { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
);
Expand Down
28 changes: 13 additions & 15 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ export class CommandResolutionError extends Data.TaggedError("CommandResolutionE
const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g;

/**
* Escapes a single argument for `cmd.exe` shell mode (`spawn(..., { shell: true })`
* on Windows). Node joins the command and arguments with spaces and hands the
* resulting string to `cmd.exe` without any quoting, so every dynamic argument
* must be escaped to survive both cmd.exe parsing and the target program's
* `CommandLineToArgvW` parsing. Mirrors cross-spawn's argument escaping.
* Escapes a single argument so it can be concatenated into a `cmd.exe /c`
* command line. Mirrors cross-spawn: the value must survive both cmd.exe
* parsing and the target program's `CommandLineToArgvW` parsing.
*/
function escapeWindowsShellArg(arg: string): string {
// Double up backslashes that precede a double quote, then escape the quote
Expand All @@ -68,15 +66,15 @@ function escapeWindowsShellArg(arg: string): string {
}

/**
* Escapes arguments for shell-mode spawns: applies {@link escapeWindowsShellArg}
* when the platform is `win32` (where `shell: true` routes through `cmd.exe`)
* and returns the arguments untouched everywhere else.
* Builds the already-escaped command line Node hands to `cmd.exe /d /s /c`
* when `shell: true` on Windows. Callers must spawn this as `command` with an
* empty `args` array: Node 24's DEP0190 fires for `spawn(file, args, { shell:
* true })` when `args` is non-empty, because those args are concatenated
* rather than escaped. An empty argv is the documented migration; Node still
* expands it to `%ComSpec% /d /s /c "…"` with `windowsVerbatimArguments`.
*/
function sanitizeShellModeArgsForPlatform(
args: ReadonlyArray<string>,
platform: NodeJS.Platform,
): Array<string> {
return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args];
function buildWindowsCmdExeCommandLine(command: string, args: ReadonlyArray<string>): string {
return [escapeWindowsShellArg(command), ...args.map(escapeWindowsShellArg)].join(" ");
}

export interface ResolvedSpawnCommand {
Expand Down Expand Up @@ -650,8 +648,8 @@ export const resolveSpawnCommand = Effect.fn("shell.resolveSpawnCommand")(functi
}

return {
command: escapeWindowsShellArg(resolvedCommand),
args: sanitizeShellModeArgsForPlatform(args, platform),
command: buildWindowsCmdExeCommandLine(resolvedCommand, args),
args: [],
shell: true,
};
});
Expand Down
Loading