diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index 64bf1600..83c3b128 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { accessSync, constants, existsSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -47,6 +48,24 @@ export function resolveClaudeModel(cascadeModel: string): string { ); } +/** + * Resolve the absolute path to the `claude` CLI for `pathToClaudeCodeExecutable`. + * Skips the SDK's platform-subpackage probe, which is broken on glibc Linux + * because it tries the `-musl` variant first and errors on ENOENT. + */ +export function resolveClaudeCodeExecutablePath(): string { + const fromEnv = process.env.CLAUDE_CODE_EXECUTABLE_PATH?.trim(); + if (fromEnv) return fromEnv; + try { + return execFileSync('which', ['claude'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return '/usr/local/bin/claude'; + } +} + /** * Ensure $HOME/.claude.json exists with the onboarding flag. * Claude Code CLI requires this file to skip interactive onboarding @@ -295,6 +314,7 @@ export class ClaudeCodeEngine extends NativeToolEngine { tools: sdkTools, allowedTools: sdkTools, persistSession: true, + pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(), hooks, env, debug: true, diff --git a/tests/unit/backends/claude-code.test.ts b/tests/unit/backends/claude-code.test.ts index 2b117790..703c833f 100644 --- a/tests/unit/backends/claude-code.test.ts +++ b/tests/unit/backends/claude-code.test.ts @@ -26,6 +26,7 @@ import { buildToolGuidance, ClaudeCodeEngine, ensureOnboardingFlag, + resolveClaudeCodeExecutablePath, resolveClaudeModel, } from '../../../src/backends/claude-code/index.js'; import { @@ -1434,6 +1435,93 @@ describe('ensureOnboardingFlag', () => { }); }); +describe('resolveClaudeCodeExecutablePath', () => { + const ENV_KEY = 'CLAUDE_CODE_EXECUTABLE_PATH'; + let original: string | undefined; + + beforeEach(() => { + original = process.env[ENV_KEY]; + unsetEnv(ENV_KEY); + }); + + afterEach(() => { + if (original === undefined) unsetEnv(ENV_KEY); + else process.env[ENV_KEY] = original; + }); + + it('honors CLAUDE_CODE_EXECUTABLE_PATH override', () => { + process.env[ENV_KEY] = '/opt/custom/claude'; + expect(resolveClaudeCodeExecutablePath()).toBe('/opt/custom/claude'); + }); + + it('trims whitespace from the env override', () => { + process.env[ENV_KEY] = ' /opt/custom/claude '; + expect(resolveClaudeCodeExecutablePath()).toBe('/opt/custom/claude'); + }); + + it('returns a non-empty string from `which claude` or the docker fallback', () => { + const resolved = resolveClaudeCodeExecutablePath(); + expect(typeof resolved).toBe('string'); + expect(resolved.length).toBeGreaterThan(0); + }); +}); + +describe('execute — pathToClaudeCodeExecutable', () => { + const ENV_KEY = 'CLAUDE_CODE_EXECUTABLE_PATH'; + let original: string | undefined; + + function mockStream(messages: Array<{ type: string; [key: string]: unknown }>) { + const iterator = messages[Symbol.iterator](); + mockQuery.mockReturnValue({ + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve(iterator.next()); + }, + }; + }, + } as ReturnType); + } + + beforeEach(() => { + original = process.env[ENV_KEY]; + mockQuery.mockReset(); + }); + + afterEach(() => { + if (original === undefined) unsetEnv(ENV_KEY); + else process.env[ENV_KEY] = original; + }); + + it('passes pathToClaudeCodeExecutable to query() so the SDK skips its native-binary probe', async () => { + mockStream([ + { type: 'result', subtype: 'success', result: 'Done', total_cost_usd: 0, num_turns: 1 }, + ]); + + await new ClaudeCodeEngine().execute(makeInput()); + + const opts = mockQuery.mock.calls[0]?.[0]?.options as + | { pathToClaudeCodeExecutable?: unknown } + | undefined; + expect(typeof opts?.pathToClaudeCodeExecutable).toBe('string'); + expect((opts?.pathToClaudeCodeExecutable as string).length).toBeGreaterThan(0); + }); + + it('forwards CLAUDE_CODE_EXECUTABLE_PATH override into query() options', async () => { + process.env[ENV_KEY] = '/opt/custom/claude'; + mockStream([ + { type: 'result', subtype: 'success', result: 'Done', total_cost_usd: 0, num_turns: 1 }, + ]); + + await new ClaudeCodeEngine().execute(makeInput()); + + const opts = mockQuery.mock.calls[0]?.[0]?.options as + | { pathToClaudeCodeExecutable?: unknown } + | undefined; + expect(opts?.pathToClaudeCodeExecutable).toBe('/opt/custom/claude'); + }); +}); + describe('buildEnv', () => { it('sets CLAUDE_AGENT_SDK_CLIENT_APP', () => { const { env } = buildEnv();