Add CLIInstaller for vendoring a pinned Claude Code CLI binary - #54
Conversation
ClaudeAgentSDK::CLIInstaller.install(version:, dir:) downloads a pinned CLI binary from the official release endpoints into a project-local directory (default vendor/claude/), for hermetic deploys without bundling the ~280MB binary into the gem. Stdlib only. - Version resolution via stable/latest dist-tag endpoints or a concrete pinned version; manifest sha256 verification with the download bounded by the manifest's declared size - Crash/upgrade-safe publication order: download -> verify -> record metadata -> atomic rename last, so a failed upgrade never destroys a working install and readers only ever see an intact binary - flock-based install lock makes concurrent installs safe; the idempotent path re-hashes the binary against the recorded checksum with zero network access, so repeat boots work offline - O_EXCL random-suffix temp files; filesystem failures normalized to the new CLIInstallError - find_cli discovery order extended: options.cli_path -> CLAUDE_CLI_PATH env var -> vendored binary -> PATH/common locations Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TS6ALBqEGJc3rGqNjnXGqD
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aeeeafa916
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # File.file? as well as executable?: executable? is true for directories, | ||
| # so a directory in CLAUDE_CLI_PATH would otherwise pass here and fail | ||
| # much later with an opaque spawn error. | ||
| return env_path if !env_path.empty? && File.file?(env_path) && File.executable?(env_path) |
There was a problem hiding this comment.
Resolve relative CLI overrides before changing directories
When CLAUDE_CLI_PATH is relative and ClaudeAgentOptions#cwd differs from the process's current directory, this check accepts the executable relative to the current directory and returns that unchanged relative path. The version probe later finds it from the original directory, but connect invokes the same path through Open3.popen3(..., chdir: @cwd), so it either raises CLINotFoundError or executes a different file in the configured working directory. Expand the validated override to an absolute path before returning it.
Useful? React with 👍 / 👎.
| # nothing vouched for, and a metadata failure then had to delete the | ||
| # freshly renamed file, taking the previous working install with it.) | ||
| def publish(dir, binary, version, platform, entry) | ||
| tmp = "#{binary}.download.#{SecureRandom.hex(8)}" |
There was a problem hiding this comment.
Remove abandoned download files before retrying
If the installer is killed or the machine stops during fetch_verified or after metadata is written, the ensure block never runs and this randomly named sibling file remains, potentially containing the entire roughly 245–280 MB binary. The next install ignores it and downloads into another random file, so an interrupted install on a constrained or persistent shared volume can leave insufficient space for every subsequent retry, with repeated interruptions accumulating more files. Since installation already holds the directory lock, clean up stale files matching this private download-file pattern before starting another download.
Useful? React with 👍 / 👎.
| # Dist-tags resolved through a GET to BASE_URL/<tag>. | ||
| DIST_TAGS = %w[stable latest].freeze | ||
| # Concrete version, optionally with a pre-release suffix (e.g. 2.1.220-rc1). | ||
| VERSION_PATTERN = /\A\d+\.\d+\.\d+(-\S+)?\z/ |
There was a problem hiding this comment.
Reject URL path delimiters in concrete versions
The \S+ prerelease suffix accepts slashes and dot segments, so a value such as 2.1.220-foo/../2.1.221 passes validation and is interpolated directly into both release URLs. In environments where the origin or an HTTPS intermediary normalizes dot segments, this can fetch and verify 2.1.221 while recording the supplied 2.1.220-prefixed string in VERSION, defeating the installer's exact-version pinning guarantee; without normalization it still turns malformed input into unintended network requests instead of the promised validation error. Restrict the suffix to the release service's valid prerelease characters.
Useful? React with 👍 / 👎.
| dir = File.expand_path(dir || default_dir) | ||
| # Resolved before the lock: it is a read-only GET, and the format check | ||
| # must reject a bad version before anything is created on disk. | ||
| resolved = Release.resolve_version(version) |
There was a problem hiding this comment.
Resolve moving dist-tags while holding the install lock
When concurrent callers install stable or latest during a tag rollout or across inconsistent CDN responses, resolving the tag before acquiring the lock allows one caller to capture the older version, pause, and then acquire the lock after another caller has already installed the newer version. The delayed caller sees a version mismatch and atomically downgrades the shared installation, so the final version depends on scheduling rather than the most recently resolved tag and the documented loser does not merely observe the completed install. Resolve moving tags within the serialized section, or otherwise prevent a stale tag result from replacing a newer installed version.
Useful? React with 👍 / 👎.
…tion, env path - Restrict VERSION_PATTERN's pre-release suffix to the semver character set: an accepted version is interpolated into download URLs, and \S+ allowed "2.1.220-x/../2.1.221" to traverse out of the release path - Sweep stale claude.download.* / VERSION.*.tmp files (abandoned by a SIGKILL'd install) under the install lock before each install - Resolve dist-tags INSIDE the install lock so a stalled installer cannot downgrade a concurrently published newer version (last resolver wins); local format validation stays before mkdir_p - Absolutize a relative CLAUDE_CLI_PATH against the current working directory so the validated file and the spawned file (chdir: options.cwd) are the same file Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TS6ALBqEGJc3rGqNjnXGqD
|
All four review findings addressed in 5e59b9a:
1296 examples, 0 failures; RuboCop clean. +7 regression specs covering each finding. |
CLIInstaller: vendor a pinned Claude Code CLI binary for hermetic deploys (PR #54), plus CLAUDE_CLI_PATH / vendored-binary CLI discovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TS6ALBqEGJc3rGqNjnXGqD
Summary
Adds
ClaudeAgentSDK::CLIInstaller— a stdlib-only installer that downloads a pinned Claude Code CLI binary from the official release endpoints (downloads.claude.ai/claude-code-releases) into a project-local directory (defaultvendor/claude/). This gives deploys a hermetic, version-pinned CLI without bundling the ~280MB binary into the gem (the approach the Python SDK took with platform wheels).Design
stable/latestdist-tags resolved via their endpoints (response validated against a version pattern), or a concrete pinned version. Manifest sha256 verification; the binary stream is bounded by the manifest's declaredsize.renamelast. No failable step after the rename, so a failed upgrade never destroys a working install, and readers (find_cli, a CLI being spawned) only ever see an intact binary. This is also why discovery stays lock-free.flockondir/.install.lockcovers the whole check→download→publish sequence; parallel boots serialize and the loser observes a finished install.CLIInstallError.find_cli:options.cli_path→CLAUDE_CLI_PATHenv var → vendored binary →which/common locations.Review
Externally reviewed (two adversarial passes): 6 findings (4 P1 / 2 P2) all resolved — install lock, checksum-verified idempotency, symlink/temp-name hardening, response caps, error normalization, and the destructive-upgrade publication-order fix.
Verification
bundle exec rspec: 1289 examples, 0 failures (cli_installer_spec: 56 examples)bundle exec rubocop: 66 files, no offensesclaude -vruns, tamper triggers reinstall, idempotent re-call in 0.11s🤖 Generated with Claude Code
https://claude.ai/code/session_01TS6ALBqEGJc3rGqNjnXGqD