JuliusBrussee/caveman · error · Error

failed to exec

Error message

failed to exec ${bin}: ${(error as Error).message}

What it means

This error wraps a synchronous exception from Node's spawn() when launching an external binary. On macOS some exec failures (notably ENOEXEC, a file that exists but is not executable) are reported synchronously instead of via the child's async 'error' event, so the launcher catches them and re-throws with the binary name and underlying OS message so failures name the tool instead of surfacing a bare spawn error.

Solutions

  1. Make the target binary executable: chmod +x $(which <bin>) and verify it runs standalone
  2. Inspect the OS message after the colon (e.g. EACCES vs ENOEXEC) and fix accordingly — permission vs format
  3. If it is a script without a shebang, add one (e.g. #!/usr/bin/env node) or reinstall the tool
  4. Reinstall or reinstall via its package manager to restore a valid launcher shim
  5. Check PATH for shadowing: an earlier directory may contain a stale binary of the same name

Example fix

// before (broken shim without shebang)
$ cat $(which claude)
console.log('cli')
// after
$ head -1 $(which claude)
#!/usr/bin/env node
Defensive patterns

Strategy: try-catch

Validate before calling

import { accessSync, constants } from 'fs';
function isExecutable(p) {
  try { accessSync(p, constants.F_OK | constants.X_OK); return true; } catch { return false; }
}
// verify isExecutable(resolvedBin) before invoking the CLI subcommand

Type guard

function isSpawnExecError(err: unknown): err is Error & { code?: string } {
  return err instanceof Error && /failed to exec /.test(err.message);
}

Try / catch

try {
  await cli.run(args);
} catch (err) {
  if (/^failed to exec /.test((err as Error).message)) {
    const bin = (err as Error).message.split(' ')[3];
    console.error(`Binary '${bin}' is missing or not executable; check PATH and +x permission.`);
    process.exitCode = 127;
  } else throw err;
}

Prevention

When it happens

Trigger: Running a CLI subcommand that resolves `bin` and calls spawn(invocation.command, invocation.args, {stdio:"inherit"}) where the resolved path is not executable (wrong interpreter/shebang, ENOEXEC), the file exists but lacks +x permission, or the platform reports the exec failure synchronously rather than through the child 'error' event.

Common situations: A PATH-resolved `claude`/`codex`/`pi` shim that is a text file without a shebang; a binary downloaded without the executable bit set; a broken wrapper script after a version upgrade; macOS-only synchronous spawn behavior that works on Linux.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/593ce0f3a6101ac1. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:5094

        opts.pixelModels,
        opts.pixelDensity,
        gw,
        subscription ? "codex-subscription" : "standard",
        false,
      );
    }
  } catch { /* runtime startup is fail-open; the native hook retries at SessionStart */ }
  if (native === "hermes") maybeWarnHermesMissingKey(agent, gatewayURL());
  const stopProxyKeepalive = startProxyKeepalive();
  const code = await new Promise<number>((resolve, reject) => {
    const invocation = portableInvocation(bin, [...agent.args, ...rest.slice(1)]);
    let child: ReturnType<typeof spawn>;
    try {
      child = spawn(invocation.command, invocation.args, { stdio: "inherit" });
    } catch (error) {
      // macOS reports some exec failures (e.g. ENOEXEC) synchronously — wrap
      // them like the async 'error' path so the message names the binary.
      throw new Error(`failed to exec ${bin}: ${(error as Error).message}`);
    }
    // tty-generated signals (Ctrl+C / Ctrl+\) already reach the child through
    // the shared foreground group — forwarding would double-deliver them. But
    // process-directed SIGTERM/SIGHUP (timeout(1), supervisors, pkill) only hit
    // this launcher, so those must be forwarded. Either way the launcher
    // re-raises on itself after the child exits so callers see a signal death,
    // not a clean exit.
    let fatal: NodeJS.Signals | undefined;
    for (const signal of ["SIGINT", "SIGQUIT"] as NodeJS.Signals[]) {
      process.on(signal, () => { fatal = signal; /* the tty delivered it to the child already */ });
    }
    for (const signal of ["SIGHUP", "SIGTERM"] as NodeJS.Signals[]) {
      process.on(signal, () => {
        fatal = signal;
        child.kill(signal);
        const grace = setTimeout(() => child.kill("SIGKILL"), 10_000);
        grace.unref();
      });

View on GitHub (pinned to 3ee70a1026)