May 07, 2026 • Version: 2026.5.4

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 ENOENT

Affected 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.123

Direct 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:

  1. It accepts only an absolute path to an executable, or a filename with an explicit extension.
  2. It does not consult the PATHEXT environment variable (e.g., .COM;.EXE;.BAT;.CMD).
  3. It does not enumerate directories in PATH searching 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 invocation

The actual binary:

%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe  # 253 MB PE32+ executable

This 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):

  1. Node.js calls Windows' `CreateProcessW(L"claude", ...)`.
  2. Windows looks for `claude.exe` (no extension provided, defaulting to .exe).
  3. Windows enumerates directories in PATH looking for `claude.exe`.
  4. `%APPDATA%\npm\` is in PATH, but contains only `claude.cmd`, not `claude.exe`.
  5. 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

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 --save

2. 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 / .ps1 shim 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.exe

3. 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-cli

4. 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 complete

No 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, ProcessId

You 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 ENOENT reappears 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 with npm config get prefix.
  • Yarn or pnpm global installs: Yarn and pnpm may place shims in different locations (e.g., Yarn's yarn global bin). The cross-spawn fix 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 PATH resolution 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 claude in cmd or Get-Command claude in PowerShell.
  • Enterprise-controlled PATH: Some Windows enterprise deployments reset PATH on login or lock the environment. The workaround (copying `claude.exe`) is resilient to PATH changes but requires write access to the destination directory.
  • 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 denied on 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.
  • 128 exit code from spawned process — In some Node.js versions on Windows, a failed spawn due to ENOENT returns exit code 128 rather 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 run claude --version fails. The fix applies to all spawn calls, not just the main streaming spawn.
  • Historical: npx fails with ENOENT on Windows (Node < 8) — Pre-Node 8 versions had the same issue. npm and npx solved it by bundling cross-spawn. OpenClaw's cli-backend should follow the same pattern.

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.