Skip to content

fix(windows): route npm/npx through node + cli.js to survive uv_spawn#5

Open
oldschoola wants to merge 1 commit into
ogrodev:mainfrom
oldschoola:fix/windows-npm-cmd-spawn
Open

fix(windows): route npm/npx through node + cli.js to survive uv_spawn#5
oldschoola wants to merge 1 commit into
ogrodev:mainfrom
oldschoola:fix/windows-npm-cmd-spawn

Conversation

@oldschoola
Copy link
Copy Markdown

Symptom

On Windows, /supi:plan (when the user picks "Terminal + Visual companion") fails with:

Extension "command:supi:plan" error: ENOENT: no such file or directory, uv_spawn 'npm'

/supi:doctor and /supi:update are affected by the same root cause but fail more quietly (silent "npm not found" in doctor, broken update flow on first install).

Root cause

platform.exec ultimately calls libuv's uv_spawn. On Windows that's a double bug for npm-shipped CLIs:

  1. libuv does not consult PATHEXT, so spawning "npm" fails because the on-disk file is npm.cmd.
  2. Even when callers resolve the absolute path, Node ≥18.20.2 hard-rejects spawning .cmd / .bat shims without shell: true (CVE-2024-27980). ExecOptions does not expose shell.

Wrapping in cmd.exe /d /s /c is the canonical workaround, but only safe when the spawner sets windowsVerbatimArguments: true — Node's default CRT escaping double-quotes the command line and cmd's /s only strips one pair, leaving ""command"" for cmd.exe to choke on. Tested locally: this does break for paths with spaces. We don't control the spawner, so we sidestep the whole problem.

Fix

Resolve npm and npx to the exact node <cli.js> ...args invocation their .cmd shims use internally. node.exe is a plain binary that libuv spawns without ceremony, and the CVE check doesn't trip. Repro:

$ node -e "require('child_process').spawnSync('npm',['-v'],{}).error"
{ code: 'ENOENT', errno: -4058, syscall: 'spawn npm' }

$ node -e "require('child_process').spawnSync('C:\\Program Files\\nodejs\\npm.cmd',['-v'],{}).error"
{ code: 'EINVAL', errno: ... }                # CVE-2024-27980

$ node -e "require('child_process').spawnSync('C:\\Program Files\\nodejs\\node.exe', \
  ['C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js','-v']).stdout.toString()"
11.6.2\n                                       # ✓

A small execCli helper (src/utils/exec-cli.ts) wraps each existing call site:

File Call
src/commands/plan.ts npm install --production (visual companion deps — the user-visible failure)
src/bootstrap.ts background npm view supipowers version
src/commands/doctor.ts npm --version, npm ping
src/commands/update.ts npm view, npm install --prefix, bun install, npm fallback, plus the deps-installer lambda via wrapExecForCli
src/harness/anti_slop/fallow-adapter.ts npx --no-install fallow ... and the resolved-invocation runner

Other binaries (bun, git, node, gh, rustup, go, pip, uv, …) ship as .exe on Windows and pass through untouched. POSIX always passes through.

Tests

  • tests/utils/exec-cli.test.ts — new. Covers the rewrite, pass-through, wrapExecForCli, fallback when npm-cli.js is missing, and POSIX behaviour. Uses a synthesised fake Node tree on a PATH override so the Windows branch is deterministic.
  • tests/commands/update.test.ts, tests/harness/anti_slop/fallow-adapter.test.ts — normalize captured platform.exec calls so existing mocks keep matching on the logical command (helper: tests/helpers/exec-calls.ts).
$ bun run typecheck
$ tsc --noEmit                           # clean

$ bun test tests/utils/exec-cli.test.ts \
           tests/commands/update.test.ts tests/commands/doctor.test.ts \
           tests/harness/anti_slop
 90 pass, 0 fail

Full bun test has 11 pre-existing failures on main on this Windows box, all in tests/context-mode/ / tests/release/ / tests/mempalace/ / tests/qa/ and all about WSL/Hyper-V not being installed, dev-server timing, or release dry-run output. None touch the spawn path.

Risk

  • Cache is keyed on process.platform === "win32" and the bare command name; a stale cache survives the process. There's a _resetExecCliCacheForTesting escape hatch.
  • The helper only rewrites npm and npx. Anything else falls through, so existing callers that spawn git, node, gh, bun, rustup, etc. are unchanged.
  • Falls back to the bare command name (no rewrite) when node or <command>-cli.js cannot be located, so POSIX-only setups and exotic installs degrade to today's behaviour.

Closes the symptom reported as Extension "command:supi:plan" error: ENOENT: no such file or directory, uv_spawn 'npm'.

platform.exec ultimately calls libuv's uv_spawn, which on Windows:

  1. does not consult PATHEXT, so spawning 'npm' fails with
     'ENOENT: uv_spawn npm' because the on-disk file is npm.cmd, and
  2. since Node >=18.20.2 (CVE-2024-27980) hard-rejects spawning .cmd
     and .bat shims without shell: true, which ExecOptions does not
     expose.

Wrapping in cmd.exe /d /s /c needs windowsVerbatimArguments to be
safe, which we also don't control. Instead, resolve npm and npx to
the same 'node <cli.js> ...args' invocation their .cmd shims use
internally. node.exe is a plain binary that libuv spawns cleanly.

Threaded through every existing call site:

  - src/bootstrap.ts                  background version check
  - src/commands/plan.ts              visual-companion install
                                      (the user-visible failure on
                                      /supi:plan)
  - src/commands/doctor.ts            npm presence + ping
  - src/commands/update.ts            npm view / install,
                                      bun + npm fallback,
                                      deps installer lambda
  - src/harness/anti_slop/fallow.ts   npx --no-install fallow

Other binaries (bun, git, node, gh, rustup, go, pip, uv) ship as
.exe and pass through untouched. POSIX always passes through.

Tests cover the rewrite, the pass-through, the wrapExecForCli
adapter, and the fallback when npm-cli.js is missing. Existing
update / fallow tests normalize captured calls so they stay stable
on both platforms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant