diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index c18ae2022..ef46ed2ee 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -41,11 +41,42 @@ import { preparePrompt } from "../../base-action/src/prepare-prompt"; import { runClaude } from "../../base-action/src/run-claude"; import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk"; +/** + * Resolve the path the Agent SDK should use for the Claude binary, given + * where install.sh is expected to drop it. Returns `undefined` if the binary + * isn't actually on disk so the SDK falls back to its bundled platform binary + * (the `@anthropic-ai/claude-agent-sdk-{platform}` optional dependency). + * + * This is the v1.0.99 behavior: if install.sh silently fails (e.g. claude.ai + * unreachable behind a corporate firewall but npm packages are mirrored), we + * shouldn't hand a non-existent path to the SDK and crash with + * `ReferenceError: Claude Code native binary not found`. See #1242. + * + * Exported for unit testing. + */ +export function resolveInstalledClaudeBinary( + homeBin: string, + fileExists: (p: string) => boolean = existsSync, +): string | undefined { + const path = `${homeBin}/claude`; + if (fileExists(path)) { + return path; + } + console.warn( + `Claude binary not found at ${path} after install.sh ran; falling back to the Agent SDK's bundled platform binary. ` + + `This usually means install.sh couldn't reach claude.ai (corporate firewall, air-gapped runner, etc.). ` + + `If the SDK's bundled binary is also unavailable, set PATH_TO_CLAUDE_CODE_EXECUTABLE to a working binary.`, + ); + return undefined; +} + /** * Install Claude Code CLI, handling retry logic and custom executable paths. - * Returns the absolute path to the claude executable. + * Returns the absolute path to the claude executable, or `undefined` if the + * native install didn't actually produce a binary (in which case the SDK + * falls back to its bundled platform binary). */ -async function installClaudeCode(): Promise { +async function installClaudeCode(): Promise { const customExecutable = process.env.PATH_TO_CLAUDE_CODE_EXECUTABLE; if (customExecutable) { if (/[\x00-\x1f\x7f]/.test(customExecutable)) { @@ -68,6 +99,7 @@ async function installClaudeCode(): Promise { const claudeCodeVersion = "2.1.119"; console.log(`Installing Claude Code v${claudeCodeVersion}...`); + const homeBin = `${process.env.HOME}/.local/bin`; for (let attempt = 1; attempt <= 3; attempt++) { console.log(`Installation attempt ${attempt}...`); try { @@ -86,26 +118,35 @@ async function installClaudeCode(): Promise { }); child.on("error", reject); }); - console.log("Claude Code installed successfully"); - // Add to PATH - const homeBin = `${process.env.HOME}/.local/bin`; + console.log("Claude Code install command finished"); + // Add to PATH (do this even if the binary is missing — homeBin may pick + // up other tools the SDK / plugins look for). const githubPath = process.env.GITHUB_PATH; if (githubPath) { await appendFile(githubPath, `${homeBin}\n`); } process.env.PATH = `${homeBin}:${process.env.PATH}`; - return `${homeBin}/claude`; + // The pipeline `curl ... | bash` can exit 0 even when curl fails (no + // pipefail), so verify the binary actually exists. If it doesn't, fall + // back to the SDK's bundled binary instead of handing it a bad path. + return resolveInstalledClaudeBinary(homeBin); } catch (error) { if (attempt === 3) { - throw new Error( - `Failed to install Claude Code after 3 attempts: ${error}`, + // Even if every spawn failed, the bundled SDK binary may still work. + // Log loudly and let the SDK try its fallback rather than aborting + // the whole action. + console.error( + `install.sh failed after 3 attempts: ${error}. ` + + `Falling back to the Agent SDK's bundled platform binary.`, ); + return resolveInstalledClaudeBinary(homeBin); } console.log("Installation failed, retrying..."); await new Promise((resolve) => setTimeout(resolve, 5000)); } } - throw new Error("unreachable"); + // Unreachable: the loop body always returns or throws on the last attempt. + return undefined; } /** diff --git a/test/resolve-installed-claude-binary.test.ts b/test/resolve-installed-claude-binary.test.ts new file mode 100644 index 000000000..dc376726b --- /dev/null +++ b/test/resolve-installed-claude-binary.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { resolveInstalledClaudeBinary } from "../src/entrypoints/run"; + +// Regression test for #1242: when install.sh silently fails (curl pipe to +// bash exits 0 even if curl errors out), the binary at $HOME/.local/bin/claude +// won't exist. Before the fix, run.ts handed that non-existent path to the +// Agent SDK, which crashed with "Claude Code native binary not found". After +// the fix, we return undefined so the SDK falls back to its bundled platform +// binary (the v1.0.99 behavior). +describe("resolveInstalledClaudeBinary (regression for #1242)", () => { + let warnSpy: any; + + beforeEach(() => { + warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test("returns the path when the binary exists on disk", () => { + const fileExists = (p: string) => p === "/home/runner/.local/bin/claude"; + const result = resolveInstalledClaudeBinary( + "/home/runner/.local/bin", + fileExists, + ); + expect(result).toBe("/home/runner/.local/bin/claude"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("returns undefined and warns when the binary is missing", () => { + // This is the air-gapped / unreachable-claude.ai case: install.sh ran but + // curl never reached claude.ai, so $HOME/.local/bin/claude doesn't exist. + const fileExists = () => false; + const result = resolveInstalledClaudeBinary( + "/home/sh-runner/.local/bin", + fileExists, + ); + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = String(warnSpy.mock.calls[0][0]); + expect(message).toContain("/home/sh-runner/.local/bin/claude"); + // Warning should mention the fallback so users in air-gapped CI know what + // to do (the behavior they got on v1.0.99 silently). + expect(message).toContain("bundled"); + }); + + test("uses fs.existsSync by default (no injected predicate)", () => { + // Hitting a path that definitely doesn't exist should return undefined, + // proving the default predicate is wired up. Don't assert on warnings + // here — only that the return value is undefined. + const result = resolveInstalledClaudeBinary( + "/nonexistent/path/that/should/not/exist/anywhere", + ); + expect(result).toBeUndefined(); + }); +});