diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 155c2dfa..232b17b6 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -1,3 +1,5 @@ +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; import { join } from 'node:path'; import { query, type Query } from '@anthropic-ai/claude-agent-sdk'; import type { ChatEvent } from '../../api/chat/types'; @@ -7,9 +9,27 @@ import { createParseState, processMessage } from './stream-parser'; const SEND_TIMEOUT_MS = 30 * 60 * 1000; -// Use /usr/local/bin/claude so it's visible inside bwrap sandbox (which ro-binds /usr). -// The actual binary lives at ~/.local/bin/claude, symlinked from /usr/local/bin/claude. -const CLAUDE_BIN = '/usr/local/bin/claude'; +// Where the Claude Code CLI lives. This was hardcoded to /usr/local/bin/claude, which dated from the +// bwrap-sandboxed architecture: the jail ro-bound /usr and saw nothing else, so the installer's real +// target (~/.local/bin/claude) had to be symlinked into a path the sandbox could reach. That sandbox +// is gone, and the hardcoded path made the sidecar unrunnable anywhere it does not exist — a stock +// macOS host has no /usr/local/bin at all. +// +// Resolution order mirrors OPENCODE_BIN in the opencode sidecar: an explicit pin, then PATH, then the +// locations Anthropic's installer actually writes to. +function resolveClaudeBin(): string { + const pinned = process.env.CLAUDE_BIN; + if (pinned) return pinned; + + const onPath = Bun.which('claude'); + if (onPath) return onPath; + + const candidates = [join(homedir(), '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude']; + return candidates.find((candidate) => existsSync(candidate)) ?? 'claude'; +} + +const CLAUDE_BIN = resolveClaudeBin(); +console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`); // Capture original HOME before user-instance overrides it const HOST_HOME = process.env.HOME!;