From 19f68fa2f6de2ba7d2bb86f34b5e9b2ac68abbf8 Mon Sep 17 00:00:00 2001 From: Austin Liu Date: Sun, 26 Jul 2026 14:08:01 +0930 Subject: [PATCH] fix(windows): request shell when spawning bare qoder binary name on Windows (fixes #8590) Post Node CVE-2024-27980, spawn('qodercli', [], { shell: false }) fails with ENOENT on Windows when command is a bare binary name without extension. Enabling shell mode for bare command names allows cmd.exe to resolve .cmd / .bat wrappers from PATH. --- open-sse/services/qoderCliResolve.ts | 12 +++++++- .../qodercli-windows-resolve-6263.test.ts | 28 ++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/open-sse/services/qoderCliResolve.ts b/open-sse/services/qoderCliResolve.ts index 4a098ccc9f..453e50df95 100644 --- a/open-sse/services/qoderCliResolve.ts +++ b/open-sse/services/qoderCliResolve.ts @@ -11,6 +11,7 @@ * we reuse it: `getCliRuntimeStatus("qoder")` returns an absolute `.cmd`/`.exe` * `commandPath`, and `shouldUseShellForCommand()` tells us whether it needs cmd.exe. */ +import path from "path"; import { getCliRuntimeStatus, getKnownToolPaths, @@ -76,7 +77,16 @@ export async function resolveQoderCliInvocation( /* fall back to the bare/explicit command — spawn will surface a real ENOENT */ } - const invocation: QoderCliInvocation = { command, useShell: shouldUseShell(command) }; + // On Windows, if explicit CLI_QODER_BIN / command is a bare binary name like "qodercli" or "qoder" (no path or extension), + // spawn(cmd, { shell: false }) will fail with ENOENT post Node CVE-2024-27980. + // Enabling shell: true for bare command names on win32 lets system PATH resolution find qodercli.cmd / qoder.cmd. + const useShell = + shouldUseShell(command) || + (process.platform === "win32" && + !path.isAbsolute(command) && + !path.basename(command).includes(".")); + + const invocation: QoderCliInvocation = { command, useShell }; if (cacheable) { qoderInvocationCache = { ...invocation, diff --git a/tests/unit/qodercli-windows-resolve-6263.test.ts b/tests/unit/qodercli-windows-resolve-6263.test.ts index e9826fc301..b586666a21 100644 --- a/tests/unit/qodercli-windows-resolve-6263.test.ts +++ b/tests/unit/qodercli-windows-resolve-6263.test.ts @@ -53,11 +53,25 @@ test("cliRuntime enumerates qodercli.cmd under %APPDATA%\\npm on Windows", () => ); }); -test("shouldUseShellForCommand: true for a .cmd wrapper, false for the bare name (Windows)", () => { +test("resolveQoderCliInvocation requests shell for explicit bare command CLI_QODER_BIN on Windows (#8590)", async () => { setPlatform("win32"); - const cmdPath = path.join(os.homedir(), "AppData", "Roaming", "npm", "qodercli.cmd"); - assert.equal(cliRuntime.shouldUseShellForCommand(cmdPath), true); - assert.equal(cliRuntime.shouldUseShellForCommand("qodercli"), false); + process.env.CLI_QODER_BIN = "custom-qoder"; + + const invocation = await qoderResolve.resolveQoderCliInvocation("custom-qoder", { + getStatus: async () => ({ + installed: false, + runnable: false, + command: "qodercli", + commandPath: null, + reason: "not_found", + runtimeMode: "auto", + requiresBinary: true, + }), + }); + + assert.equal(invocation.command, "custom-qoder"); + assert.equal(invocation.useShell, true); + delete process.env.CLI_QODER_BIN; }); test("resolveQoderCliInvocation picks the absolute .cmd path + shell when cliRuntime finds it", async () => { @@ -83,7 +97,7 @@ test("resolveQoderCliInvocation picks the absolute .cmd path + shell when cliRun assert.notEqual(invocation.command, "qodercli"); }); -test("resolveQoderCliInvocation falls back to the bare command when cliRuntime finds nothing", async () => { +test("resolveQoderCliInvocation falls back to bare command + requests shell for bare name on Windows (#8590)", async () => { setPlatform("win32"); delete process.env.CLI_QODER_BIN; @@ -100,8 +114,8 @@ test("resolveQoderCliInvocation falls back to the bare command when cliRuntime f }); assert.equal(invocation.command, "qodercli"); - // A bare name is not a .cmd/.bat, so no shell is requested. - assert.equal(invocation.useShell, false); + // Bare name on Windows requires shell: true so cmd.exe can resolve qodercli.cmd / qodercli.bat / qodercli.exe on PATH (#8590) + assert.equal(invocation.useShell, true); }); test("resolveQoderCliInvocation is inert on non-Windows (no shell, resolved path honored)", async () => {