diff --git a/bin/cli/commands/setup-open-code.mjs b/bin/cli/commands/setup-open-code.mjs index fc79d3aab6..dd20ba28a6 100644 --- a/bin/cli/commands/setup-open-code.mjs +++ b/bin/cli/commands/setup-open-code.mjs @@ -218,13 +218,29 @@ function registerPluginInOpenCodeConfig({ * a clear "could not run opencode" message instead of a hard import * failure. */ -function runOpenCodeAuth(providerId) { - const isWin = process.platform === "win32"; - const opencodeBin = isWin ? "opencode.cmd" : "opencode"; - const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], { - stdio: "inherit", - shell: false, - }); +/** + * Pure resolver for the `opencode auth login` spawn descriptor. Extracted so the + * platform-branching logic is unit-testable without mocking child_process or + * mutating process.platform. + * + * On Windows the `opencode` binary is an npm `.cmd` shim that Node's hardened + * spawnSync (post CVE-2024-27980) refuses to run without a shell — spawning it + * with shell:false throws EINVAL (#7913). Mirror the same fix already applied to + * codex (resolveCodexSpawn in launch-codex.mjs, crediting #6263) and + * qodercli/Auggie (#6263/#6304): shell:true on win32, shell:false everywhere else. + */ +export function resolveOpenCodeAuthSpawn(providerId, platform = process.platform) { + const isWin = platform === "win32"; + return { + command: isWin ? "opencode.cmd" : "opencode", + args: ["auth", "login", "--provider", providerId], + options: { stdio: "inherit", shell: isWin }, + }; +} + +export function runOpenCodeAuth(providerId) { + const { command, args, options } = resolveOpenCodeAuthSpawn(providerId); + const res = spawnSync(command, args, options); if (res.error) { // ENOENT = opencode is not on PATH if (res.error.code === "ENOENT") { diff --git a/changelog.d/fixes/7913-opencode-spawnsync-einval-win32.md b/changelog.d/fixes/7913-opencode-spawnsync-einval-win32.md new file mode 100644 index 0000000000..20d0c34b0b --- /dev/null +++ b/changelog.d/fixes/7913-opencode-spawnsync-einval-win32.md @@ -0,0 +1 @@ +- fix(cli): spawn the win32 `opencode.cmd` shim with `shell:true` in `omniroute setup opencode --auth` to avoid the Node `spawnSync EINVAL` hardening error (#7913) diff --git a/tests/unit/setup-open-code-win32-shell.test.mjs b/tests/unit/setup-open-code-win32-shell.test.mjs new file mode 100644 index 0000000000..334fd2b14e --- /dev/null +++ b/tests/unit/setup-open-code-win32-shell.test.mjs @@ -0,0 +1,42 @@ +// Regression test for #7913: `omniroute setup opencode --auth` spawns the +// `opencode.cmd` shim on win32. Since Node's CVE-2024-27980 hardening, +// spawning a `.cmd`/`.bat` shim with `shell:false` throws EINVAL — the same +// class already fixed for codex (bin/cli/commands/launch-codex.mjs, +// crediting #6263) and qodercli/Auggie (#6263/#6304). This callsite was +// missed; `resolveOpenCodeAuthSpawn` must use `shell: isWin`. +// +// Tested through the pure `resolveOpenCodeAuthSpawn(providerId, platform)` +// resolver (no child_process mocking, no process.platform mutation — both of +// which required an unavailable --experimental-test-module-mocks flag in CI). +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { resolveOpenCodeAuthSpawn } from "../../bin/cli/commands/setup-open-code.mjs"; + +test("resolveOpenCodeAuthSpawn: win32 spawns opencode.cmd with shell:true (repro #7913)", () => { + const spawn = resolveOpenCodeAuthSpawn("omniroute", "win32"); + assert.equal(spawn.command, "opencode.cmd"); + assert.equal( + spawn.options.shell, + true, + `expected shell:true on win32 (the EINVAL fix), got shell:${spawn.options.shell}` + ); + assert.deepEqual(spawn.args, ["auth", "login", "--provider", "omniroute"]); +}); + +test("resolveOpenCodeAuthSpawn: linux/darwin spawn bare opencode with shell:false (no regression)", () => { + for (const platform of ["linux", "darwin"]) { + const spawn = resolveOpenCodeAuthSpawn("omniroute", platform); + assert.equal(spawn.command, "opencode", `command on ${platform}`); + assert.equal( + spawn.options.shell, + false, + `expected shell:false on ${platform}, got shell:${spawn.options.shell}` + ); + } +}); + +test("resolveOpenCodeAuthSpawn: forwards the provider id into the args", () => { + const spawn = resolveOpenCodeAuthSpawn("anthropic", "linux"); + assert.deepEqual(spawn.args, ["auth", "login", "--provider", "anthropic"]); +});