Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -693,8 +693,11 @@ describe("CodexAdapterV2 process spawning", () => {
if (!ChildProcess.isStandardCommand(command)) {
return;
}
assert.equal(command.command, '^"C:\\npm\\codex.cmd^"');
assert.deepEqual(command.args, ['^"app-server^"', '^"argument^ with^ spaces^"']);
assert.equal(
command.command,
'^"C:\\npm\\codex.cmd^" ^"app-server^" ^"argument^ with^ spaces^"',
);
assert.deepEqual(command.args, []);
assert.equal(command.options.shell, true);
assert.equal(command.options.cwd, "C:\\workspace");
assert.deepEqual(command.options.env, { CUSTOM: "1" });
Expand Down
47 changes: 29 additions & 18 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,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 @@ -241,12 +245,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 @@ -1033,12 +1041,13 @@ for (const { platform, installPath, editor, args } of [
),
);
assert.ok(spawned);
const shim = executable.endsWith(".cmd");
assert.equal(
spawned.command,
executable.endsWith(".cmd") ? `^"${executable.replaceAll(" ", "^ ")}^"` : executable,
shim ? [`^"${executable.replaceAll(" ", "^ ")}^"`, ...args].join(" ") : executable,
);
assert.deepEqual(spawned.args, args);
assert.equal(spawned.options.shell, executable.endsWith(".cmd"));
assert.deepEqual(spawned.args, shim ? [] : args);
assert.equal(spawned.options.shell, shim);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
}
Expand Down Expand Up @@ -1476,10 +1485,12 @@ it.effect("starts a Windows command shim for unicode paths with cmd metacharacte
assert.isTrue(Exit.isSuccess(exit));
assert.ok(spawned);
assert.equal(spawned.options.shell, true);
assert.deepEqual(spawned.args, [
'^"--goto^"',
'^"C:\\Users\\j枚\\R^&D^ ^(100^%^)\\na茂ve^ ^^file^!.ts:3:7^"',
]);
assert.deepEqual(spawned.args, []);
assert.isTrue(
spawned.command.endsWith(
' ^"--goto^" ^"C:\\Users\\j枚\\R^&D^ ^(100^%^)\\na茂ve^ ^^file^!.ts:3:7^"',
),
);
}),
);

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
17 changes: 6 additions & 11 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,21 +943,16 @@ 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.
// routes the spawn through cmd.exe (shell: true) as one escaped line.
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.
assert.match(call.command, /npm\.cmd/i);
assert.strictEqual(
call.command,
'^"C:\\fake\\npm\\npm.cmd^" ^"install^" ^"-g^" ^"@openai/codex@latest^"',
);
assert.deepEqual(call.args, []);
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.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
}).pipe(
Effect.provide(
Expand Down
36 changes: 25 additions & 11 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,11 +681,19 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
}),
);

it.effect("escapes the executable and arguments for Windows command shims", () =>
it.effect("builds one escaped cmd.exe line for Windows command shims", () =>
Effect.gen(function* () {
const command = yield* resolveSpawnCommand(
"vp",
["run", "value & calc", "%PATH%", 'quote"value'],
[
"run",
"value & calc",
"%PATH%",
'quote"value',
"C:\\dir with space\\",
'slash\\"quote',
"x|y<z>^",
],
{ env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" } },
).pipe(
Effect.provideService(HostProcess.Platform, "win32"),
Expand All @@ -696,15 +704,21 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
),
);

expect(command.shell).toBe(true);
expect(command.command).not.toContain(" & ");
expect(command.command).toContain("^&");
expect(command.args).toEqual([
'^"run^"',
'^"value^ ^&^ calc^"',
'^"^%PATH^%^"',
'^"quote\\^"value^"',
]);
// No args alongside `shell: true`: Node prints DEP0190 for that pairing.
expect(command).toEqual({
command: [
String.raw`^"C:\Program^ Files\npm^ ^&^ tools\vp.cmd^"`,
String.raw`^"run^"`,
String.raw`^"value^ ^&^ calc^"`,
String.raw`^"^%PATH^%^"`,
String.raw`^"quote\^"value^"`,
String.raw`^"C:\dir^ with^ space\\^"`,
String.raw`^"slash\\\^"quote^"`,
String.raw`^"x^|y^<z^>^^^"`,
].join(" "),
args: [],
shell: true,
});
}),
);

Expand Down
27 changes: 9 additions & 18 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ 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.
* on Windows). Node hands the command string to `cmd.exe /d /s /c "<command>"`
* 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.
*/
function escapeWindowsShellArg(arg: string): string {
// Double up backslashes that precede a double quote, then escape the quote
Expand All @@ -63,18 +63,6 @@ function escapeWindowsShellArg(arg: string): string {
return escaped.replace(WINDOWS_SHELL_META_CHARS, "^$1");
}

/**
* 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.
*/
function sanitizeShellModeArgsForPlatform(
args: ReadonlyArray<string>,
platform: NodeJS.Platform,
): Array<string> {
return platform === "win32" ? args.map(escapeWindowsShellArg) : [...args];
}

export interface ResolvedSpawnCommand {
readonly command: string;
readonly args: ReadonlyArray<string>;
Expand Down Expand Up @@ -773,9 +761,12 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
return { command: resolvedCommand, args: [...args], shell: false };
}

// Node runs a shell spawn as `cmd.exe /d /s /c "<command> <args...>"`, but
// passing args with `shell: true` prints DEP0190. Build the same escaped line
// here and pass no args.
return {
command: escapeWindowsShellArg(resolvedCommand),
args: sanitizeShellModeArgsForPlatform(args, platform),
command: [resolvedCommand, ...args].map(escapeWindowsShellArg).join(" "),
args: [],
shell: true,
};
});
Expand Down
9 changes: 3 additions & 6 deletions scripts/dev-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,15 +791,12 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
}
}

const spawnCommand = yield* resolveSpawnCommand(
"vp",
[...MODE_ARGS[input.mode], ...input.runArgs],
{ env },
);
const args = [...MODE_ARGS[input.mode], ...input.runArgs];
const spawnCommand = yield* resolveSpawnCommand("vp", args, { env });
const processContext = {
mode: input.mode,
executable: "vp" as const,
argumentCount: spawnCommand.args.length,
argumentCount: args.length,
shell: spawnCommand.shell,
} as const;
const child = yield* ChildProcess.make(spawnCommand.command, spawnCommand.args, {
Expand Down
2 changes: 1 addition & 1 deletion scripts/mobile-native-static-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export const runCommand = Effect.fn("runCommand")(function* (
const spawnCommand = yield* resolveSpawnCommand(command, args);
const processContext = {
command,
argumentCount: spawnCommand.args.length,
argumentCount: args.length,
cwd,
shell: spawnCommand.shell,
} as const;
Expand Down
Loading