Windows spawn ENOENT: Node.js child_process.spawn Fails to Resolve .cmd CLI Shims
On Windows, the cli-backend's bare `spawn('claude')` call fails with ENOENT because Node.js does not honor PATHEXT or .cmd shims without shell:true. The fix is to use cross-spawn or pass shell:true on win32.
🔍 Symptoms
Error Manifestation
When agents.defaults.agentRuntime.id is set to "claude-cli" on Windows, all requests routed through the cli-backend fail silently. The gateway terminal emits:
[agent/cli-backend] cli exec: provider=claude-cli model=sonnet promptChars=188 trigger=user useResume=false session=none resumeSession=none reuse=none historyPrompt=none
[process/supervisor] spawn failed: runId=9f0408f7-... reason=Error: spawn claude ENOENT
Embedded agent failed before reply: spawn claude ENOENTAffected Conditions
The error reproduces under all of the following conditions simultaneously:
- Platform: Windows (any version; confirmed on Windows 11, Node 22.22.2)
- Install method: npm global install (`npm i -g @anthropic-ai/claude-code`)
- Runtime selection: `agents.defaults.agentRuntime.id = "claude-cli"` in
~/.openclaw/openclaw.json - Model routing: Any `anthropic/*` model routed through the cli-backend
Verification of Installed CLI
The claude.cmd shim is correctly present and functional when invoked from a shell:
C:\> where claude
C:\Users\<username>\AppData\Roaming\npm\claude.cmd
C:\> claude --version
claude 2.1.123Direct invocation of claude.cmd from cmd.exe or PowerShell succeeds. The failure is specific to the Node.js spawn call within the cli-backend.
Downstream User Impact
The chat UI shows no assistant response. No error is surfaced to the end user. The failure is opaque—logs must be consulted to identify the ENOENT.
🧠 Root Cause
The PATHEXT Resolution Gap
Node.js child_process.spawn(command, args) delegates to the platform’s native process creation API. On Windows, this is CreateProcess via libuv. CreateProcess has strict requirements:
- It accepts only an absolute path to an executable, or a filename with an explicit extension.
- It does not consult the
PATHEXTenvironment variable (e.g.,.COM;.EXE;.BAT;.CMD). - It does not enumerate directories in
PATHsearching for a match.
The npm shim Architecture
When npm installs @anthropic-ai/claude-code globally, it creates shim files in %APPDATA%\npm\ (the global bin directory):
%APPDATA%\npm\claude # POSIX shell script (#!/bin/sh) — Windows cannot execute
%APPDATA%\npm\claude.cmd # cmd.exe batch shim — called by shells, not by CreateProcess
%APPDATA%\npm\claude.ps1 # PowerShell shim — requires explicit invocationThe actual binary:
%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe # 253 MB PE32+ executableThis claude.exe path is never placed on the system PATH.
Failure Sequence
The cli-backend source (e.g., dist/cli-backend-BlVdMXrZ.js) contains:
function buildAnthropicCliBackend() {
return {
id: CLAUDE_CLI_BACKEND_ID,
config: {
command: "claude", // ← bare string; no shell, no .cmd resolution
args: ["-p", "--output-format", "stream-json", /* ... */]
}
};
}When this reaches child_process.spawn("claude", args):
- Node.js calls Windows' `CreateProcessW(L"claude", ...)`.
- Windows looks for `claude.exe` (no extension provided, defaulting to .exe).
- Windows enumerates directories in
PATHlooking for `claude.exe`. - `%APPDATA%\npm\` is in PATH, but contains only `claude.cmd`, not `claude.exe`.
ERROR_FILE_NOT_FOUND(ENOENT) is returned.
Why It Worked Before
This regression likely occurred when the cli-backend was refactored to use the config object pattern with command: "claude" instead of an explicit shell invocation or a resolution step. Any prior implementation that used shell: true, cross-spawn, or absolute path resolution would not exhibit this behavior.
Code Location
The hardcoded command string appears in the generated/compiled output of dist/cli-backend-*.js. The source location in the monorepo is the buildAnthropicCliBackend() factory function.
🛠️ Step-by-Step Fix
Recommended Fix: Use cross-spawn
This is the cleanest solution and is already used by npm, pnpm, yarn, and most Node.js CLI tooling.
1. Add the dependency:
npm install cross-spawn --save2. Replace the spawn call in the cli-backend.
Locate the spawn call that uses command: "claude". Change from:
// Before (cli-backend-*.js or the source file that builds it)
const { spawn } = require('child_process');
// ... later, when executing the backend:
childProcess = spawn(config.command, config.args, spawnOptions);To:
// After
const crossSpawn = require('cross-spawn');
// ... later, when executing the backend:
childProcess = crossSpawn(config.command, config.args, spawnOptions);cross-spawn automatically handles:
.cmd/.bat/.ps1shim resolution on Windows.- Shebang interpretation on POSIX.
- PATH resolution across all platforms.
Alternative Fix A: Conditional shell:true
If adding a dependency is not feasible, pass shell: true on Windows only:
const spawnOptions = {
stdio: ['pipe', 'pipe', 'pipe'],
...(process.platform === 'win32' ? { shell: true } : {})
};
childProcess = spawn(config.command, config.args, spawnOptions);Trade-off: shell: true spawns cmd.exe on Windows, which introduces a shell layer. This can cause:
- Slightly different argument parsing behavior.
- Environment variable inheritance differences.
- Additional process in the tree.
Alternative Fix B: Resolve to absolute path on Windows
Resolve claude to its .cmd shim path before spawning:
const path = require('path');
function resolveCommandOnWindows(cmd) {
if (process.platform !== 'win32') return cmd;
// Check for .cmd in the same directory as where npm places global bins
const npmPrefix = process.env.APPDATA + '\\npm';
const cmdPath = path.join(npmPrefix, cmd + '.cmd');
try {
require('fs').accessSync(cmdPath);
return cmdPath; // Return absolute path to the .cmd shim
} catch {
return cmd; // Fallback: let spawn attempt resolution
}
}
// Usage:
const resolvedCommand = resolveCommandOnWindows(config.command);
childProcess = spawn(resolvedCommand, config.args, spawnOptions);Workaround: Copy claude.exe to a PATH directory
For immediate relief without code changes, copy the embedded claude.exe to a directory already on PATH:
Copy-Item "$env:APPDATA\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe" "$env:APPDATA\npm\claude.exe"This is a one-time operation per machine. No gateway restart is required—PATH resolution happens at spawn time.
🧪 Verification
Verification Steps
1. Apply the fix (or apply the workaround).
2. Restart the gateway to load the updated cli-backend code:
# Stop the gateway
# On Windows PowerShell:
Stop-Process -Name "openclaw-gateway" -Force -ErrorAction SilentlyContinue
# Or kill by process ID
taskkill /F /IM node.exe3. Start the gateway and send a test prompt:
openclaw start
# In a separate terminal, send a test request through the control UI
# or via the CLI:
openclaw prompt "Hello, respond with a single word." --model anthropic/claude-sonnet-4-6 --runtime claude-cli4. Confirm success in gateway logs:
[agent/cli-backend] cli exec: provider=claude-cli model=sonnet promptChars=36 trigger=user ...
[agent/cli-backend] stream started
[agent/cli-backend] stream completeNo spawn failed: ENOENT error should appear.
5. Verify the spawned process on Windows (optional diagnostic):
# In PowerShell, find the gateway process and inspect its children:
$gatewayPid = (Get-Process -Name "node" | Select-Object -First 1).Id
Get-CimInstance Win32_Process -Filter "ParentProcessId=$gatewayPid" | Select-Object Name, ProcessIdYou should see claude.exe (or cmd.exe if using shell: true) as a child process during streaming.
6. Expected exit codes:
- Success: Gateway exits
0, stream completes. - Still broken:
Error: spawn claude ENOENTreappears in logs.
⚠️ Common Pitfalls
⚠️ Common Pitfalls
- Installing claude globally without admin rights:
On Windows, `npm i -g` installs to `%APPDATA%\npm\` (per-user). This is correct. However, some corporate environments redirect
%APPDATA%to network shares, which can cause permission or latency issues with spawn. Verify withnpm config get prefix. - Yarn or pnpm global installs:
Yarn and pnpm may place shims in different locations (e.g., Yarn's
yarn global bin). Thecross-spawnfix handles this automatically, but the workaround requiring manual copy must use the correct package manager's bin directory. - 32-bit Node on 64-bit Windows:
Node.js 32-bit builds can have different
PATHresolution behavior. Use the 64-bit Node distribution for compatibility. - Anti-virus interference: Windows Defender or corporate AV can intercept the spawn of unsigned executables. The `claude.exe` (253 MB) may be flagged initially. Whitelist the binary or the `%APPDATA%\npm\node_modules\` directory.
- Shell:true and argument quoting: When `shell: true` is used, the shell interprets metacharacters. Arguments containing spaces, quotes, or special characters may be re-parsed. Use an array with `shell: false` (via cross-spawn) to avoid quoting issues.
- PATH order precedence:
If another `claude.exe` exists in an earlier PATH directory (e.g., from a project-local install), the wrong binary may be invoked. Verify with
where claudein cmd orGet-Command claudein PowerShell. - Enterprise-controlled PATH:
Some Windows enterprise deployments reset
PATHon login or lock the environment. The workaround (copying `claude.exe`) is resilient to PATH changes but requires write access to the destination directory.
🔗 Related Errors
ENOENT: spawn XXXX(general) — Generic Node.js spawn failure. On Windows, the root cause is almost always the .cmd/.bat shim resolution issue described here. On POSIX, typically means the binary is not on PATH or lacks execute permissions (chmod +x).EACCES: permission deniedon Windows spawn — The spawned executable lacks the Execute permission bit or is blocked by AppLocker/Group Policy. Different from ENOENT; the file exists but cannot be launched.128exit code from spawned process — In some Node.js versions on Windows, a failed spawn due to ENOENT returns exit code128rather than propagating the system error. The workaround and fix are identical.Error: Command failed: claude --version— This error appears when the cli-backend attempts to verify the CLI version before spawning. It has the same root cause: the initial spawn to runclaude --versionfails. The fix applies to all spawn calls, not just the main streaming spawn.- Historical:
npxfails with ENOENT on Windows (Node < 8) — Pre-Node 8 versions had the same issue. npm and npx solved it by bundlingcross-spawn. OpenClaw's cli-backend should follow the same pattern.