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
- Make the target binary executable: chmod +x $(which <bin>) and verify it runs standalone
- Inspect the OS message after the colon (e.g. EACCES vs ENOEXEC) and fix accordingly — permission vs format
- If it is a script without a shebang, add one (e.g. #!/usr/bin/env node) or reinstall the tool
- Reinstall or reinstall via its package manager to restore a valid launcher shim
- 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
- Verify target binaries exist and are executable (chmod +x) before launching
- Test harness binaries directly in the shell before routing them through the CLI
- Watch for macOS-only ENOEXEC failures when wrappers lack shebangs
- Pin tool versions and reinstall after upgrades to keep shims valid
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
- cannot safely launch non-Node Windows command shim
- cannot safely launch Windows command shim
- cave_transform_registry_unavailable: run caveman setup or…
- could not remove credentials from macOS Keychain
- ENOENT
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)