Skip to content

Add CLIInstaller for vendoring a pinned Claude Code CLI binary - #54

Merged
ya-luotao merged 2 commits into
mainfrom
feature/cli-installer
Aug 9, 2026
Merged

Add CLIInstaller for vendoring a pinned Claude Code CLI binary#54
ya-luotao merged 2 commits into
mainfrom
feature/cli-installer

Conversation

@ya-luotao

Copy link
Copy Markdown
Owner

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 (default vendor/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).

# bin/setup or a Dockerfile build step
ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220')

Design

  • Version resolution: stable / latest dist-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 declared size.
  • Crash/upgrade-safe publication order: download → verify checksum → write metadata → atomic rename last. 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.
  • Concurrency: an exclusive flock on dir/.install.lock covers the whole check→download→publish sequence; parallel boots serialize and the loser observes a finished install.
  • Offline idempotency: the shortcut re-hashes the vendored binary against the checksum recorded at install time (~0.1s for the real 245MB binary) — zero network, so repeat boots work offline with a pinned version.
  • Hardening: O_EXCL random-suffix temp files (no symlink following, no predictable names), HTTPS-only with bounded redirects, text response caps, filesystem errors normalized to the new CLIInstallError.
  • Discovery order in find_cli: options.cli_pathCLAUDE_CLI_PATH env 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 offenses
  • Real-endpoint end-to-end: full 245MB download, checksum match against the live manifest, claude -v runs, tamper triggers reinstall, idempotent re-call in 0.11s

🤖 Generated with Claude Code

https://claude.ai/code/session_01TS6ALBqEGJc3rGqNjnXGqD

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread lib/claude_agent_sdk/cli_installer.rb Outdated
# 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/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread lib/claude_agent_sdk/cli_installer.rb Outdated
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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
@ya-luotao

Copy link
Copy Markdown
Owner Author

All four review findings addressed in 5e59b9a:

  1. Relative CLAUDE_CLI_PATH vs cwd: — the env override is now absolutized against the process working directory before validation, so the validated file and the spawned file (chdir: options.cwd) are always the same file.
  2. Abandoned download temp files — stale claude.download.* / VERSION.*.tmp files are swept under the install lock at the start of every install (including no-op idempotent hits, so a finished machine still reclaims them).
  3. Version suffix traversalVERSION_PATTERN's pre-release suffix restricted to the semver character set; 2.1.220-x/../2.1.221 and friends now raise CLIInstallError before any network or filesystem activity.
  4. Dist-tag downgrade race — resolution split into local validate_version (still before mkdir_p, malformed input creates nothing) and network resolve_version (now inside the install lock). Semantics: last resolver wins; a stalled installer can no longer downgrade a concurrently published newer version.

1296 examples, 0 failures; RuboCop clean. +7 regression specs covering each finding.

@ya-luotao
ya-luotao merged commit 3cf7e46 into main Aug 9, 2026
3 checks passed
ya-luotao added a commit that referenced this pull request Aug 9, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant