-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Validate CLAUDE_CLI_VERSION and remove shell interpolation from the build scripts #1117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
ae771e2
Validate CLAUDE_CLI_VERSION and avoid shell interpolation in download…
qing-ant 118bf7c
Validate the version argument to update_cli_version.py
qing-ant cd91b25
Fix two union-attr errors in download_cli.py retry loop
qing-ant 6764d44
Fail CI when the CLI install pipeline fails
qing-ant 7d23db2
Lint and typecheck scripts/ in CI
qing-ant d4a6529
Exit cleanly on an invalid CLAUDE_CLI_VERSION
qing-ant a6a912a
Match the installer's version grammar, and stop retrying its rejections
qing-ant 0b68217
Simplify the version validator and its install-path tests
qing-ant 87cc5d6
Stop guessing at installer argument rejections; tighten dist-tag matc…
qing-ant 35ed7ed
Fail fast on the install failures a retry cannot fix
qing-ant 54fd51c
Accept comment-based-help installers; simplify the validator and inst…
qing-ant 1676543
Fold the two install-path test harnesses into one
qing-ant a8c73e9
Cut the comment bloat, and share the install scaffolding across platf…
qing-ant File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| --- | ||
| name: verify | ||
| description: Drive this repo's build/release scripts end-to-end without touching the network. Use when verifying changes to scripts/download_cli.py, scripts/build_wheel.py, scripts/update_cli_version.py, or scripts/_cli_version_validation.py. | ||
| --- | ||
|
|
||
| # Verifying the build scripts | ||
|
|
||
| The SDK itself is a library (drive it through `import claude_agent_sdk`), but | ||
| the `scripts/` directory is a set of **CLIs** run by the release workflows. | ||
| Verify them by running them, not by importing them. | ||
|
|
||
| ## NEVER run the real installer | ||
|
|
||
| `scripts/download_cli.py` shells out to `curl https://claude.ai/install.sh | bash` | ||
| (and the PowerShell equivalent). Running it for real **overwrites the `claude` | ||
| binary on the developer's machine**. Do not run `bash install.sh`, and do not | ||
| invoke `download_cli.py` with `latest`/`stable` (or any case variant) against | ||
| the real network. | ||
|
|
||
| Instead, put stub `curl` / `bash` / `powershell` on a temp `PATH` and drive the | ||
| script under `env -i` with an isolated `HOME` (so `find_installed_cli()` cannot | ||
| discover the real binary and copy it into `src/claude_agent_sdk/_bundled/`). | ||
|
|
||
| ## The harness | ||
|
|
||
| ```bash | ||
| SB=$(mktemp -d); mkdir -p $SB/stub $SB/home $SB/repo/src/claude_agent_sdk | ||
| cp scripts/{download_cli.py,build_wheel.py,update_cli_version.py,_cli_version_validation.py} $SB/repo/scripts/ | ||
| printf '__cli_version__ = "2.1.208"\n' > $SB/repo/src/claude_agent_sdk/_cli_version.py | ||
|
|
||
| cat > $SB/stub/curl <<'EOF' | ||
| #!/bin/bash | ||
| out=""; prev=""; for a in "$@"; do [ "$prev" = "-o" ] && out="$a"; prev="$a"; done | ||
| echo "[stub curl] argv: $*" >> "$MARKER" | ||
| [ -n "$out" ] && printf '%b' "${CURL_BODY:-#!/bin/bash\necho hi\n}" > "$out" | ||
| exit ${CURL_EXIT:-0} | ||
| EOF | ||
| cat > $SB/stub/bash <<'EOF' | ||
| #!/bin/bash | ||
| { echo "[stub bash] argc=$#"; for a in "$@"; do echo " <$a>"; done; } >> "$MARKER" | ||
| EOF | ||
| chmod +x $SB/stub/* | ||
|
|
||
| cd $SB/repo | ||
| env -i HOME=$SB/home PATH=$SB/stub:/usr/bin:/bin MARKER=$SB/m \ | ||
| CLAUDE_CLI_VERSION=2.1.208 .venv/bin/python scripts/download_cli.py | ||
| ``` | ||
|
|
||
| `run_command()` captures the child's output, so the stubs must log to a | ||
| `$MARKER` file — printing to stderr is swallowed. | ||
|
qing-ant marked this conversation as resolved.
|
||
|
|
||
| ## Flows worth driving | ||
|
|
||
| - **`update_cli_version.py <version>`** — the whole validator surface is | ||
| reachable here: a concrete version writes the file; `latest`/`stable`, | ||
| `v2.1.207`, `next`, `2.1`, and a quote-breakout string each exit 1 with a | ||
| distinct message and leave the file untouched. | ||
| - **`build_wheel.py --skip-sdist`** — reads the pin, then chains into | ||
| `download_cli.py`. Rewrite `_cli_version.py` in the sandbox to a moving tag / | ||
| single quotes / garbage / delete it: each must fail *before* any subprocess | ||
| is spawned. `--cli-version <v>` bypasses the pin (intentional escape hatch; | ||
| the release workflow does not use it). | ||
| - **`download_cli.py`** — set `CURL_BODY` to an HTML error page or an empty | ||
| string to prove the body check refuses it with no retry; empty the `PATH` to | ||
| prove a missing `curl` fails fast in one attempt; `CURL_EXIT=22` to prove a | ||
| genuine transient failure still retries 3×. | ||
| - **The Windows path is unreachable on Linux.** It is behind | ||
| `platform.system() == "Windows"`. Drive it with a small runner that | ||
| `patch.object(m.platform, "system", return_value="Windows")` and a stub | ||
| `powershell` that reads the `-Command` text and honours | ||
| `$env:CLAUDE_CLI_INSTALL_SCRIPT`. This is the one place where forcing the | ||
| platform is legitimate. | ||
|
|
||
| ## Gotchas | ||
|
|
||
| - `${CURL_BODY:-default}` treats an *empty* body as unset. Use a separate stub | ||
| that does `: > "$out"` to test the empty-body branch. | ||
| - The retry path really sleeps (jitter up to 5s, then 2s + 4s). A full | ||
| three-attempt run takes ~10s; budget for it rather than assuming a hang. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| r"""Shared validation for Claude Code CLI version strings. | ||
|
|
||
| update_cli_version.py writes the version into src/claude_agent_sdk/_cli_version.py | ||
| and download_cli.py reads it back out and hands it to an installer, so the rule | ||
| lives here once rather than in two copies that could drift apart. | ||
|
|
||
| The installer is the authority on what a version may be. install.sh (and | ||
| install.ps1) enforce | ||
|
|
||
| ^(stable|latest|[0-9]+\.[0-9]+\.[0-9]+(-[^[:space:]]+)?)$ | ||
|
|
||
| VERSION_PATTERN admits a deliberate *subset* of that: the installer's `-[^\s]+` | ||
| suffix would accept quotes, backslashes and semicolons, so the suffix here is | ||
| narrowed to the characters real versions use. Never widen it back toward the | ||
| installer's -- update_cli_version.py writes this value into a Python string | ||
| literal in a real source file. | ||
|
|
||
| "latest" and "stable" are the installer's dist-tags, and they are *moving*: | ||
| fine for a download, wrong for a pin, since _cli_version.py is the only record | ||
| of which build went into the wheels. Hence ``allow_dist_tag``. | ||
|
|
||
| VERSION_PATTERN is deliberately unanchored, and matched with fullmatch(): with | ||
| "^...$" a swap to match() would silently accept a trailing newline ("1.0.0\n"); | ||
| unanchored, the same swap accepts an obvious prefix like "1.0.0; id" and fails | ||
| loudly in tests. | ||
| """ | ||
|
|
||
| import re | ||
|
|
||
| VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.+-]+)?") | ||
|
qing-ant marked this conversation as resolved.
|
||
|
|
||
| # Matched exactly: install.sh's grammar is case-sensitive, so "Latest" is not a | ||
| # tag it would resolve, and accepting it here would widen what reaches a real | ||
| # network install. | ||
| DIST_TAGS = ("latest", "stable") | ||
|
|
||
| # Anything word-shaped that is not a version: "next", "beta", "nightly". Named | ||
| # so the error can say *why* it was rejected instead of printing a regex. | ||
| _DIST_TAG_SHAPED = re.compile(r"[A-Za-z][0-9A-Za-z-]*") | ||
|
|
||
| _SUPPORTED_TAGS = ", ".join(repr(tag) for tag in DIST_TAGS) | ||
|
|
||
|
|
||
| def _expected(allow_dist_tag: bool) -> str: | ||
| """The phrase naming what the caller should have passed instead.""" | ||
| if allow_dist_tag: | ||
| return f"{_SUPPORTED_TAGS}, or a concrete version" | ||
| return "a concrete version" | ||
|
|
||
|
|
||
| def _rejection(version: str, allow_dist_tag: bool) -> str: | ||
| """Why ``version`` is unusable -- the most specific reason that applies. | ||
|
|
||
| The caller prefixes "Invalid <source>: ", so each reason reads as the rest | ||
| of that sentence. | ||
| """ | ||
| candidate = version.strip() | ||
|
|
||
| # Reachable only when a tag is not a legal answer: validate_version() | ||
| # returns the tag otherwise. | ||
| if candidate in DIST_TAGS: | ||
| return ( | ||
| f"{candidate!r} is a moving dist-tag, not a concrete version. A pinned " | ||
| f"version must name the one build that goes into the wheels. Expected " | ||
| f"a version matching {VERSION_PATTERN.pattern}" | ||
| ) | ||
|
|
||
| # Only where a tag is a legal answer at all; when pinning, the | ||
| # dist-tag-shaped branch below correctly says to use a concrete version. | ||
| if allow_dist_tag and candidate.lower() in DIST_TAGS: | ||
| return f"{candidate!r}. Did you mean {candidate.lower()!r}?" | ||
|
|
||
| # Never normalized away: the caller asked for something we do not support, | ||
| # and silently installing a different string is worse. | ||
| if candidate[:1] in ("v", "V") and VERSION_PATTERN.fullmatch(candidate[1:]): | ||
| return f"{candidate!r}. Did you mean {candidate[1:]!r}? (no leading 'v')" | ||
|
|
||
| if _DIST_TAG_SHAPED.fullmatch(candidate): | ||
| return ( | ||
| f"{candidate!r} is not a supported dist-tag; " | ||
| f"use {_expected(allow_dist_tag)}" | ||
| ) | ||
|
|
||
| # Name the raw value, not the stripped one: if the whitespace is the | ||
| # problem, the reader has to be able to see it. | ||
| return ( | ||
| f"{version!r}. Expected {_expected(allow_dist_tag)} " | ||
| f"matching {VERSION_PATTERN.pattern}" | ||
| ) | ||
|
|
||
|
|
||
| def validate_version(version: str, *, source: str, allow_dist_tag: bool) -> str: | ||
| """Return the usable form of ``version``, or raise. | ||
|
|
||
| Surrounding whitespace is stripped first -- a trailing "\\n" from a file | ||
| read or a "\\r" from a CRLF checkout is unambiguous in intent -- and the | ||
| stripped value is what the caller gets back and must use downstream. | ||
|
|
||
| Args: | ||
| version: The candidate version string. | ||
| source: Where the value came from, named in the error message | ||
| (e.g. "CLAUDE_CLI_VERSION"). | ||
| allow_dist_tag: Whether a moving dist-tag ("latest", "stable") is | ||
| acceptable. It is for a download, which resolves it at install | ||
| time; it is not for a value pinned into _cli_version.py. | ||
|
|
||
| Raises: | ||
| ValueError: If ``version`` is neither an allowed dist-tag nor a | ||
| fullmatch of VERSION_PATTERN. | ||
| """ | ||
| candidate = version.strip() | ||
|
|
||
| if candidate in DIST_TAGS and allow_dist_tag: | ||
| return candidate | ||
|
|
||
| if VERSION_PATTERN.fullmatch(candidate): | ||
| return candidate | ||
|
|
||
| raise ValueError(f"Invalid {source}: {_rejection(version, allow_dist_tag)}") | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.