Skip to content

Windows: resolve npm shims and shebang CLIs before spawning (no agent CLI is detected at all) - #41

Closed
E4B-labs wants to merge 1 commit into
milind-soni:mainfrom
E4B-labs:fix/windows-cli-resolver
Closed

Windows: resolve npm shims and shebang CLIs before spawning (no agent CLI is detected at all)#41
E4B-labs wants to merge 1 commit into
milind-soni:mainfrom
E4B-labs:fix/windows-cli-resolver

Conversation

@E4B-labs

@E4B-labs E4B-labs commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What's broken

On Windows, no agent CLI is ever detected. Every provider reports unavailable regardless of what is installed, so no bot can be selected and no turn can start — the app is unusable on the platform.

Measured on Windows 10 with Claude Code 2.1.229 and codex-cli 0.144.6 both installed and working in the terminal, against main (8511f02):

claude snapshot -> {"state":"unavailable","reason":"`claude` CLI not found"}
codex  snapshot -> {"state":"unavailable","reason":"`codex` CLI not found"}

Root cause

Three separate problems, all in the spawn path, all Windows-specific:

  1. libuv does not apply PATHEXT. spawn("claude") looks for a file named literally claude and walks straight past claude.cmd, which is what npm actually installs. (npm writes both: a claude sh script for Git Bash and a claude.cmd shim for Windows. Only the latter is runnable here.)
  2. A .cmd cannot be spawned directly any more. Since Node's fix for CVE-2024-27980, spawning a .cmd/.bat without shell: true throws synchronously.
  3. Windows has no #! support. A node-shebang script — every fake CLI under server/testing/ — cannot be executed as itself.

shell: true is not an option here. The drivers pass raw JSON in argv (claude's --mcp-config), and handing that to cmd mangles it.

The fix

resolveCliSpawn(cli, args) in server/env-path.ts works out how to actually spawn the thing, without a shell:

  • PATHEXT-aware which over augmentedPath(), preferring the PATHEXT hit over an extensionless sibling.
  • npm/pnpm .cmd shims are parsed down to what they actually run — the .exe, or node <cli.js> for the _prog shim shape (never the shim's own bundled node.exe).
  • #!...node scripts become node <script>.
  • Only an unparseable shim falls back to ComSpec, and that path owns its escaping: arguments are quoted for the CRT parser and then ^-escaped twice, because a .cmd's %* hands the line to a second round of cmd parsing. A raw-JSON --mcp-config payload survives byte for byte (there is a test pinning exactly that).
  • If nothing is found, the name is handed back untouched so spawn reports its own ENOENT as before.

Off win32 it is the identity function — POSIX resolves PATH and #! in the kernel, so nothing changes there.

Every driver spawn/execFile goes through it: claude (spawn, snapshot, generateText), codex (spawn, snapshot), acp/core (spawn, snapshot).

Tests

The spawn-based suites were skipped on win32 for exactly this reason, so they are unskipped here. Two supporting changes were needed to make them actually pass on Windows:

  • server/testing/setup.ts retries the temp-home cleanup briefly. Windows holds a directory that was a live process's cwd for a beat after the kill returns, and rmSync's own maxRetries does not cover an EPERM on the directory itself.
  • server/comms.test.ts passes SystemRoot through to the spawned server, without which winsock fails to initialize in the child.

server/drivers/claude.test.ts stays skipped on win32 in this PR — it additionally needs the permission-broker fix, and is unskipped in #42 (stacked on this one). The other Windows fixes in this series, #43 and #44, are independent of this PR and of each other.

How this was tested

  • pnpm typecheck and pnpm test on Windows 10: 77 passed / 15 skipped, up from 52 passed / 33 skipped on main. Nothing regressed; the delta is entirely previously-skipped tests now running.
  • 6 new unit tests for resolveCliSpawn covering both real npm shim shapes, the PATHEXT-vs-extensionless preference, the shebang rewrite, the ComSpec fallback with a raw-JSON payload round-tripped through cmd, and the not-found passthrough. The two shim fixtures are the exact bytes npm writes.
  • Smoke test on real Windows 10 against the installed CLIs, same machine and same snapshot call as the "before" above:
claude: resolveCliSpawn -> {"command":"...\\@anthropic-ai\\claude-code\\bin\\claude.exe","args":["--version"]}
        snapshot -> {"state":"available","version":"2.1.229 (Claude Code)"}
codex:  resolveCliSpawn -> {"command":"...\\node.EXE","args":["...\\@openai\\codex\\bin\\codex.js","--version"]}
        snapshot -> {"state":"available","version":"codex-cli 0.144.6"}

macOS behaviour is unchanged by construction — resolveCliSpawn returns its input unmodified off win32.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HgJeiantdRcZSBrc5sqqCp

On Windows the agent CLIs are never found, so every provider reports
"unavailable" and no turn can start.

Three separate reasons, all in the spawn path:

- libuv does not apply PATHEXT, so spawn("claude") looks for a file
  literally named `claude` and never finds `claude.cmd`.
- Since Node's CVE-2024-27980 fix, spawning a `.cmd` without
  `shell: true` throws synchronously.
- Windows has no `#!` support, so a node-shebang script (every fake CLI
  under server/testing) cannot be executed as itself.

`shell: true` is not an option: the drivers pass raw JSON in argv
(claude's `--mcp-config`), which cmd would mangle.

So resolve the target ourselves. `resolveCliSpawn(cli, args)` does a
PATHEXT-aware `which`, parses npm/pnpm `.cmd` shims down to the `.exe`
or node script they wrap, rewrites `#!node` scripts to `node <script>`,
and only falls back to ComSpec for an unparseable shim — with the
double `^`-escaping that fallback then owns, so a raw-JSON argument
survives byte for byte. Off win32 it is the identity function: POSIX
already resolves PATH and `#!` in the kernel.

Every driver spawn/execFile goes through it: claude (spawn, snapshot,
generateText), codex (spawn, snapshot), acp/core (spawn, snapshot).

The spawn-based suites were skipped on win32 for exactly this reason, so
they are unskipped here; server/testing/setup.ts gets a short retry
around the temp-home cleanup (Windows holds a directory that was a live
process's cwd for a beat after the kill returns) and comms.test.ts
passes SystemRoot through to the child, without which winsock fails to
initialize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgJeiantdRcZSBrc5sqqCp
@chidhu07

Copy link
Copy Markdown
Contributor

Independent run of this branch on a real Windows box — Windows 11, Node 22, pnpm 10.33. CI's windows leg already covers the suite, but since the repo is macOS-primary a from-the-machine report seemed worth having.

This PR: pnpm typecheck clean, pnpm test 77 passed / 15 skipped.

Same machine, for context: main 52 passed / 33 skipped · #42 89/3 · #43 53/33. All three branches typecheck clean and pass.

I also went looking for a hole in comSpecFallback and did not find one. I replicated escapeCmdArg/comSpecFallback verbatim and pushed claude's --mcp-config JSON and a & echo pwned & b through two unparseable shims — one ending in %*, one using %1. My guess was that the second ^ round would over-escape when there is no %* re-parse to consume it. It doesn't:

shim argument round-trips byte for byte
ends in %* --mcp-config JSON yes
uses %1 --mcp-config JSON yes
ends in %* a & echo pwned & b yes
uses %1 a & echo pwned & b yes

Nothing executed in any case. The double-escape is doing what the comment says it does.

One thing worth a maintainer's eye rather than a change here: CONTRIBUTING says "Never build command strings for a shell. No shell: true, no spawning through cmd.exe with quoted strings." This fallback is a narrow, deliberate, well-escaped exception to that, and it only fires for a .cmd whose %dp0% target isn't on disk — never for claude/codex/grok, whose npm shims parse fine. The alternative is to return the shim untouched and let spawn fail loudly, which the drivers already degrade to unavailable / a failed turn; that buys rule-purity at the cost of a rare-but-working CLI, and this version is more useful to users. Either way it'd be worth saying so explicitly in CONTRIBUTING, because the next person to read that line will stop on the same question.

@milind-soni milind-soni left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This remains relevant: current main's server/procs.ts still routes .cmd/.bat shims through cmd.exe, which conflicts with CONTRIBUTING's no-shell rule and leaves ACP/model arguments exposed to cmd metacharacter parsing. The shim-unwrapping and shebang resolution here are worth keeping, but the branch needs a rebase around current procs.ts and the newer Windows/Antigravity paths before it can merge.

@milind-soni

Copy link
Copy Markdown
Owner

The core Windows CLI-resolution work has been integrated into main via #81. I resolved the conflicts against the newer centralized process launcher, applied it to Antigravity as well, and kept unparseable shims out of cmd.exe to preserve the repository's no-shell security rule. I also added packaged-Electron safeguards and Windows cleanup coverage. The final integration passed macOS, Ubuntu, Windows (167 tests passed / 17 platform-appropriate skips), and the packaged Ubuntu smoke test. Thank you for the detailed Windows diagnosis and fixtures—they made the port substantially safer.

pull Bot referenced this pull request in dubbypanda/OpenMausBot Aug 14, 2026
Integrate the resolver through the centralized process launcher, preserve newer provider lifecycle handling, cover Antigravity, and keep unparseable shims out of cmd.exe.
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.

3 participants