diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 8da14a613..ae6e371c4 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -41,6 +41,13 @@ 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"; +// Exported for unit testing. `set -o pipefail` makes curl's non-zero exit +// propagate through the pipe so the install retry logic actually triggers +// on 429/403 instead of silently succeeding (see #1136). +export function buildInstallCommand(version: string): string { + return `set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash -s -- ${version}`; +} + /** * Install Claude Code CLI, handling retry logic and custom executable paths. * Returns the absolute path to the claude executable. @@ -74,10 +81,7 @@ async function installClaudeCode(): Promise { await new Promise((resolve, reject) => { const child = spawn( "bash", - [ - "-c", - `curl -fsSL https://claude.ai/install.sh | bash -s -- ${claudeCodeVersion}`, - ], + ["-c", buildInstallCommand(claudeCodeVersion)], { stdio: "inherit" }, ); child.on("close", (code) => { diff --git a/test/install-pipefail.test.ts b/test/install-pipefail.test.ts new file mode 100644 index 000000000..2ab88696f --- /dev/null +++ b/test/install-pipefail.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "bun:test"; +import { spawnSync } from "child_process"; +import { buildInstallCommand } from "../src/entrypoints/run"; + +describe("buildInstallCommand (regression for #1136)", () => { + it("includes the pinned claude version in the bash -s args", () => { + const cmd = buildInstallCommand("2.1.114"); + expect(cmd).toContain("bash -s -- 2.1.114"); + }); + + it("prefixes the pipeline with `set -o pipefail`", () => { + const cmd = buildInstallCommand("2.1.114"); + expect(cmd.startsWith("set -o pipefail;")).toBe(true); + }); + + it("keeps the curl -fsSL flags so the script is fetched, not inlined", () => { + const cmd = buildInstallCommand("2.1.114"); + expect(cmd).toContain( + "curl -fsSL https://claude.ai/install.sh | bash -s --", + ); + }); +}); + +describe("pipefail semantics (proves the bug shape and the fix)", () => { + // Mirrors the real install invocation: a curl that returns non-zero + // feeding into `bash -s --`. Without pipefail, the pipeline exits 0 + // because bash -s receives an empty stdin and does nothing. With + // pipefail, curl's exit code wins and the retry loop in run.ts triggers. + // + // Uses port 1 (reserved/unused) so curl fails deterministically with no + // network access. No shell-escaping traps here: the version argument is + // a numeric literal. + const unreachable = "http://127.0.0.1:1/nope"; + const version = "2.1.114"; + + it("BEFORE FIX: pipeline without pipefail swallows curl failure (exit 0)", () => { + const buggy = `curl -fsSL ${unreachable} | bash -s -- ${version}`; + const result = spawnSync("bash", ["-c", buggy], { stdio: "pipe" }); + expect(result.status).toBe(0); + }); + + it("AFTER FIX: buildInstallCommand (against unreachable host) exits non-zero", () => { + const fixed = buildInstallCommand(version).replace( + "https://claude.ai/install.sh", + unreachable, + ); + const result = spawnSync("bash", ["-c", fixed], { stdio: "pipe" }); + expect(result.status).not.toBe(0); + }); +});