Skip to content

fix(desktop): fix Windows PATH clobber and .cmd shim EINVAL#2563

Open
wpfleger96 wants to merge 4 commits into
mainfrom
wpfleger96/windows-path-correctness
Open

fix(desktop): fix Windows PATH clobber and .cmd shim EINVAL#2563
wpfleger96 wants to merge 4 commits into
mainfrom
wpfleger96/windows-path-correctness

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 23, 2026

Copy link
Copy Markdown
Member

What

Three interacting defects caused Claude Code and Codex installs to fail on Windows with 'npm: command not found', installed adapters to appear missing, and agent turns to EINVAL-fail.

1. Install PATH clobber (install_shell_command)

install_shell_command() built PATH from managed-Node dirs + login_shell_path(), but both are always None on Windows: no managed Node runtime for Windows exists yet, and login_shell_path() returns None because Git Bash returns POSIX colon-delimited paths that poison native children. With an empty path_parts the PATH was unset entirely, so the Git Bash install shell couldn't find npm → install failed.

2. Probe/runtime PATH clobber (build_augmented_path)

Same omission in build_augmented_path(). Callers pass its result to Command::env("PATH", …) which replaces rather than extends the child's PATH. Without the inherited process PATH every readiness probe, version probe, and launched agent lost node/npm/git — installed adapters appeared missing and agent turns failed with 'node' is not recognized.

3. .cmd shim EINVAL (configure_runtime_cli)

configure_runtime_cli() resolved the Claude CLI and set CLAUDE_CODE_EXECUTABLE unconditionally. On Windows resolve_command returns claude.cmd (an npm shim); passing that path to CreateProcess causes EINVAL — batch scripts cannot be exec'd directly by Node.

Fix

is_batch_shim(path: &Path) -> bool — pure host-independent predicate in runtime/path.rs. Returns true for .cmd/.bat extensions (case-insensitive). Used internally by should_skip_claude_executable and tested directly via super::path::is_batch_shim in runtime/tests.rs.

should_skip_claude_executable(path: &Path, is_windows: bool) -> bool — pure policy predicate in runtime/path.rs, re-exported from runtime.rs. On Windows, skips .cmd/.bat paths for CLAUDE_CODE_EXECUTABLE so the adapter falls back to its own PATH lookup. On non-Windows, .cmd/.bat are valid executables and must be assigned — the explicit is_windows flag restores that behavior and makes the OS gate testable cross-host on macOS CI. Production call: should_skip_claude_executable(&cli_path, cfg!(windows)).

should_use_inherited(had_shell_path, has_local_context, is_windows) -> bool — pure policy predicate in runtime/path.rs, re-exported from runtime.rs. Both build_augmented_path and install_shell_command call it — the inherited-PATH policy lives in exactly one place and is testable cross-host.

compose_path_entries(managed, login, inherited, use_inherited) -> Vec<PathBuf> — pure PATH composition kernel (pub(crate)) in runtime/path.rs, called by both build_augmented_path and install_shell_command. Eliminates the drift hazard between the two paths. Inputs are already-split Vec<PathBuf>; split_paths/join_paths stay at wrapper boundaries.

Non-Windows impact

None. should_use_inherited returns false when is_windows=false; should_skip_claude_executable returns false when is_windows=false. Both use cfg!(windows) at their production call sites, which is a compile-time constant — the Windows branches are dead-code-eliminated on non-Windows. Unix output is byte-identical to before.

Tests

Pure, host-agnostic (run on all CI targets including macOS):

  • runtime/tests.rs: 6 is_batch_shim predicate tests via super::path::is_batch_shim
  • runtime/path.rs compose_tests: should_use_inherited truth table (4 cases), compose_path_entries ordering, empty/unset inherited with Windows policy ON, should_skip_claude_executable policy matrix (4 tests covering shim+Windows→skip, shim+non-Windows→assign, exe/no-ext both ways)

Platform-gated:

  • runtime/path.rs #[cfg(unix)]: unix_shell_path_output_unchanged_by_windows_fallback_logic
  • runtime/path.rs #[cfg(windows)]: process-PATH append, shell-PATH-present guard, no-context guard
  • agent_discovery.rs #[cfg(windows)]: test_install_shell_command_includes_process_path_on_windows

Fixes #2327, Fixes #2342, Fixes #2397
Supersedes #2247, #2420, #2506, #2533 (PATH family), #2495 (.cmd shim)

On Windows, three interacting defects caused Claude Code and Codex
installs to fail with 'npm: command not found', installed adapters to
appear missing, and agent turns to fail with EINVAL.

