From e890097907f91dac6946e7c2acb55d5399396905 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 29 Aug 2026 11:55:42 -0700 Subject: [PATCH 1/4] fix(exec): timeout Windows taskkill on the timeout path Bound taskkillTree so a hung taskkill cannot block runCommandArgs after timeoutMs already fired. Default 5s, override with CLAWPATCH_TASKKILL_TIMEOUT_MS. Signed-off-by: Sebastien Tardif --- CHANGELOG.md | 1 + src/exec.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++++++--- src/exec.ts | 33 ++++++++++++++++-- 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b26fe75..7f08ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Updated pnpm, Node typings, formatter and linter tooling, Vitest/Vite, CodeQL, TruffleHog, and the release workflow's npm CLI. - Fixed `clawpatch open-pr` so a stalled `git push` or `gh pr create` times out instead of hanging the command, thanks @SebTardif. +- Fixed Windows command timeouts so a hung `taskkill` cannot block `runCommandArgs` after the command timeout already fired, thanks @SebTardif. - Bound npm trusted publishing to the `npm-release` GitHub environment and restored canonical package repository metadata. - Reworked the README around a verified install and quickstart path, with deeper command, mapper, provider, and safety details linked to the existing docs. - Updated transitive Vitest dependencies. diff --git a/src/exec.test.ts b/src/exec.test.ts index 7b8168d..ae724af 100644 --- a/src/exec.test.ts +++ b/src/exec.test.ts @@ -1,8 +1,9 @@ -import { access, mkdtemp, writeFile } from "node:fs/promises"; +import { access, chmod, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { runCommand, runCommandArgs } from "./exec.js"; +import { delimiter, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCommand, runCommandArgs, taskkillTimeoutMs, taskkillTree } from "./exec.js"; +import { fixtureRoot, writeFixture } from "./test-helpers.js"; describe("runCommand", () => { it("runs a shell command and passes stdin", async () => { @@ -220,3 +221,82 @@ describe("runCommandArgs", () => { expect(JSON.parse(result.stdout)).toEqual(args); }); }); + +const HANG_TEST_TIMEOUT_MS = 4_000; +const SHORT_TIMEOUT_MS = 80; + +describe("taskkillTree", () => { + const previousEnv = { + CLAWPATCH_TASKKILL_TIMEOUT_MS: process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"], + PATH: process.env["PATH"], + }; + + afterEach(() => { + restoreEnv("CLAWPATCH_TASKKILL_TIMEOUT_MS", previousEnv.CLAWPATCH_TASKKILL_TIMEOUT_MS); + restoreEnv("PATH", previousEnv.PATH); + }); + + it("defaults taskkill wait to 5s and rejects invalid overrides", () => { + delete process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"]; + expect(taskkillTimeoutMs()).toBe(5_000); + + process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = "1234"; + expect(taskkillTimeoutMs()).toBe(1_234); + + process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = "invalid"; + expect(taskkillTimeoutMs()).toBe(5_000); + + process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = "0"; + expect(taskkillTimeoutMs()).toBe(5_000); + }); + + it( + "times out a hung taskkill instead of blocking the timeout path", + { timeout: HANG_TEST_TIMEOUT_MS }, + async () => { + const root = await fixtureRoot("clawpatch-taskkill-timeout-"); + process.env["PATH"] = + `${await writeHangTaskkill(root)}${delimiter}${process.env["PATH"] ?? ""}`; + process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = String(SHORT_TIMEOUT_MS); + + const outcome = await Promise.race([ + taskkillTree(42_424).then(() => "resolved" as const), + new Promise<"hung">((resolve) => { + setTimeout(() => { + resolve("hung"); + }, 1_500); + }), + ]); + + expect(outcome).toBe("resolved"); + }, + ); +}); + +async function writeHangTaskkill(root: string): Promise { + const binDir = join(root, "bin"); + if (process.platform === "win32") { + await writeFixture( + root, + "bin/taskkill.cmd", + "@echo off\r\n:loop\r\ntimeout /t 30 >nul\r\ngoto loop\r\n", + ); + return binDir; + } + const wrapper = join(binDir, "taskkill"); + await writeFixture( + root, + "bin/taskkill", + '#!/bin/sh\nexec node -e "setInterval(() => {}, 1000)"\n', + ); + await chmod(wrapper, 0o755); + return binDir; +} + +function restoreEnv(name: string, previous: string | undefined): void { + if (previous === undefined) { + delete process.env[name]; + return; + } + process.env[name] = previous; +} diff --git a/src/exec.ts b/src/exec.ts index 11001b8..16430c1 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -15,6 +15,14 @@ type CommandOptions = { const abortSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; const abortableChildren = new Set(); const abortHandlers = new Map void>(); +const defaultTaskkillTimeoutMs = 5_000; + +export function taskkillTimeoutMs(): number { + const configured = Number( + process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] ?? String(defaultTaskkillTimeoutMs), + ); + return Number.isFinite(configured) && configured > 0 ? configured : defaultTaskkillTimeoutMs; +} export async function runCommand( command: string, @@ -157,14 +165,33 @@ async function killChild(child: SpawnedChild, signal: NodeJS.Signals): Promise { +export async function taskkillTree(pid: number, timeoutMs = taskkillTimeoutMs()): Promise { await new Promise((resolve) => { const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, }); - killer.on("error", () => resolve()); - killer.on("close", () => resolve()); + let settled = false; + const finish = (): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(() => { + try { + killer.kill("SIGKILL"); + } catch {} + finish(); + }, timeoutMs); + killer.on("error", () => { + finish(); + }); + killer.on("close", () => { + finish(); + }); }); } From 0161bdcced7b76c976ee0baf0d8a49e5abe93048 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 31 Aug 2026 17:01:35 -0700 Subject: [PATCH 2/4] fix(exec): terminate children after bounded Windows cleanup Verify hanging cleanup invocation, reject unsupported timer delays, and cover the Windows caller in CI. Co-authored-by: Sebastien Tardif --- .github/workflows/ci.yml | 12 +++ CHANGELOG.md | 2 +- docs/configuration.md | 7 ++ src/exec.test.ts | 159 +++++++++++++++++++++++---------------- src/exec.ts | 8 +- 5 files changed, 122 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc6aeb7..5ba1c0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,15 @@ jobs: - run: pnpm test - run: pnpm build - run: pnpm pack:smoke + + windows-exec: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@v7 + with: + node-version: 26 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test src/exec.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f08ef0..55f74b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## 0.7.3 - Unreleased +- Fixed Windows command timeouts so a hung `taskkill` cannot keep the CLI or its direct child running after the cleanup deadline, thanks @SebTardif. - Updated pnpm, Node typings, formatter and linter tooling, Vitest/Vite, CodeQL, TruffleHog, and the release workflow's npm CLI. - Fixed `clawpatch open-pr` so a stalled `git push` or `gh pr create` times out instead of hanging the command, thanks @SebTardif. -- Fixed Windows command timeouts so a hung `taskkill` cannot block `runCommandArgs` after the command timeout already fired, thanks @SebTardif. - Bound npm trusted publishing to the `npm-release` GitHub environment and restored canonical package repository metadata. - Reworked the README around a verified install and quickstart path, with deeper command, mapper, provider, and safety details linked to the existing docs. - Updated transitive Vitest dependencies. diff --git a/docs/configuration.md b/docs/configuration.md index 2ea14a7..dc454b5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,10 +75,17 @@ Environment overrides: - `CLAWPATCH_CLAUDE_AUTH_CONTEXT` (`isolated` or `host`; default `isolated`) - `CLAWPATCH_GIT_PUSH_TIMEOUT_MS` (default `600000`, or 10 minutes) - `CLAWPATCH_GH_PR_CREATE_TIMEOUT_MS` (default `300000`, or 5 minutes) +- `CLAWPATCH_TASKKILL_TIMEOUT_MS` (Windows cleanup deadline; default `5000`, or 5 seconds) The `open-pr` timeout overrides must be positive millisecond values. Invalid values fall back to their defaults. +`CLAWPATCH_TASKKILL_TIMEOUT_MS` must be between `1` and `2147483647` milliseconds; +invalid values fall back to 5 seconds. Fractional values are truncated. Each Windows +process-tree cleanup attempt is bounded independently of the command deadline. +If cleanup fails or times out, Clawpatch also terminates the direct child; descendant +cleanup remains best effort when `taskkill` is unavailable or hung. + `provider.codexConfig` passes primitive values to Codex as `-c key=value`. Only config loaded by `--config` or `CLAWPATCH_CONFIG` may set non-empty Codex passthrough config. Auto-discovered repository and state config files diff --git a/src/exec.test.ts b/src/exec.test.ts index ae724af..3bc8c80 100644 --- a/src/exec.test.ts +++ b/src/exec.test.ts @@ -1,9 +1,9 @@ -import { access, chmod, mkdtemp, writeFile } from "node:fs/promises"; +import childProcess, { spawn, type ChildProcess } from "node:child_process"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { runCommand, runCommandArgs, taskkillTimeoutMs, taskkillTree } from "./exec.js"; -import { fixtureRoot, writeFixture } from "./test-helpers.js"; describe("runCommand", () => { it("runs a shell command and passes stdin", async () => { @@ -222,81 +222,112 @@ describe("runCommandArgs", () => { }); }); -const HANG_TEST_TIMEOUT_MS = 4_000; -const SHORT_TIMEOUT_MS = 80; +vi.mock("node:child_process", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, spawn: vi.fn(original.spawn) }; +}); -describe("taskkillTree", () => { - const previousEnv = { - CLAWPATCH_TASKKILL_TIMEOUT_MS: process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"], - PATH: process.env["PATH"], - }; +const cleanupTimeoutMs = 1_000; +describe("taskkillTree", () => { afterEach(() => { - restoreEnv("CLAWPATCH_TASKKILL_TIMEOUT_MS", previousEnv.CLAWPATCH_TASKKILL_TIMEOUT_MS); - restoreEnv("PATH", previousEnv.PATH); + vi.unstubAllEnvs(); + vi.mocked(spawn).mockImplementation(childProcess.spawn); }); - it("defaults taskkill wait to 5s and rejects invalid overrides", () => { - delete process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"]; + it("defaults to 5s and accepts supported millisecond overrides", () => { + vi.stubEnv("CLAWPATCH_TASKKILL_TIMEOUT_MS", undefined); expect(taskkillTimeoutMs()).toBe(5_000); - - process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = "1234"; + vi.stubEnv("CLAWPATCH_TASKKILL_TIMEOUT_MS", "1234"); expect(taskkillTimeoutMs()).toBe(1_234); + vi.stubEnv("CLAWPATCH_TASKKILL_TIMEOUT_MS", "2147483647"); + expect(taskkillTimeoutMs()).toBe(2_147_483_647); + }); - process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = "invalid"; - expect(taskkillTimeoutMs()).toBe(5_000); + it.each(["invalid", "", "0", "-1", "0.5", "Infinity", "2147483648"])( + "rejects unsupported timeout override %s", + (value) => { + vi.stubEnv("CLAWPATCH_TASKKILL_TIMEOUT_MS", value); + expect(taskkillTimeoutMs()).toBe(5_000); + }, + ); - process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = "0"; - expect(taskkillTimeoutMs()).toBe(5_000); + it("bounds a verified hanging cleanup process", async () => { + const root = await mkdtemp(join(tmpdir(), "clawpatch-taskkill-")); + const marker = join(root, "killer.json"); + const children = interceptTaskkill(marker); + try { + await taskkillTree(42_424, cleanupTimeoutMs); + expect(JSON.parse(await readFile(marker, "utf8"))).toEqual(["/pid", "42424", "/T", "/F"]); + expect(children).toHaveLength(1); + await expect + .poll(() => children[0]?.exitCode !== null || children[0]?.signalCode !== null) + .toBe(true); + } finally { + for (const child of children) child.kill("SIGKILL"); + await rm(root, { recursive: true, force: true }); + } }); - it( - "times out a hung taskkill instead of blocking the timeout path", - { timeout: HANG_TEST_TIMEOUT_MS }, + it.runIf(process.platform === "win32")( + "returns a timeout and kills the original child when taskkill hangs", async () => { - const root = await fixtureRoot("clawpatch-taskkill-timeout-"); - process.env["PATH"] = - `${await writeHangTaskkill(root)}${delimiter}${process.env["PATH"] ?? ""}`; - process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] = String(SHORT_TIMEOUT_MS); - - const outcome = await Promise.race([ - taskkillTree(42_424).then(() => "resolved" as const), - new Promise<"hung">((resolve) => { - setTimeout(() => { - resolve("hung"); - }, 1_500); - }), - ]); - - expect(outcome).toBe("resolved"); + const root = await mkdtemp(join(tmpdir(), "clawpatch-taskkill-caller-")); + const marker = join(root, "killer.json"); + const children = interceptTaskkill(marker); + vi.stubEnv("CLAWPATCH_TASKKILL_TIMEOUT_MS", String(cleanupTimeoutMs)); + let pid: number | undefined; + try { + const result = await runCommandArgs( + process.execPath, + ["-e", "console.log(process.pid); setInterval(() => {}, 1000)"], + root, + undefined, + { timeoutMs: 1_000 }, + ); + pid = Number(result.stdout.trim()); + expect(pid).toBeGreaterThan(0); + expect(result.exitCode).toBe(124); + expect(result.stderr).toContain("command timed out after 1000ms"); + expect(JSON.parse(await readFile(marker, "utf8"))).toEqual([ + "/pid", + String(pid), + "/T", + "/F", + ]); + expect(children.length).toBeGreaterThan(0); + expect(() => process.kill(pid!, 0)).toThrow(); + } finally { + if (pid) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + for (const child of children) child.kill("SIGKILL"); + await rm(root, { recursive: true, force: true }); + } }, ); }); -async function writeHangTaskkill(root: string): Promise { - const binDir = join(root, "bin"); - if (process.platform === "win32") { - await writeFixture( - root, - "bin/taskkill.cmd", - "@echo off\r\n:loop\r\ntimeout /t 30 >nul\r\ngoto loop\r\n", +function interceptTaskkill(marker: string): ChildProcess[] { + const realSpawn = childProcess.spawn; + const children: ChildProcess[] = []; + vi.mocked(spawn).mockImplementation(((...params: Parameters) => { + const [program, args = [], options = {}] = params; + if (program !== "taskkill") return realSpawn(program, args, options); + const child = realSpawn( + process.execPath, + [ + "-e", + "require('node:fs').writeFileSync(process.argv[1], JSON.stringify(process.argv.slice(2))); setInterval(() => {}, 1000)", + marker, + ...args, + ], + options, ); - return binDir; - } - const wrapper = join(binDir, "taskkill"); - await writeFixture( - root, - "bin/taskkill", - '#!/bin/sh\nexec node -e "setInterval(() => {}, 1000)"\n', - ); - await chmod(wrapper, 0o755); - return binDir; -} - -function restoreEnv(name: string, previous: string | undefined): void { - if (previous === undefined) { - delete process.env[name]; - return; - } - process.env[name] = previous; + children.push(child); + return child; + }) as typeof childProcess.spawn); + return children; } diff --git a/src/exec.ts b/src/exec.ts index 16430c1..6b73f5a 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -21,7 +21,9 @@ export function taskkillTimeoutMs(): number { const configured = Number( process.env["CLAWPATCH_TASKKILL_TIMEOUT_MS"] ?? String(defaultTaskkillTimeoutMs), ); - return Number.isFinite(configured) && configured > 0 ? configured : defaultTaskkillTimeoutMs; + return Number.isFinite(configured) && configured >= 1 && configured <= 2_147_483_647 + ? Math.trunc(configured) + : defaultTaskkillTimeoutMs; } export async function runCommand( @@ -152,6 +154,10 @@ function terminateChild(child: SpawnedChild, onForceKill: () => void): NodeJS.Ti async function killChild(child: SpawnedChild, signal: NodeJS.Signals): Promise { if (process.platform === "win32" && child.pid !== undefined) { await taskkillTree(child.pid); + // A failed or hung tree killer must not leave the direct child keeping the CLI alive. + try { + child.kill(signal); + } catch {} return; } try { From 117a2502a6ab5213c926ad5e50a1e64dfdf7e167 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 31 Aug 2026 17:06:19 -0700 Subject: [PATCH 3/4] docs(changelog): keep timeout entry mergeable with main --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f74b5..4b04e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## 0.7.3 - Unreleased -- Fixed Windows command timeouts so a hung `taskkill` cannot keep the CLI or its direct child running after the cleanup deadline, thanks @SebTardif. - Updated pnpm, Node typings, formatter and linter tooling, Vitest/Vite, CodeQL, TruffleHog, and the release workflow's npm CLI. - Fixed `clawpatch open-pr` so a stalled `git push` or `gh pr create` times out instead of hanging the command, thanks @SebTardif. +- Fixed Windows command timeouts so a hung `taskkill` cannot keep the CLI or its direct child running after the cleanup deadline, thanks @SebTardif. - Bound npm trusted publishing to the `npm-release` GitHub environment and restored canonical package repository metadata. - Reworked the README around a verified install and quickstart path, with deeper command, mapper, provider, and safety details linked to the existing docs. - Updated transitive Vitest dependencies. From a08843e342983fdda72766b98a666aa6112be717 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 31 Aug 2026 17:13:27 -0700 Subject: [PATCH 4/4] fix(exec): preserve Windows shell command quoting --- CHANGELOG.md | 1 + src/exec.test.ts | 9 +++++---- src/exec.ts | 13 ++++++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b04e76..b99629c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Updated pnpm, Node typings, formatter and linter tooling, Vitest/Vite, CodeQL, TruffleHog, and the release workflow's npm CLI. - Fixed `clawpatch open-pr` so a stalled `git push` or `gh pr create` times out instead of hanging the command, thanks @SebTardif. - Fixed Windows command timeouts so a hung `taskkill` cannot keep the CLI or its direct child running after the cleanup deadline, thanks @SebTardif. +- Fixed Windows shell validation commands with quoted executable paths. - Bound npm trusted publishing to the `npm-release` GitHub environment and restored canonical package repository metadata. - Reworked the README around a verified install and quickstart path, with deeper command, mapper, provider, and safety details linked to the existing docs. - Updated transitive Vitest dependencies. diff --git a/src/exec.test.ts b/src/exec.test.ts index 3bc8c80..1a1e9f7 100644 --- a/src/exec.test.ts +++ b/src/exec.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runCommand, runCommandArgs, taskkillTimeoutMs, taskkillTree } from "./exec.js"; +import { shellQuotePath } from "./shell.js"; describe("runCommand", () => { it("runs a shell command and passes stdin", async () => { @@ -16,7 +17,7 @@ describe("runCommand", () => { ); const result = await runCommand( - `${JSON.stringify(process.execPath)} ${JSON.stringify(script)}`, + `${shellQuotePath(process.execPath)} ${shellQuotePath(script)}`, dir, "ok", ); @@ -29,7 +30,7 @@ describe("runCommand", () => { const dir = await mkdtemp(join(tmpdir(), "clawpatch-exec-shell-")); const script = join(dir, "large-output.mjs"); await writeFile(script, "process.stdout.write('x'.repeat(9000));", "utf8"); - const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(script)}`; + const command = `${shellQuotePath(process.execPath)} ${shellQuotePath(script)}`; const trimmed = await runCommand(command, dir); const raw = await runCommand(command, dir, undefined, { trimOutput: false }); @@ -46,13 +47,13 @@ describe("runCommand", () => { await writeFile(hanging, "setInterval(() => {}, 1000);", "utf8"); const bounded = await runCommand( - `${JSON.stringify(process.execPath)} ${JSON.stringify(noisy)}`, + `${shellQuotePath(process.execPath)} ${shellQuotePath(noisy)}`, dir, undefined, { trimOutput: false, maxOutputChars: 10_000 }, ); const timedOut = await runCommand( - `${JSON.stringify(process.execPath)} ${JSON.stringify(hanging)}`, + `${shellQuotePath(process.execPath)} ${shellQuotePath(hanging)}`, dir, undefined, { timeoutMs: 50 }, diff --git a/src/exec.ts b/src/exec.ts index 6b73f5a..8ed2fb6 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -10,6 +10,7 @@ type CommandOptions = { timeoutMs?: number; replaceEnv?: boolean; maxOutputChars?: number; + windowsVerbatimArguments?: boolean; }; const abortSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; @@ -42,8 +43,13 @@ export async function runCommandRaw( options: CommandOptions = {}, ): Promise { const shell = process.platform === "win32" ? (process.env["ComSpec"] ?? "cmd.exe") : "/bin/sh"; - const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-c", command]; - const result = await runCommandArgs(shell, args, cwd, input, options); + const windows = process.platform === "win32"; + // cmd.exe owns shell quoting; Node's executable argument escaping breaks quoted paths. + const args = windows ? ["/d", "/s", "/c", `"${command}"`] : ["-c", command]; + const result = await runCommandArgs(shell, args, cwd, input, { + ...options, + windowsVerbatimArguments: windows, + }); return { ...result, command }; } @@ -67,7 +73,8 @@ export async function runCommandArgs( detached: process.platform !== "win32" && options.timeoutMs !== undefined, shell: false, stdio: ["pipe", "pipe", "pipe"], - windowsVerbatimArguments: spawnSpec.windowsVerbatimArguments, + windowsVerbatimArguments: + options.windowsVerbatimArguments ?? spawnSpec.windowsVerbatimArguments, }); const stdout = new OutputBuffer(options.maxOutputChars); const stderr = new OutputBuffer(options.maxOutputChars);