From 60ae8293211035663f1f3b1f6bc420ff22a516e7 Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Sun, 26 Apr 2026 14:04:57 -0700 Subject: [PATCH] fix(install): fall back to SDK bundled binary when install.sh produces no binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When claude.ai is unreachable (corporate firewall, air-gapped runner), the curl|bash install pipeline can exit 0 without producing a binary at $HOME/.local/bin/claude. The current code returns that bogus path to the Agent SDK, which then crashes with 'Claude Code native binary not found'. This change verifies the binary actually exists on disk before returning its path. When missing, it returns undefined so the Agent SDK falls back to its bundled platform binary (the @anthropic-ai/claude-agent-sdk-{platform} optional dependency) — restoring the v1.0.99 behavior. Closes #1242 --- src/entrypoints/run.ts | 59 +++++++++++++++++--- test/resolve-installed-claude-binary.test.ts | 57 +++++++++++++++++++ 2 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 test/resolve-installed-claude-binary.test.ts 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(); + }); +});