Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 50 additions & 9 deletions src/entrypoints/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
async function installClaudeCode(): Promise<string | undefined> {
const customExecutable = process.env.PATH_TO_CLAUDE_CODE_EXECUTABLE;
if (customExecutable) {
if (/[\x00-\x1f\x7f]/.test(customExecutable)) {
Expand All @@ -68,6 +99,7 @@ async function installClaudeCode(): Promise<string> {
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 {
Expand All @@ -86,26 +118,35 @@ async function installClaudeCode(): Promise<string> {
});
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;
}

/**
Expand Down
57 changes: 57 additions & 0 deletions test/resolve-installed-claude-binary.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});