Skip to content

fix(windows): run dev-install's external commands without a shell - #393

Merged
avifenesh merged 1 commit into
mainfrom
fix/dev-install-argv
Aug 16, 2026
Merged

fix(windows): run dev-install's external commands without a shell#393
avifenesh merged 1 commit into
mainfrom
fix/dev-install-argv

Conversation

@avifenesh

Copy link
Copy Markdown
Collaborator

Why

scripts/dev-install.js ran all four of its external commands through execSync, i.e. through a shell. On Windows that hid two defects and created a third:

  1. claude and npm are .cmd shims there. execSync only worked because it goes through cmd.exe implicitly - the same call written as an argv spawn fails twice over, once because execFileSync applies no PATHEXT to a bare npm, and once because Node has refused direct .cmd spawns since the CVE-2024-27980 fix. This is the same defect class fixed at five other call sites in fix(windows): route .cmd shims through cmd.exe in dev and perf runners #391; this script was the last shell-string holdout.
  2. commandExists ran where ${cmd} / which ${cmd} as a shell string.
  3. The plugin uninstall line interpolated a discovered plugin name into a shell string: claude plugin uninstall ${plugin}@${suffix}.

On (3): nothing can reach it today - plugin is checked against /^[a-z0-9][a-z0-9-]*$/ immediately before the loop, so a directory named a & calc is skipped rather than run. But the guard and the interpolation live in different places, and the guard is the only thing making that line safe. As an argv element it is safe by construction.

What

One runCommand(command, args, options) helper: resolveExecutableForPlatform supplies the Windows extension, planShimSpawn builds the cmd.exe /d /s /c hop when the target is a shim, shimSpawnOptions adds windowsVerbatimArguments. All four sites go through it. No behaviour change on Linux or macOS beyond losing the shell.

Test plan

