Skip to content

[SpaProxy] Fall back to .exe when no .cmd shim is on PATH - #67422

Draft
bsallesp wants to merge 4 commits into
dotnet:mainfrom
bsallesp:fix/spaproxy-pnpm-standalone
Draft

bsallesp wants to merge 4 commits into
dotnet:mainfrom
bsallesp:fix/spaproxy-pnpm-standalone

Conversation

@bsallesp

Copy link
Copy Markdown

Fixes #45700.

Bug

SpaProxyLaunchManager appends .cmd to any bare launch command on Windows to handle the npm/yarn shim convention:

if (OperatingSystem.IsWindows() && !Path.HasExtension(command))
{
    command = $"{command}.cmd";
}

Tools installed via standalone script ship a .exe instead of a .cmd. The most common case in the wild is pnpm installed via the standalone script: it places pnpm.exe on PATH with no companion .cmd. The transform above turns pnpm dev into pnpm.cmd dev and the launcher fails:

fail: Microsoft.AspNetCore.SpaProxy.SpaProxyLaunchManager[0]
      Failed to launch the SPA development server.

Same shape applies to other modern Node toolchains that ship a single .exe (bun installed via PowerShell installer, etc.).

Fix

When the bare command does not already carry an extension on Windows, probe PATH for a .cmd shim first (preserves the existing behavior for npm/yarn). If no .cmd is on PATH but .exe is, use .exe. If neither resolves, fall back to .cmd so the existing diagnostic message is unchanged.

internal static string ResolveWindowsLaunchCommand(string command, Func<string, bool> existsOnPath)
{
    if (existsOnPath($"{command}.cmd")) return $"{command}.cmd";
    if (existsOnPath($"{command}.exe")) return $"{command}.exe";
    return $"{command}.cmd"; // preserve historical default for diagnostics
}

Why not blind .exe fallback or removing the transform

  • Removing the .cmd transform entirely (suggested by @morganbp in the thread): some npm setups on Windows depend on the .cmd shim being launched explicitly via ProcessStartInfo rather than letting the shell resolve PATHEXT. Probing PATH for .cmd first preserves that path.
  • Trying .exe blindly when .cmd exists: would change behavior for environments that have both (some scoop/winget npm packages do).

Probing strategy

ExistsOnPath walks the PATH environment variable and checks File.Exists for each directory. PATH parsing tolerates invalid entries (catches per-directory exceptions instead of failing the whole probe). The single iteration runs at SPA proxy launch time only, not on every request.

Tests

No test project exists for Microsoft.AspNetCore.SpaProxy today, so I have not added unit tests in this PR. The resolution helper is intentionally:

  • internal static (no instance state),
  • pure given an injected Func<string, bool> for PATH probing,

so a follow-up that introduces a Microsoft.AspNetCore.SpaProxy.Tests project can exercise it directly. Happy to do that as a follow-up if maintainers prefer the new project to ship in this PR.

Behavior matrix

Setup command Old New
npm via npm install -g (only .cmd exists) npm npm.cmd npm.cmd
yarn via npm install -g yarn yarn yarn.cmd yarn.cmd
pnpm via standalone script (only .exe) pnpm pnpm.cmd ❌ pnpm.exe ✅
<SpaProxyLaunchCommand>pnpm.cmd dev</SpaProxyLaunchCommand> (explicit) pnpm.cmd pnpm.cmd pnpm.cmd
Neither .cmd nor .exe on PATH whatever whatever.cmd whatever.cmd (same diagnostic)

Draft because I have not been able to run the aspnetcore build locally (preview SDK + Linux). Will mark Ready for Review once CI is green; happy to address review feedback while in draft.

bsallesp added 3 commits May 7, 2026 11:24
Fixes dotnet#45700.

SpaProxyLaunchManager appends '.cmd' to a bare launch command on Windows
to handle the npm/yarn shim convention. Tools installed via standalone
script (notably pnpm at https://pnpm.io/installation#using-a-standalone-script)
ship a '.exe' instead of a '.cmd', so the existing transform turns
'pnpm dev' into 'pnpm.cmd dev' and the launcher fails with
'Failed to launch the SPA development server'.

Behavior change: when the bare command resolves to a '.cmd' on PATH,
nothing changes (npm, yarn, anything currently working). When no '.cmd'
shim is found but a '.exe' is, use the '.exe'. If neither resolves,
fall back to '.cmd' so the existing diagnostic message is unchanged.

The resolution logic is factored into an 'internal static' helper with
an injectable 'Func<string, bool>' for PATH probing, so it can be
exercised by tests if a test project is introduced for SpaProxy (none
exists today).
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Jun 26, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Thanks for your PR, @bsallesp. Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

Comment thread FORK_SCOPE.md Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please remove this file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed in 449b7fd. Also cleaning it from the fork's main so future branches don't inherit it.

internal static string ResolveWindowsLaunchCommand(string command)
=> ResolveWindowsLaunchCommand(command, ExistsOnPath);

internal static string ResolveWindowsLaunchCommand(string command, Func<string, bool> existsOnPath)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is the way via delegate needed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right — it was speculative for a test project that doesn't exist yet. Collapsed to a single private static method that calls ExistsOnPath directly in 449b7fd.

- FORK_SCOPE.md was leftover fork documentation that should not be in
  an upstream contribution. Removed.
- Collapsed the two ResolveWindowsLaunchCommand overloads into a single
  private static method. The Func<string, bool> seam was speculative
  (no test project exists today); YAGNI.
@Youssef1313 Youssef1313 added the area-middleware Includes: URL rewrite, redirect, response cache/compression, session, and other general middlewares label Sep 2, 2026
@AllainPL

AllainPL commented Sep 19, 2026 •

Copy link
Copy Markdown

This covers a second case: nvm-windows v2's default shim mode puts only npm.exe on PATH, so plain npm hits it too, not just pnpm. Your probe order handles it - no npm.cmd on PATH there, npm.exe present.

Matrix row if useful: nvm-windows v2 shim mode | npm | npm.cmd ❌ | npm.exe ✅

Details and repro: nvm-windows/nvm#1405.

@EduardF1

Copy link
Copy Markdown
Contributor

I have been carrying #66924 for the same issue, taking the narrower route of resolving pnpm specifically through PNPM_HOME and the standalone installer's default directories. Your approach is the better one and I am closing mine in favour of it.

Probing PATH for .cmd then .exe fixes the case I originally hit (pnpm installed via the standalone script, pnpm.exe on PATH with no companion .cmd) without hardcoding a tool name or a list of install locations, and as @AllainPL notes above it also covers the nvm-windows v2 shim layout, which a PNPM_HOME lookup structurally cannot reach. One special case per tool was never going to scale.

Two things from my branch that may be useful to you, take or leave:

  • Test matrix. I have coverage for PATH precedence (a .cmd present must still win over a .exe), and for the bare-command case where neither resolves, asserting the diagnostic message is unchanged. That maps onto your existsOnPath seam directly, since it can be driven without touching the real PATH.
  • The one gap I considered and decided against. A tool installed but not on PATH at all, for example PNPM_HOME set while PATH has not been refreshed in a long-lived process, is not resolved by a PATH probe. I concluded it is not worth a special case: SpaProxy inherits the developer's environment, so a pnpm that no PATH lookup can find is one the developer could not run as pnpm dev in their own shell either, and resolving it anyway would paper over a broken setup. Recording it here so the decision is visible rather than lost.

Happy to send either as a PR against your branch if that is easier than lifting it.

@coreybutler

Copy link
Copy Markdown

Creator of NVM for Windows here.

As @AllainPL described, we put an npm.exe shim on the PATH (as well as pnpm, npx, and yarn). We explicitly do not put npm.cmd or npm.ps1. Here's why:

1. npm has no ABI contract

Currently, npm has npm.cmd and npm.ps1, but they do not document it and they do not promote the direct invocation of these files. They could drop those files at any time. Some older versions of npm ship with a different ABI, and while npm.cmd has been mostly consistent, there's precedence for unannounced change. The only thing you can reliably count on is the presence of an npm executable in the PATH. There is no guarantee about file type.

2. npm.cmd/npm.ps1 are exploitable

These files cannot be code-signed. They're easily replaceable, i.e. evil-agent.exe can blindly drop a compromised npm.cmd into node_modules.

nvm-windows ships a code-signed npm/npx/pnpm/yarn executable that directly invokes the underlying JS module from a DACL-protected directory. The nvm-windows node shim verifies the npm shim's digital signature, while the npm shim monitors JS file SHASUMs for unintentional changes. The result is an E2E guarantee that you're actually running the npm you intend to run, but it depends on entry via npm.exe.

You can see an example of why we use this approach in nvm-windows/nvm#1403 (comment)


In the coming months, there will be more announcements about security around Node.js. It's actually one of the main subjects for NodeConf EU in October 2026. npm continues to be an attack target, so it wouldn't surprise me to see the ABI remove some of these vulnerable files. My recommendation is to honor the guarantee that an npm executable will be on PATH.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-middleware Includes: URL rewrite, redirect, response cache/compression, session, and other general middlewares community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support pnpm that installed using a standalone script in SpaProxyLaunchCommand

6 participants