diff --git a/runners/agent-runner/bun.lock b/runners/agent-runner/bun.lock index 56a71b6..b550b8c 100644 --- a/runners/agent-runner/bun.lock +++ b/runners/agent-runner/bun.lock @@ -5,7 +5,7 @@ "": { "name": "ejclaw-agent-runner", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.101", + "@anthropic-ai/claude-agent-sdk": "0.2.114", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "ejclaw-runners-shared": "file:../shared", @@ -18,7 +18,23 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.101", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-LwDQ6ueXnd1EVJbP0XICUO5en4FYJ8Cv/98/+RofoRr4l1cdAdYn1/n758DrTeGkMvTqb4lbdyMNs0RnT7mtoA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.114", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.114" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-plJ+j17jew9tDMHir/90hXrwoB8cZ9GrIyG19zIJcFyQ8pVhRXjZRJCtF2ElfPoiwkxMmNu1Klqyui4xP4shPg=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.114", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0/6LWrNilWpmiX6Xrj5plsBmCrCdKGERgAlKUZQEJZplnfuweFAJu7WXZB4KBaUpGlPO91zB/yqDh6kp5aZFbA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.114", "", { "os": "darwin", "cpu": "x64" }, "sha512-sOHxq1rEO/KZg2iEZILTPn62lMRRMPqtxKx41uGLi3xjVDrAej6Ury9dDZjYBKkK9n4kBylXV0Oom2CZ14dDYw=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-j/SfEoN6+fyEsp8EuPe+xKcGfsZtaBmdUUH+YSRk5H/lYgy38yNsDhdt+AJMQcdMKfHsiwZ3Y9Ajoe9G9wNwHQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mhd7bumTwWvkgjSJnYvCgyt8DfmLiUoK92mfvAKxHX7i5YSw+h5Kprqh2Cap+2SBbpwZvnwIoEYGCxhGwE5ddg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-wbaExKDleLlm2zHEhb74GKMLVhtO0IUmFhdimQcdL6CdTkmDE8ZJi53tYWE9+jq+XWNRXoM2yEmKPzXoUmsJng=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-c1URsameGHAcghen+mY6jvr2oypiAPHXJIdP4huxR25zPdXWv2x+BCy+vcRVeajsq4VmFzAyQJwaM+BXkmXjAw=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.114", "", { "os": "win32", "cpu": "arm64" }, "sha512-qeWdUpQymcKCA92osPmffG4QogrOSvuffPvm6c2OlMDjCPYs8vKG7bSe1Vq5tP9tfBszKPVJWBDh+2ANkNissQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.114", "", { "os": "win32", "cpu": "x64" }, "sha512-nVr43WwsKvWA6rojw15qBS/f31srukdLxy1KwKzpftlpmkzQ9Lh8uhIafOmoIPzz67f8VJ8JqHE0caA5YrhX9A=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], @@ -26,38 +42,6 @@ "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], diff --git a/runners/agent-runner/package.json b/runners/agent-runner/package.json index d3c49a7..1727ebd 100644 --- a/runners/agent-runner/package.json +++ b/runners/agent-runner/package.json @@ -10,7 +10,7 @@ "start": "bun dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.101", + "@anthropic-ai/claude-agent-sdk": "0.2.114", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "ejclaw-runners-shared": "file:../shared", diff --git a/runners/agent-runner/src/bundled-cli-path.ts b/runners/agent-runner/src/bundled-cli-path.ts new file mode 100644 index 0000000..ac9d58d --- /dev/null +++ b/runners/agent-runner/src/bundled-cli-path.ts @@ -0,0 +1,146 @@ +/** + * Resolves the path to the Claude Code native CLI binary bundled with + * `@anthropic-ai/claude-agent-sdk`. + * + * SDK 0.2.113+ ships a per-platform native binary via optional dependencies. + * Its internal auto-detection (W7() in sdk.mjs) tries the linux-x64-musl + * package BEFORE linux-x64, so on glibc hosts where bun has installed the musl + * package as an empty shell (no executable binary) the SDK throws: + * + * "Claude Code native binary not found at .../claude-agent-sdk-linux-x64-musl/claude" + * + * By resolving the bundled binary path explicitly we: + * - Skip the SDK's unreliable musl/glibc auto-detection. + * - Keep EJClaw self-contained (no dependency on a host `claude` install). + * + * Override: set `EJCLAW_CLAUDE_CLI_PATH` to a custom absolute binary path. + */ + +import fs from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; + +const ENV_OVERRIDE = 'EJCLAW_CLAUDE_CLI_PATH'; + +interface PlatformCandidate { + pkg: string; + file: string; +} + +function platformCandidates( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): PlatformCandidate[] { + if (platform === 'linux') { + // glibc-first: the SDK gets this wrong (musl-first) — we correct it here. + return [ + { pkg: `@anthropic-ai/claude-agent-sdk-linux-${arch}`, file: 'claude' }, + { + pkg: `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl`, + file: 'claude', + }, + ]; + } + if (platform === 'darwin') { + return [ + { + pkg: `@anthropic-ai/claude-agent-sdk-darwin-${arch}`, + file: arch === 'arm64' ? 'cli' : 'cli', + }, + ]; + } + if (platform === 'win32') { + return [ + { + pkg: `@anthropic-ai/claude-agent-sdk-win32-${arch}`, + file: 'cli.exe', + }, + ]; + } + return []; +} + +/** + * Returns an absolute path to the bundled Claude Code CLI binary. + * + * Resolution order: + * 1. `EJCLAW_CLAUDE_CLI_PATH` env var (if set and file exists). + * 2. Platform-appropriate bundled binary under + * `runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-*`. + * + * Throws if no binary can be found — callers must not fall back silently. + */ +export function resolveBundledClaudeCodeExecutable(options?: { + /** Override platform (tests only). */ + platform?: NodeJS.Platform; + /** Override arch (tests only). */ + arch?: string; + /** Override env source (tests only). */ + env?: NodeJS.ProcessEnv; + /** Override fs.existsSync (tests only). */ + existsSync?: (p: string) => boolean; + /** Override how candidate dirs are resolved (tests only). */ + resolvePackageDir?: (pkg: string) => string | null; +}): string { + const env = options?.env ?? process.env; + const existsSync = options?.existsSync ?? fs.existsSync; + const platform = options?.platform ?? process.platform; + const arch = options?.arch ?? process.arch; + + const override = env[ENV_OVERRIDE]; + if (override && override.trim().length > 0) { + const resolved = path.resolve(override); + if (!existsSync(resolved)) { + throw new Error( + `${ENV_OVERRIDE} is set to "${override}" but no file exists at that path.`, + ); + } + return resolved; + } + + const resolvePackageDir = + options?.resolvePackageDir ?? defaultResolvePackageDir; + + const candidates = platformCandidates(platform, arch); + const tried: string[] = []; + for (const { pkg, file } of candidates) { + const dir = resolvePackageDir(pkg); + if (!dir) { + tried.push(`${pkg} (package not resolvable)`); + continue; + } + const candidatePath = path.join(dir, file); + if (existsSync(candidatePath)) { + return candidatePath; + } + tried.push(candidatePath); + } + + throw new Error( + `Unable to locate a bundled Claude Code CLI binary for platform=${platform} arch=${arch}. ` + + `Tried: ${tried.join(', ') || '(no candidates for this platform)'}. ` + + `Set ${ENV_OVERRIDE} to an absolute path to the CLI binary if you have a custom install.`, + ); +} + +/** + * Default package directory resolver. Uses `require.resolve` against this + * module's location so it works regardless of whether agent-runner is invoked + * from its own node_modules layout or via a parent workspace. + */ +function defaultResolvePackageDir(pkg: string): string | null { + try { + // `require.resolve('/package.json')` returns the absolute path to the + // package's package.json — its dirname is the package root. + const req = createRequire(import.meta.url); + const pkgJson = req.resolve(`${pkg}/package.json`); + return path.dirname(pkgJson); + } catch { + return null; + } +} + +export const __test__ = { + platformCandidates, + ENV_OVERRIDE, +}; diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 5a7ad62..9665bba 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -24,6 +24,7 @@ import { } from 'ejclaw-runners-shared'; import { fileURLToPath } from 'url'; +import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js'; import { prependRoomRoleHeader, type RoomRoleContext, @@ -88,6 +89,17 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } +// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. +// (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로 +// glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.) +let cachedClaudeCliPath: string | null = null; +function getClaudeCliPath(): string { + if (cachedClaudeCliPath) return cachedClaudeCliPath; + cachedClaudeCliPath = resolveBundledClaudeCodeExecutable(); + log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`); + return cachedClaudeCliPath; +} + // Graceful shutdown: SIGTERM → abort SDK query, allowing cleanup const agentAbortController = new AbortController(); process.on('SIGTERM', () => { @@ -267,6 +279,8 @@ async function runQuery( for await (const message of query({ prompt: stream, options: { + // 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. + pathToClaudeCodeExecutable: getClaudeCliPath(), cwd: effectiveCwd, model, thinking, @@ -702,6 +716,8 @@ async function main(): Promise { for await (const message of query({ prompt: trimmedPrompt, options: { + // 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함. + pathToClaudeCodeExecutable: getClaudeCliPath(), cwd: mainEffectiveCwd, resume: sessionId, systemPrompt: undefined, diff --git a/runners/agent-runner/test/bundled-cli-path.test.ts b/runners/agent-runner/test/bundled-cli-path.test.ts new file mode 100644 index 0000000..2c5dd54 --- /dev/null +++ b/runners/agent-runner/test/bundled-cli-path.test.ts @@ -0,0 +1,136 @@ +import fs from 'fs'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveBundledClaudeCodeExecutable } from '../src/bundled-cli-path.js'; + +describe('resolveBundledClaudeCodeExecutable', () => { + const origEnv = process.env.EJCLAW_CLAUDE_CLI_PATH; + + afterEach(() => { + if (origEnv === undefined) { + delete process.env.EJCLAW_CLAUDE_CLI_PATH; + } else { + process.env.EJCLAW_CLAUDE_CLI_PATH = origEnv; + } + }); + + it('honors EJCLAW_CLAUDE_CLI_PATH when the file exists', () => { + const fake = '/tmp/fake-claude-binary-path'; + const existsSync = (p: string): boolean => p === fake; + const result = resolveBundledClaudeCodeExecutable({ + env: { EJCLAW_CLAUDE_CLI_PATH: fake }, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: () => null, + }); + expect(result).toBe(path.resolve(fake)); + }); + + it('throws when env override points at a non-existent file', () => { + const existsSync = () => false; + expect(() => + resolveBundledClaudeCodeExecutable({ + env: { EJCLAW_CLAUDE_CLI_PATH: '/does/not/exist' }, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: () => null, + }), + ).toThrow(/EJCLAW_CLAUDE_CLI_PATH/); + }); + + it('prefers Linux glibc binary over musl binary', () => { + const glibcDir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64'; + const muslDir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl'; + const existsSync = (p: string): boolean => { + // Simulate: both dirs resolve, but only glibc has an actual binary. + return p === path.join(glibcDir, 'claude'); + }; + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-linux-x64' + ? glibcDir + : pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' + ? muslDir + : null, + }); + expect(result).toBe(path.join(glibcDir, 'claude')); + }); + + it('falls back to musl binary if glibc package is not present', () => { + const muslDir = + '/fake/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl'; + const existsSync = (p: string): boolean => + p === path.join(muslDir, 'claude'); + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-linux-x64-musl' ? muslDir : null, + }); + expect(result).toBe(path.join(muslDir, 'claude')); + }); + + it('resolves Darwin arm64 binary under `cli`', () => { + const dir = '/fake/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64'; + const existsSync = (p: string): boolean => p === path.join(dir, 'cli'); + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'darwin', + arch: 'arm64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-darwin-arm64' ? dir : null, + }); + expect(result).toBe(path.join(dir, 'cli')); + }); + + it('resolves Windows binary under `cli.exe`', () => { + const dir = 'C:\\\\fake\\\\node_modules\\\\@anthropic-ai\\\\claude-agent-sdk-win32-x64'; + const binary = path.join(dir, 'cli.exe'); + const existsSync = (p: string): boolean => p === binary; + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'win32', + arch: 'x64', + resolvePackageDir: (pkg) => + pkg === '@anthropic-ai/claude-agent-sdk-win32-x64' ? dir : null, + }); + expect(result).toBe(binary); + }); + + it('throws a descriptive error when no binary is found', () => { + const existsSync = () => false; + expect(() => + resolveBundledClaudeCodeExecutable({ + env: {}, + existsSync, + platform: 'linux', + arch: 'x64', + resolvePackageDir: () => null, + }), + ).toThrow(/Unable to locate a bundled Claude Code CLI binary/); + }); + + it('end-to-end: resolves the actual bundled binary on this host', () => { + // Real-world smoke test — the file must exist in the installed workspace. + const result = resolveBundledClaudeCodeExecutable({ + env: {}, + }); + expect(fs.existsSync(result)).toBe(true); + // On Linux glibc we expect the non-musl binary to win. + if (process.platform === 'linux') { + expect(result).not.toMatch(/linux-x64-musl/); + } + }); +});