__tests__/dev-install.test.js gains cases for:

  • the win32 plan for claude (cmd.exe + quoted payload + verbatim flag) and the direct spawn off Windows
  • npm.cmd resolution with the cwd preserved
  • a plugin name holding & staying one argv element
  • commandExists using where.exe on Windows, which elsewhere, and reporting false when the lookup throws
  • a source assertion that no execSync( remains

npm test: 88 suites, 3574 passing. npm run validate: clean.

Windows CI coverage arrives with #392; this branch will be rebased onto it before merge.

Copilot AI lite review requested due to automatic review settings August 16, 2026 14:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@revuto-review revuto-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an auto review done by revuto.


One finding on the Windows path — the claude executable is now resolved to a single hardcoded suffix, which is the defect #390 removed from bin/cli.js. Everything else (argv conversion, where.exe, planShimSpawn reuse, spying on execFileSync before dev-install destructures it) looks correct.

Comment thread scripts/dev-install.js
* second command.
*/
function runCommand(command, args, options = {}) {
const plan = planShimSpawn(resolveExecutableForPlatform(command), args);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an auto review done by revuto.


runCommand('claude', ...) resolves the executable purely through resolveExecutableForPlatform, which maps claudeclaude.cmd on win32 (lib/utils/command-parser.js:9,45-47). That is exactly the resolution #390 removed from the installer: per CHANGELOG.md:18 and bin/cli.js:77-98, claude.cmd does not exist for native-installer users (they have claude.exe), and __tests__/cli-args.test.js:364-366 asserts the CLI never hardcodes one Windows suffix.

Concretely on Windows with a native Claude install: commandExists('claude') succeeds (it asks where.exe, which finds claude.exe), then runCommand('claude', ...) spawns cmd.exe /d /s /c ""claude.cmd" ...", which fails; both call sites (scripts/dev-install.js:293 and :304) swallow the error in an empty catch, so the marketplace removal and the plugin uninstalls silently no-op and a marketplace copy can stay installed alongside the dev copy. The old execSync('claude plugin marketplace remove ...') worked here because cmd.exe applied PATHEXT and found claude.exe, so this is a behaviour regression on that host, not just a lost shell.

bin/cli.js already exports pickClaudeExecutable / claudeExecutable for this; routing claude through the same where.exe-based pick (or, minimally, falling back to a direct spawn when the shim is absent) would keep the two mechanisms from drifting apart, which was the stated goal of #391.

dev-install shelled out through execSync for all four of its external
commands. On Windows that hid two problems and created a third.

`claude` and `npm` are .cmd shims there. execSync worked only because it
routes through cmd.exe implicitly; the moment the command is anything but a
shell string it needs the explicit hop planShimSpawn builds, since Node has
refused direct .cmd spawns since the CVE-2024-27980 fix and execFileSync
applies no PATHEXT to a bare name.

The plugin uninstall line also interpolated a discovered plugin name into a
shell string. The name is checked against /^[a-z0-9][a-z0-9-]*$/ first, so
nothing could reach it today, but the check and the interpolation sat in
different functions - the guard is the only thing that made the line safe.
As an argv element it is safe by construction.

Commands are now argv lists through one runCommand helper, so every call
site gets shim resolution and the cmd.exe hop. Tests cover the win32 and
non-win32 plans, the cwd passthrough, and that a plugin name holding shell
metacharacters stays a single argument.
Copilot AI review requested due to automatic review settings August 16, 2026 18:35
@avifenesh
avifenesh force-pushed the fix/dev-install-argv branch from 111b986 to e2a6c5a Compare August 16, 2026 18:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@avifenesh
avifenesh merged commit 39e44f5 into main Aug 16, 2026
10 checks passed
@avifenesh
avifenesh deleted the fix/dev-install-argv branch August 16, 2026 18:38
avifenesh added a commit that referenced this pull request Aug 16, 2026
…ixed suffix (#394)

* fix(windows): resolve dev-install's claude through where.exe, not a fixed suffix

dev-install's four external commands became argv spawns in #393, and the claude
ones resolved the executable with resolveExecutableForPlatform, which maps a bare
`claude` to `claude.cmd` on win32. That is the assumption #390 removed from
bin/cli.js: the npm global install does ship claude.cmd, but the native installer
ships claude.exe, and there commandExists('claude') passes while the spawn of
claude.cmd fails - with both call sites catching and discarding the error, so the
marketplace removal and every plugin uninstall silently did nothing.

The where.exe-based pick moves out of bin/cli.js into
lib/utils/claude-executable.js so both callers get the same answer and cannot
drift apart; cli.js imports it rather than keeping a copy. Its unit tests move to
__tests__/claude-executable.test.js with the module, and cli.js stops exporting
the two functions it no longer owns.

The empty catches also become a warning. Both removals are best-effort - nothing
to remove exits non-zero and that is normal - so the two cases are told apart by
what execFileSync reports: a numeric status means claude ran and refused, while a
spawn failure leaves status null and carries the errno code. Only the second is
worth a line, and only the first one of them.

Tests cover the native .exe spawning directly with no cmd.exe hop, the .cmd shim
taking the hop, the posix path, where.exe failing entirely, and the warning
appearing for a spawn failure but not for a non-zero exit.

* fix(windows): treat cmd.exe exit 9009 as a claude that never ran

The spawn-failure warning missed the host it was written for. On win32 a .cmd
claude is launched through cmd.exe, so execFileSync sees cmd.exe start
successfully and a shim it could not launch arrives as an exit code, not as a
null status - which the discriminator read as "claude ran and refused" and stayed
silent, exactly the silent no-op the warning exists to break.

cmd.exe reports a command it cannot find or launch as 9009, so that code counts
as never-ran, but only on the shim route: 9009 from a directly spawned claude.exe
is the executable's own exit code and stays a normal best-effort failure. The
reason string now carries the exit code when there is no errno to report.

Tests cover a 9009 through the shim warning, a 1 through the shim staying quiet,
and a 9009 from a .exe staying quiet.
@avifenesh avifenesh mentioned this pull request Aug 17, 2026
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.

2 participants