1. install_shell_command() built PATH from managed-Node dirs +
   login_shell_path(), but both are always None on Windows (no managed
   Node for Windows; login_shell_path() returns None because Git Bash
   returns POSIX paths that poison native children). With an empty
   path_parts the npm install ran with PATH unset: install failed.
   Fix: on Windows, append the inherited process PATH when no
   login-shell PATH is available, after Buzz-managed dirs.

2. build_augmented_path() had the same omission: callers pass its
   result to Command::env("PATH", …) which *replaces* the child PATH.
   Without the inherited process PATH, agents and probes lose node/npm/
   git entirely. Fix: same approach, gated on local context (home or
   exe_parent supplied) to prevent manufacturing a PATH from ambient
   state when no context is provided. Shared by both the install path
   and the runtime/probe/launch path — the single shared helper is the
   point of the fix so the two callers cannot drift again.

3. configure_runtime_cli() resolved the claude CLI and set
   CLAUDE_CODE_EXECUTABLE unconditionally. On Windows resolve_command
   returns claude.cmd (an npm shim); passing that path to CreateProcess
   causes EINVAL — batch scripts cannot be exec'd directly. Fix: on
   Windows, skip .cmd/.bat extensions so the adapter falls back to its
   own PATH lookup.

Non-Windows behaviour is provably unchanged: the Windows-only blocks
are #[cfg(windows)]-gated; the exe_added refactor (replacing the inline
exe_parent.is_some() after exe_parent was consumed) is a pure rename
with identical semantics. All existing Unix tests pass unmodified.

Fixes #2327, Fixes #2342, Fixes #2397
Supersedes #2247, #2420, #2506, #2533 (PATH), #2495 (.cmd shim)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner July 23, 2026 17:12
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits July 23, 2026 13:37
Addresses Thufir pass-1 blockers on PR #2563:

1. is_batch_shim — pure path predicate (no cfg(windows) guard, no PATH/cache
   involvement). Replaces three Windows-only integration tests that were
   poisoned by the resolve_command cache from the preceding
   claude_spawn_uses_the_probed_cli_executable test. Covered by 6 pure
   predicate tests that run on every host: .cmd, .CMD, .bat, .BAT (.exe and
   no-extension are accepted).

2. compose_path_entries — pure PATH composition kernel (pub(crate)) in
   runtime/path.rs, called by both build_augmented_path and
   install_shell_command. Eliminates the drift hazard between the two paths.
   Inputs are already-split Vec<PathBuf>; split_paths/join_paths stay at
   wrapper boundaries. use_inherited computed as
   !had_shell_path && has_local_context && cfg!(windows) so non-Windows output
   is byte-identical to before. Covered by 7 pure compose_tests (managed-first
   ordering, login-suppresses-inherited, inherited-appended-last, empty inputs,
   install/runtime parity, unix-parity) plus un-vacuoused Windows integration
   test that asserts PATH env var is set and sentinel appears last.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ited

Remove the duplicate is_batch_shim body from runtime.rs (was 2223 lines,
over the 2216 cap) and re-export it from path.rs alongside compose_path_entries
and should_use_inherited.

agent_discovery.rs now calls should_use_inherited(had_login, true, cfg!(windows))
instead of the open-coded !had_login && cfg!(windows) — the shared pure predicate
is the same expression at compile time, but it eliminates the last place the
Windows policy could drift from the runtime wrapper.

The cross-host policy matrix (should_use_inherited tests in compose_tests)
already covers all four cases; the wrapper_policy_parity test exercises all
of them explicitly. No new tests needed — the pure-function coverage is
already exhaustive and runs on every host.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… should_skip_claude_executable

is_batch_shim is a host-independent predicate; calling it unconditionally
made configure_runtime_cli silently skip .cmd/.bat on macOS/Linux, which
are valid executables there (not CreateProcess batch shims).

Wrap the production guard in should_skip_claude_executable(path, is_windows)
— the same pure-policy pattern used by should_use_inherited — so non-Windows
behavior is byte-identical to main and the OS gate is testable cross-host.

Four new cross-host tests cover all four OS × shim-type combinations:
shim+windows → skip, shim+non-windows → assign, exe/no-ext both → assign.

Also rename wrapper_policy_parity_install_and_runtime_agree to
should_use_inherited_policy_truth_table: the old name overclaimed structural
wrapper parity; the test is an exhaustive policy truth table, which is what
it says now.

runtime/tests.rs batch-shim tests updated to use super::path::is_batch_shim
directly (the pub(crate) re-export of is_batch_shim from runtime.rs was
removed since is_batch_shim is no longer called from production code — only
should_skip_claude_executable wraps it).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant