diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0c192b50c..a93bec32f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -169,10 +169,10 @@ jobs: run: sed -i -E 's/^version = ".*"$/version = "${{ needs.version.outputs.version }}"/' Cargo.toml - name: Snapshot CLI version + hashes for build.rs run: bash scripts/snapshot-bundled-cli-version.sh - - name: Verify snapshot file exists + - name: Verify cli-version.txt exists run: | - if [[ ! -f bundled_cli_version.txt ]]; then - echo "::error::bundled_cli_version.txt was not generated. The Snapshot step must run before packaging." + if [[ ! -f cli-version.txt ]]; then + echo "::error::cli-version.txt was not generated. The Snapshot step must run before packaging." exit 1 fi - name: Package (dry run) diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 952294e13..23e686eb7 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -73,18 +73,38 @@ jobs: prefix-key: v1-rust-no-bin cache-bin: false + - name: Read pinned @github/copilot CLI version + id: cli-version + working-directory: ./nodejs + run: | + version=$(node -p "require('./package-lock.json').packages['node_modules/@github/copilot'].version") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Pinned CLI version: $version" + + # Share the bundled-CLI archive cache with the `bundle` job: build.rs + # now downloads in both modes (embed for `bundle`, extract-to-cache + # for this `test` job's `--no-default-features` build). + - name: Cache bundled CLI tarball + uses: actions/cache@v4 + with: + path: ./rust/.bundled-cli-cache + key: bundled-cli-${{ matrix.os }}-${{ steps.cli-version.outputs.version }} + - name: cargo fmt --check (nightly) if: runner.os == 'Linux' run: cargo +nightly-2026-04-14 fmt --all -- --config-path .rustfmt.nightly.toml --check - name: cargo clippy if: runner.os == 'Linux' + env: + BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache run: cargo clippy --all-targets --features test-support -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type - name: cargo doc if: runner.os == 'Linux' env: RUSTDOCFLAGS: "-D warnings" + BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache run: cargo doc --no-deps --all-features - name: Install test harness dependencies @@ -101,9 +121,12 @@ jobs: RUST_E2E_CONCURRENCY: 4 COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} - # `--no-default-features` disables the bundled-cli download; the - # tests use the CLI provided by setup-copilot via COPILOT_CLI_PATH. - # The dedicated `bundle` job below exercises the bundling pipeline. + BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache + # `--no-default-features` selects dev mode: build.rs still downloads + # + verifies + extracts the CLI to the per-user cache, but doesn't + # embed it. Tests exec against the setup-copilot CLI via + # COPILOT_CLI_PATH (the env override wins over the dev cache). + # The dedicated `bundle` job below exercises the embed pipeline. run: cargo test --no-default-features --features test-support -- --test-threads=4 --nocapture # Validates the bundled-CLI build path on all three supported diff --git a/rust/.gitignore b/rust/.gitignore index 03bbce707..c4095ffc0 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -1,3 +1,3 @@ /target Cargo.lock.bak -bundled_cli_version.txt +cli-version.txt diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1e02f267c..4f16c7bf5 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -19,7 +19,7 @@ include = [ "Cargo.toml", "README.md", "LICENSE", - "bundled_cli_version.txt", + "cli-version.txt", ] [lib] @@ -27,7 +27,7 @@ name = "github_copilot_sdk" [features] default = ["bundled-cli"] -bundled-cli = ["dep:dirs", "dep:tar", "dep:flate2", "dep:zip"] +bundled-cli = ["dep:tar", "dep:flate2", "dep:zip"] derive = ["dep:schemars"] test-support = [] @@ -48,7 +48,7 @@ tokio = { version = "1", features = ["io-util", "sync", "rt", "process", "net", tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", default-features = false } tracing = "0.1" -dirs = { version = "5", optional = true } +dirs = "5" parking_lot = "0.12" regex = "1" getrandom = "0.2" @@ -69,6 +69,7 @@ tempfile = "3" tokio = { version = "1", features = ["rt-multi-thread"] } [build-dependencies] +dirs = "5" flate2 = "1" sha2 = "0.10" tar = "0.4" diff --git a/rust/README.md b/rust/README.md index 00e26dbaa..215448e05 100644 --- a/rust/README.md +++ b/rust/README.md @@ -756,30 +756,59 @@ none of them are scheduled for removal. ## Embedded CLI -The SDK bundles the Copilot CLI binary inside the consumer's compiled crate by default. No env var setup, no separate install — just `cargo build` and you get a self-contained binary. +The SDK provisions the Copilot CLI binary at build time. By default the `bundled-cli` feature embeds the verified binary directly in your compiled crate, so end-user binaries are self-contained — no env var setup, no separate install, just `cargo build`. -To opt out (e.g. for binary-size-sensitive consumers, or environments that provide the CLI via PATH), set `default-features = false`: +For builds that prefer a smaller artifact, disable the `bundled-cli` feature: ```toml github-copilot-sdk = { version = "0.1", default-features = false } ``` +> **You become responsible for supplying the CLI at runtime.** With +> `bundled-cli` disabled, the produced binary does not contain the CLI +> and will not search the system for one. You must point it at a +> compatible CLI via [`CliProgram::Path`] (on `ClientOptions`) or the +> `COPILOT_CLI_PATH` environment variable, and you are responsible for +> guaranteeing the supplied CLI version is compatible with this SDK +> release. Do **not** assume that whatever CLI happens to be installed +> on the target system will work — the SDK and CLI are versioned +> together. +> +> **Convenience on the build machine only.** As a special case, +> `build.rs` downloads and SHA-verifies the compatible CLI version and +> drops it into the build machine's per-user cache; the runtime +> resolver on that same machine will pick it up automatically. This +> makes local development and CI ergonomic, but it does **not** carry +> over when you copy the built binary to another machine — distributed +> builds (release artifacts, signed installers, container images, etc.) +> must either keep `bundled-cli` enabled or ship the CLI alongside and +> set `CliProgram::Path` / `COPILOT_CLI_PATH`. + ### How it works -1. **Pinned at publish time.** When the rust crate is published, a workflow step writes `bundled_cli_version.txt` (CLI version + per-platform SHA-256 hashes) into the crate from the in-effect `nodejs/package-lock.json` and the matching GitHub Release's `SHA256SUMS.txt`. This file is gitignored locally; it only exists in the published crate tarball. +1. **Version pin.** `build.rs` reads the CLI version from one of two sources: + - `cli-version.txt` at the crate root (present in published crate tarballs and vendored slots). + - Otherwise, `../nodejs/package-lock.json` (contributor build inside the github/copilot-sdk repo — matches the .NET and Go SDK conventions here). + + The resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` regardless of mode. The runtime resolver consumes it to recompute the on-disk path by convention, so no absolute paths leak into the rlib. + +2. **Build time:** `build.rs` downloads the platform-appropriate archive from the [`github/copilot-cli` GitHub Releases](https://github.com/github/copilot-cli/releases) (`copilot-{platform}.tar.gz` on macOS/Linux, `.zip` on Windows), live-fetches the matching `SHA256SUMS.txt`, and verifies the archive hash. Then: + - **`bundled-cli` on (default, release):** embeds the raw archive bytes via `include_bytes!()`. Runtime extracts on first `Client::start()`. + - **`bundled-cli` off:** extracts the binary directly into the platform cache (staging file + atomic rename), idempotent across rebuilds. If the extracted binary is already present at the expected path, the download is skipped entirely — the extracted binary *is* the cache. -2. **Build time:** The SDK's `build.rs` resolves the version + per-platform SHA-256: - - `COPILOT_CLI_VERSION` env var (advanced override; fetches live `SHA256SUMS.txt`). - - Otherwise, `bundled_cli_version.txt` from the published crate. - - Otherwise (mono-repo contributor build), live read from `../nodejs/package-lock.json` + live fetch of `SHA256SUMS.txt`. +3. **Runtime:** in both modes the binary lives at: - It then downloads the platform-appropriate archive from the [`github/copilot-cli` GitHub Releases](https://github.com/github/copilot-cli/releases) (`copilot-{platform}.tar.gz` on macOS/Linux, `.zip` on Windows), verifies the SHA-256, extracts the `copilot` binary, compresses it with zstd, and embeds via `include_bytes!()`. + | OS | Path | + |----|------| + | macOS | `~/Library/Caches/github-copilot-sdk/cli//copilot` | + | Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli//copilot` | + | Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\\copilot.exe` | -3. **Runtime:** On the first call to `github_copilot_sdk::Client::start()`, the embedded archive is lazily extracted to the platform cache dir (`%LOCALAPPDATA%\github-copilot-sdk-{version}\` on Windows, `~/Library/Caches/github-copilot-sdk-{version}/` on macOS, `$XDG_CACHE_HOME/github-copilot-sdk-{version}/` (or `~/.cache/...`) on Linux). Subsequent runs reuse the extracted binary. + Old version directories accumulate in siblings; clean them up at your leisure. ### Overriding the extraction location -Use [`ClientOptions::with_bundled_cli_extract_dir`] when you need to place the extracted binary somewhere other than the platform cache dir (CI runners with ephemeral homes, sandboxes that disallow cache paths, etc.): +[`ClientOptions::with_bundled_cli_extract_dir`] redirects embed-mode extraction to a custom directory (CI runners with ephemeral homes, sandboxes that disallow cache paths, etc.): ```rust,ignore use std::path::PathBuf; @@ -790,15 +819,34 @@ let options = ClientOptions::new() let client = Client::start(options).await?; ``` +With `bundled-cli` disabled the equivalent knob is the **`COPILOT_CLI_EXTRACT_DIR`** environment variable, which is honored symmetrically at build time (where `build.rs` writes the binary) and at runtime (where the resolver reads it). When set, the binary lives directly under the named directory (no per-version subdir). The most ergonomic way to pin it from a consumer crate is `.cargo/config.toml`: + +```toml +# .cargo/config.toml at the consumer's repo root +[env] +COPILOT_CLI_EXTRACT_DIR = { value = "vendor/copilot", relative = true, force = true } +``` + +`relative = true` resolves the path against the config file's directory, so the value is stable regardless of where `cargo build` is invoked from. `force = true` makes the value visible to invocations of the produced binary under `cargo run` / `cargo test`, keeping build and runtime in sync. For runtime invocations outside cargo (e.g. a deploy script running the binary directly), either export the same env var or use [`CliProgram::Path`] / `COPILOT_CLI_PATH` at runtime. + +### Skipping the bundle entirely + +Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the CLI at runtime via `ClientOptions::program = CliProgram::Path(...)` or `COPILOT_CLI_PATH`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless one of those explicit sources resolves. + ### Resolution priority -`copilot_binary()` checks these sources in order: +`Client::start` resolves the CLI in this order: + +1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`. +2. `COPILOT_CLI_PATH` environment variable, if it points at a real file. +3. **`bundled-cli` on:** the embedded archive, lazily extracted on first call. +4. **`bundled-cli` off:** the build-time-extracted binary in the per-user cache, located by recomputing the convention from `COPILOT_SDK_CLI_VERSION` + OS + optional `COPILOT_CLI_EXTRACT_DIR`. + +There is no PATH scanning. If none of the above resolves, `Client::start` returns `Error::BinaryNotFound`. -1. Explicit `CliProgram::Path(path)` on `ClientOptions::program` -2. `COPILOT_CLI_PATH` environment variable -3. Embedded CLI (when the `bundled-cli` feature is enabled, which it is by default) +### Download cache (build-time, embed mode) -There is no PATH scanning. If both 1+2 are unset and the SDK was built with `default-features = false`, `Client::start` returns `Error::BinaryNotFound`. +In embed mode `build.rs` re-downloads on every clean build by default. Set `BUNDLED_CLI_CACHE_DIR=` to cache the verified archive between builds (CI keys this on `-` for ~zero-cost rebuilds on cache hits). With `bundled-cli` disabled there is no separate archive cache — the extracted binary itself is the cache. ### Platforms @@ -808,7 +856,7 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64` | Feature | Default | Description | | -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bundled-cli` | ✓ | Build-time CLI embedding. Pulls in `dirs`, `tar`+`flate2` (Linux/macOS), or `zip` (Windows). Disable via `default-features = false` to opt out (e.g. when shipping a smaller binary or when always supplying the CLI via `CliProgram::Path` / `COPILOT_CLI_PATH`). | +| `bundled-cli` | ✓ | Build-time CLI embedding. Pulls in `tar`+`flate2` (Linux/macOS) or `zip` (Windows). Disable via `default-features = false` to opt out (e.g. when shipping a smaller binary or when always supplying the CLI via `CliProgram::Path` / `COPILOT_CLI_PATH`). | | `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). Enable when defining [tool parameters](#tool-registration). | ```toml diff --git a/rust/build.rs b/rust/build.rs index c64dbff9b..64659107e 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,24 +1,48 @@ use std::io::Read; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use sha2::Digest; fn main() { - println!("cargo:rerun-if-env-changed=COPILOT_CLI_VERSION"); + println!("cargo:rerun-if-env-changed=COPILOT_SKIP_CLI_DOWNLOAD"); + println!("cargo:rerun-if-env-changed=COPILOT_CLI_EXTRACT_DIR"); println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); + println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); + println!("cargo:rerun-if-changed=cli-version.txt"); + + // Only declare the lockfile rerun when the lockfile actually exists. + // Cargo treats `rerun-if-changed` for a missing path as "always rerun" + // — so unconditionally declaring this on consumers without a sibling + // `nodejs/` (vendored slots, published crates) would force build.rs + // to re-run on every `cargo build` even when nothing has changed. + // The lockfile path is only the source-of-truth in this repo's + // contributor builds; everywhere else `cli-version.txt` is canonical. + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); + let lockfile = Path::new(&manifest_dir) + .join("..") + .join("nodejs") + .join("package-lock.json"); + if lockfile.is_file() { + println!("cargo:rerun-if-changed={}", lockfile.display()); + } - // The `bundled-cli` cargo feature gates bundling at the build-system level. - // When disabled (e.g. via `default-features = false`), runtime archive - // helpers (tar/flate2/zip) are not in the graph and no download happens. - if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_none() { + // Hard opt-out: disable the entire download / bundle / cache mechanism + // in one step. For consumers who always supply the CLI via + // `CliProgram::Path` or `COPILOT_CLI_PATH` and don't want build.rs to + // touch the network (offline builds, locked-down CI, etc.). Works + // regardless of the `bundled-cli` cargo feature state — with neither + // `has_bundled_cli` nor `has_extracted_cli` emitted, runtime resolution + // falls straight through to `Error::BinaryNotFound` unless an explicit + // path source resolves first. + if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() { + println!( + "cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping CLI download/bundle/cache" + ); return; } - println!("cargo:rerun-if-changed=bundled_cli_version.txt"); - println!("cargo:rerun-if-changed=../nodejs/package-lock.json"); - let Some(platform) = target_platform() else { println!("cargo:warning=Unsupported target platform for Copilot CLI bundling — skipping"); return; @@ -27,68 +51,123 @@ fn main() { let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is always set by cargo"); let out = Path::new(&out_dir); - // Resolve version + per-asset SHA-256 from one of three sources, in order: - // 1. `COPILOT_CLI_VERSION` env-var override (live SHA256SUMS.txt fetch) - // 2. `bundled_cli_version.txt` snapshot at the crate root (published-crate - // consumer; generated by the publish workflow) - // 3. Sibling `../nodejs/package-lock.json` (mono-repo contributor build; - // live SHA256SUMS.txt fetch) + // Resolve version + per-asset SHA-256 from one of two sources, in order: + // 1. `cli-version.txt` snapshot at the crate root (published-crate + // consumer; generated by the publish workflow). Combined format: + // `version=X` line + per-asset hash lines. Committing the hashes + // makes the publish workflow the trust boundary — an attacker who + // later re-points the release tag can't silently poison consumer + // builds. + // 2. Sibling `../nodejs/package-lock.json` (contributor build inside + // the github/copilot-sdk repo; live SHA256SUMS.txt fetch). Matches + // the .NET `_GetCopilotCliVersion` MSBuild target and the Go + // `cmd/bundler` tool. let (version, expected_hash) = resolve_version_and_hash(platform.asset_name); + // Bake the version into the crate regardless of mode. This is the + // single source of truth for "what CLI version did build.rs target", + // consumed by both the embed-mode path computation in embeddedcli.rs + // and the runtime path computation in resolve.rs (when `bundled-cli` + // is off). It's a small, machine-independent datum: no absolute + // paths, no username/home leakage, so sccache / cross-machine + // `target/` reuse stays cache-coherent. + println!("cargo:rustc-env=COPILOT_SDK_CLI_VERSION={version}"); + let base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR") .ok() .map(std::path::PathBuf::from); - let asset_name = platform.asset_name; // Versioned cache key since copilot asset names don't include the version. - let cache_key = format!("v{version}-{asset_name}"); - - // Download the archive (or read from cache) and verify SHA-256. The raw - // archive is what gets embedded — extraction happens at runtime. Quiet on - // cache hit; logs `Downloading` + `Caching archive at` on cache miss. - let archive = cached_download( - &format!("{base_url}/{asset_name}"), - &cache_key, - &expected_hash, - &cache_dir, - ); + let cache_key = format!("v{version}-{}", platform.asset_name); + + if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { + // Embed mode: we need the archive bytes to bake into the rlib, so + // always run the download (cache hit short-circuits inside + // `cached_download`). + let archive = cached_download( + &format!("{base_url}/{}", platform.asset_name), + &cache_key, + &expected_hash, + &cache_dir, + ); + verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); + emit_embedded(out, &archive); + println!("cargo:rustc-cfg=has_bundled_cli"); + } else { + // With `bundled-cli` off the extracted binary *is* the cache. + // Skip the upstream download entirely when it already exists at + // the expected path. No two separate caches. + // + // Runtime resolution (see `src/resolve.rs::extracted_cli_path`) + // recomputes this same path from `COPILOT_SDK_CLI_VERSION` + the + // OS-derived binary name + optional `COPILOT_CLI_EXTRACT_DIR`, + // so we don't bake an absolute path into the crate. + let install_dir = extracted_install_dir(&version); + let final_path = install_dir.join(platform.binary_name); + + if !final_path.is_file() { + let archive = cached_download( + &format!("{base_url}/{}", platform.asset_name), + &cache_key, + &expected_hash, + &cache_dir, + ); + verify_binary_present_in_archive(&archive, platform.binary_name, platform.asset_name); + extract_to_cache(&archive, &install_dir, platform); + } - // Sanity check: the runtime extraction path expects `binary_name` inside - // the archive. Fail the build now (with a clear message) rather than - // shipping a broken bundle if the upstream archive layout ever changes. - verify_binary_present_in_archive(&archive, platform.binary_name, asset_name); + println!("cargo:rustc-cfg=has_extracted_cli"); + } +} + +/// Install directory used when `bundled-cli` is off. Mirrors the runtime +/// convention in `src/resolve.rs::extracted_cli_path`: both sides MUST +/// compute the same path from the same inputs, otherwise the runtime +/// resolver won't find what build.rs extracted. +/// +/// If `COPILOT_CLI_EXTRACT_DIR` is set the binary lives directly under +/// that directory (no per-version subdir) — useful for vendored slots and +/// for `.cargo/config.toml [env]`-style pinning that's symmetric between +/// build-time write and runtime read. Otherwise the binary lives under +/// `/github-copilot-sdk/cli//`. +fn extracted_install_dir(version: &str) -> PathBuf { + if let Some(custom) = std::env::var_os("COPILOT_CLI_EXTRACT_DIR") { + PathBuf::from(custom) + } else { + let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); + cache + .join("github-copilot-sdk") + .join("cli") + .join(sanitize_version(version)) + } +} - std::fs::write(out.join("copilot_cli.archive"), &archive) +/// Emit the `bundled_cli.rs` glue + `copilot_cli.archive` blob into `OUT_DIR` +/// for embed mode (`bundled-cli` cargo feature on). The version is exposed +/// crate-wide via the unconditional `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` +/// emit; the binary name is OS-derived at runtime — so all we need to +/// generate here is the archive blob include. +fn emit_embedded(out: &Path, archive: &[u8]) { + std::fs::write(out.join("copilot_cli.archive"), archive) .expect("failed to write copilot_cli.archive"); - let generated = format!( - r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. + let generated = r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive"); -pub(super) static CLI_VERSION: &str = "{version}"; -pub(super) static CLI_BINARY_NAME: &str = "{binary_name}"; -"#, - binary_name = platform.binary_name, - ); +"#; std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); - - println!("cargo:rustc-cfg=has_bundled_cli"); } /// Resolve the CLI version and the expected SHA-256 hash for the current -/// target's archive. Picks one of three sources in order. Panics with a clear -/// error if none are available. +/// target's archive. Picks one of two sources in order. Panics with a clear +/// error if neither is available. fn resolve_version_and_hash(asset_name: &str) -> (String, String) { - // 1. Env-var override — fetches live SHA256SUMS for the overridden version. - if let Ok(version) = std::env::var("COPILOT_CLI_VERSION") { - let hash = fetch_live_sha256(&version, asset_name); - return (version, hash); - } - - // 2. Snapshot file at the crate root (published-crate consumer). let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); - let snapshot = Path::new(&manifest_dir).join("bundled_cli_version.txt"); + + // 1. Snapshot file at the crate root (published-crate consumer, + // vendored-slot consumer). Combined version + per-asset hashes. + let snapshot = Path::new(&manifest_dir).join("cli-version.txt"); if snapshot.is_file() { let contents = std::fs::read_to_string(&snapshot) .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); @@ -96,7 +175,8 @@ fn resolve_version_and_hash(asset_name: &str) -> (String, String) { .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); } - // 3. Mono-repo lockfile — read version, fetch live SHA256SUMS. + // 2. Lockfile fallback (contributor build inside github/copilot-sdk) — + // read version, fetch live SHA256SUMS. let lockfile = Path::new(&manifest_dir) .join("..") .join("nodejs") @@ -108,20 +188,20 @@ fn resolve_version_and_hash(asset_name: &str) -> (String, String) { } panic!( - "Could not resolve the Copilot CLI version to bundle.\n\ + "Could not resolve the Copilot CLI version.\n\ Tried:\n\ - - COPILOT_CLI_VERSION env var (unset)\n\ - {} (missing)\n\ - {} (missing)\n\ - To opt out of bundling, set `default-features = false` on the github-copilot-sdk dependency.", + In a published crate or vendored slot, `cli-version.txt` should be present.\n\ + Inside the github/copilot-sdk repo, `../nodejs/package-lock.json` is the source.", snapshot.display(), lockfile.display(), ); } -/// Parse the `bundled_cli_version.txt` snapshot file. Format is one -/// `key=value` per line. The first line is `version=X.Y.Z`; subsequent lines -/// map asset filename to hex SHA-256. Blank lines and lines starting with `#` +/// Parse the `cli-version.txt` snapshot file. Format is one `key=value` per +/// line. The first non-comment line is `version=X.Y.Z`; subsequent lines map +/// asset filename to hex SHA-256. Blank lines and lines starting with `#` /// are skipped. fn parse_snapshot(contents: &str, asset_name: &str) -> Result<(String, String), String> { let mut version: Option = None; @@ -180,6 +260,7 @@ fn fetch_live_sha256(version: &str, asset_name: &str) -> String { find_sha256_for_asset(checksums_text, asset_name) } +#[derive(Clone, Copy)] struct Platform { asset_name: &'static str, binary_name: &'static str, @@ -218,6 +299,155 @@ fn target_platform() -> Option { } } +/// Write the single binary entry from `archive` to +/// `/` and return the resulting path. +/// Idempotent — returns the existing path if a previous build already +/// populated the target. +/// +/// Uses file-level staging + atomic rename so a concurrent reader during +/// a parallel `cargo build` race never observes a partially-written +/// binary. `fs::rename` for files is atomic on both Unix and Windows +/// (Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`); for +/// directories it is not, which is why we stage at file granularity. +fn extract_to_cache(archive: &[u8], install_dir: &Path, platform: Platform) -> PathBuf { + let final_path = install_dir.join(platform.binary_name); + + // Caller already gated on `final_path.is_file()`; this is a safety + // net for any future caller that forgets. + if final_path.is_file() { + return final_path; + } + + std::fs::create_dir_all(install_dir).unwrap_or_else(|e| { + panic!( + "failed to create install dir {}: {e}", + install_dir.display() + ) + }); + + let bytes = extract_binary_bytes(archive, platform); + + // Staging file is a sibling of the final binary so the rename stays + // on the same filesystem (cross-fs rename is not atomic). PID + nanos + // disambiguate concurrent builds racing on the same cache. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let staging_path = install_dir.join(format!( + ".{}.staging-{}-{nanos}", + platform.binary_name, + std::process::id(), + )); + + if let Err(e) = std::fs::write(&staging_path, &bytes) { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to write staging file {}: {e}", + staging_path.display() + ); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Err(e) = + std::fs::set_permissions(&staging_path, std::fs::Permissions::from_mode(0o755)) + { + let _ = std::fs::remove_file(&staging_path); + panic!("failed to chmod {}: {e}", staging_path.display()); + } + } + + // Atomic file-replace on both Unix and Windows. If a concurrent build + // already produced the same file the rename overwrites it; the bytes + // are SHA-verified-identical so replacement is safe. + if let Err(e) = std::fs::rename(&staging_path, &final_path) { + let _ = std::fs::remove_file(&staging_path); + panic!( + "failed to rename {} -> {}: {e}", + staging_path.display(), + final_path.display() + ); + } + + // Surface where the binary landed so contributors can find it. Quiet + // on the hot path: the caller's `is_file()` short-circuit (and the + // safety net at the top of this function) means this only fires on a + // true cache miss. + println!( + "cargo:warning=Extracted Copilot CLI to {}", + final_path.display() + ); + + final_path +} + +/// Replace characters outside `[a-zA-Z0-9._-]` with `_` so the version +/// string is always safe to use as a path component. Kept in sync with +/// `embeddedcli::sanitize_version` and `resolve::sanitize_version` so all +/// three resolve to the same cache directory for any given version. +fn sanitize_version(version: &str) -> String { + version + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, + _ => '_', + }) + .collect() +} + +/// Extract the single `binary_name` entry from the release archive. Reused +/// between embed mode's `verify_binary_present_in_archive` and the +/// `extract_to_cache` path used when `bundled-cli` is off. Panics if the +/// entry isn't found — callers have already invoked +/// `verify_binary_present_in_archive`. +fn extract_binary_bytes(archive: &[u8], platform: Platform) -> Vec { + if platform.asset_name.ends_with(".zip") { + let cursor = std::io::Cursor::new(archive); + let mut zip = zip::ZipArchive::new(cursor) + .unwrap_or_else(|e| panic!("failed to open zip archive: {e}")); + for i in 0..zip.len() { + let mut entry = zip + .by_index(i) + .unwrap_or_else(|e| panic!("failed to read zip entry {i}: {e}")); + let name = entry.name().to_string(); + if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) + { + let mut bytes = Vec::with_capacity(entry.size() as usize); + std::io::copy(&mut entry, &mut bytes) + .unwrap_or_else(|e| panic!("failed to read zip entry bytes: {e}")); + return bytes; + } + } + } else { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar + .entries() + .unwrap_or_else(|e| panic!("failed to read tar entries: {e}")) + { + let mut entry = entry.unwrap_or_else(|e| panic!("failed to read tar entry: {e}")); + let path = entry + .path() + .unwrap_or_else(|e| panic!("failed to read tar entry path: {e}")); + let name = path.to_string_lossy().into_owned(); + if name == platform.binary_name || name.ends_with(&format!("/{}", platform.binary_name)) + { + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .unwrap_or_else(|e| panic!("failed to read tar entry bytes: {e}")); + return bytes; + } + } + } + panic!( + "binary `{}` not found in archive `{}`", + platform.binary_name, platform.asset_name + ); +} + /// Read a file from the download cache, or download it (with retries) and save /// to cache. Verifies SHA-256 on every path. Evicts stale/corrupt cache entries /// automatically. Cache I/O failures are treated as cache misses — they never diff --git a/rust/scripts/snapshot-bundled-cli-version.sh b/rust/scripts/snapshot-bundled-cli-version.sh old mode 100644 new mode 100755 index b1eb1a2a1..7f78d529b --- a/rust/scripts/snapshot-bundled-cli-version.sh +++ b/rust/scripts/snapshot-bundled-cli-version.sh @@ -11,7 +11,7 @@ # authoritative per-platform hashes. # # Output: -# - bundled_cli_version.txt (in the rust crate root). Gitignored. +# - cli-version.txt (in the rust crate root). Gitignored. set -euo pipefail @@ -19,7 +19,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" RUST_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" REPO_ROOT="$(cd "${RUST_DIR}/.." && pwd)" LOCKFILE="${REPO_ROOT}/nodejs/package-lock.json" -OUTPUT="${RUST_DIR}/bundled_cli_version.txt" +OUTPUT="${RUST_DIR}/cli-version.txt" if [[ ! -f "${LOCKFILE}" ]]; then echo "error: ${LOCKFILE} not found" >&2 diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index e1eb147dc..504edbf67 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -4,7 +4,9 @@ //! //! build.rs downloads the platform's `copilot-{platform}.{tar.gz,zip}` //! archive from GitHub Releases, SHA-256 verifies it against the version -//! pinned in `bundled_cli_version.txt`, and embeds the **raw archive bytes** +//! pinned in `cli-version.txt` (or `../nodejs/package-lock.json` when +//! building inside the github/copilot-sdk repo itself), and embeds the +//! **raw archive bytes** //! into the consumer's compiled artifact via `include_bytes!()`. Extraction //! to a real on-disk path is deferred until the first call to //! [`path`] / [`install_at`] — at which point the bytes are part of the @@ -24,21 +26,39 @@ use tracing::{info, warn}; // When the `bundled-cli` cargo feature is enabled and the target platform is // supported, build.rs generates `bundled_cli.rs` exposing the raw archive -// bytes plus the version + binary-name constants the runtime install path -// consumes. +// bytes. The CLI version is exposed crate-wide via the +// `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` emit (see `build.rs`), and the +// binary name is OS-derived — so no other generated constants are needed. #[cfg(has_bundled_cli)] mod build_time { include!(concat!(env!("OUT_DIR"), "/bundled_cli.rs")); } +// Pinned at build time and consumed by both install paths (path/install_at). +// Sourced from the unconditional `COPILOT_SDK_CLI_VERSION` env emit in +// build.rs — the single source of truth for "what version did build.rs +// target", shared with the runtime resolver used when `bundled-cli` is off. +#[cfg(has_bundled_cli)] +const CLI_VERSION: &str = env!("COPILOT_SDK_CLI_VERSION"); + +// OS-derived; matches the release-archive entry name and the on-disk +// filename. No need to bake this — `cfg(windows)` reflects the target +// the runtime is running on, which by definition is the same target +// build.rs targeted. +#[cfg(all(has_bundled_cli, windows))] +const CLI_BINARY_NAME: &str = "copilot.exe"; +#[cfg(all(has_bundled_cli, not(windows)))] +const CLI_BINARY_NAME: &str = "copilot"; + +#[cfg(feature = "bundled-cli")] static INSTALLED_PATH: OnceLock> = OnceLock::new(); /// Returns the path to the installed CLI binary, lazily extracting the /// embedded archive on first call. /// /// On first call this extracts the embedded archive to -/// `/github-copilot-sdk-{version}/copilot[.exe]` and -/// returns the resulting path. The cache dir comes from +/// `/github-copilot-sdk/cli//copilot[.exe]` +/// and returns the resulting path. The cache dir comes from /// [`dirs::cache_dir()`] — `%LOCALAPPDATA%` on Windows, /// `~/Library/Caches/` on macOS, `$XDG_CACHE_HOME` (or `~/.cache/`) on /// Linux. Subsequent calls return the cached result. The extraction @@ -47,15 +67,16 @@ static INSTALLED_PATH: OnceLock> = OnceLock::new(); /// trusted mean no further hashing is needed. /// /// Returns `None` if no CLI was embedded at build time. +#[cfg(feature = "bundled-cli")] pub(crate) fn path() -> Option { INSTALLED_PATH .get_or_init(|| { #[cfg(has_bundled_cli)] { - let dir = default_install_dir(build_time::CLI_VERSION); + let dir = default_install_dir(CLI_VERSION); match install(&dir, build_time::CLI_ARCHIVE) { Ok(path) => { - info!(path = %path.display(), version = build_time::CLI_VERSION, "embedded CLI installed"); + info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed"); return Some(path); } Err(e) => { @@ -69,18 +90,19 @@ pub(crate) fn path() -> Option { } /// Install the embedded CLI binary into the given directory instead of the -/// default `/github-copilot-sdk-{version}/` location +/// default `/github-copilot-sdk/cli//` location /// (see [`path`] for the per-platform mapping). /// /// Idempotent: skips extraction if the target binary already exists. /// Returns `None` when the SDK was built without a bundled CLI. +#[cfg(feature = "bundled-cli")] #[allow(dead_code)] // Used by resolve.rs when ClientOptions::bundled_cli_extract_dir is set. pub(crate) fn install_at(extract_dir: &Path) -> Option { #[cfg(has_bundled_cli)] { match install(extract_dir, build_time::CLI_ARCHIVE) { Ok(path) => { - info!(path = %path.display(), version = build_time::CLI_VERSION, "embedded CLI installed"); + info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed"); return Some(path); } Err(e) => { @@ -98,10 +120,11 @@ pub(crate) fn install_at(extract_dir: &Path) -> Option { #[cfg(has_bundled_cli)] fn default_install_dir(version: &str) -> PathBuf { let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); + let root = cache.join("github-copilot-sdk").join("cli"); if version.is_empty() { - cache.join("github-copilot-sdk") + root.join("unversioned") } else { - cache.join(format!("github-copilot-sdk-{}", sanitize_version(version))) + root.join(sanitize_version(version)) } } @@ -111,7 +134,7 @@ fn install(install_dir: &Path, archive: &[u8]) -> Result Result for CliProgram { /// Options for starting a [`Client`]. /// /// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`] -/// uses the bundled Copilot CLI that was embedded at build time (via the -/// default `bundled-cli` cargo feature). +/// uses `COPILOT_CLI_PATH` when set to a real file. Otherwise it uses the +/// bundled Copilot CLI when the default `bundled-cli` cargo feature is enabled, +/// or the build-time extracted dev-cache CLI when that feature is disabled. /// /// Set `program` to [`CliProgram::Path`] to use an explicit binary instead. -/// This is the required path if you've opted out of bundling via -/// `default-features = false`. +/// This skips auto-resolution entirely. #[non_exhaustive] pub struct ClientOptions { /// How to locate the CLI binary. @@ -416,15 +417,20 @@ pub struct ClientOptions { /// first use. /// /// When `None` (the default), the SDK extracts the embedded CLI to - /// `/github-copilot-sdk-{version}/copilot[.exe]`, + /// `/github-copilot-sdk/cli//copilot[.exe]`, /// where the cache dir is [`dirs::cache_dir()`] — /// `%LOCALAPPDATA%` on Windows, `~/Library/Caches/` on macOS, /// `$XDG_CACHE_HOME` (or `~/.cache/`) on Linux. Use this knob to /// redirect the extraction (e.g. to a session-scoped temp directory in /// CI runners) without changing the global cache layout. /// - /// Ignored when the SDK was built without a bundled CLI (i.e. with - /// `default-features = false` to disable the `bundled-cli` feature). + /// Only applies when the `bundled-cli` cargo feature is on (the + /// default). With `bundled-cli` disabled (`default-features = false`) + /// there is no archive to re-extract at runtime — the binary lives + /// at a build-time-known conventional path. To relocate that + /// extraction, set `COPILOT_CLI_EXTRACT_DIR` (honored symmetrically + /// at build and runtime); to point the runtime at a different + /// binary altogether, use [`CliProgram::Path`] or `COPILOT_CLI_PATH`. pub bundled_cli_extract_dir: Option, } @@ -827,6 +833,13 @@ impl ClientOptions { /// Override the directory where the bundled CLI binary is extracted on /// first use. See [`Self::bundled_cli_extract_dir`]. + /// + /// Only applies when the `bundled-cli` cargo feature is on. With + /// `bundled-cli` disabled (`default-features = false`), set + /// `COPILOT_CLI_EXTRACT_DIR` to relocate the build-time extraction + /// (honored symmetrically at build and runtime), or use + /// [`CliProgram::Path`] / `COPILOT_CLI_PATH` to point at a different + /// binary at runtime. pub fn with_bundled_cli_extract_dir(mut self, dir: impl Into) -> Self { self.bundled_cli_extract_dir = Some(dir.into()); self diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs index 7a1b29a04..cd813407a 100644 --- a/rust/src/resolve.rs +++ b/rust/src/resolve.rs @@ -1,16 +1,17 @@ //! Internal resolution of the GitHub Copilot CLI binary. //! -//! Resolution order (matches the .NET and TypeScript SDKs): +//! Resolution order: //! //! 1. An explicit path supplied by the application via //! [`CliProgram::Path`](crate::CliProgram::Path). //! 2. The `COPILOT_CLI_PATH` environment variable. -//! 3. The bundled CLI embedded in this crate at build time (gated on the -//! default `bundled-cli` cargo feature). +//! 3. The bundled CLI embedded in this crate at build time (when the +//! `bundled-cli` cargo feature is on, the default). +//! 4. The build-time-extracted CLI in the per-user cache (when +//! `bundled-cli` is off). //! //! There is no PATH scanning and no walking of standard install locations. -//! If you've opted out of bundling (via `default-features = false`) and -//! neither `CliProgram::Path` nor `COPILOT_CLI_PATH` is set, +//! If none of the above resolves to a real file, //! [`Client::start`](crate::Client::start) returns //! [`Error::BinaryNotFound`](crate::Error::BinaryNotFound). @@ -24,7 +25,14 @@ use crate::Error; /// Resolve the CLI binary, optionally overriding the directory the bundled /// CLI is extracted to. Called by `Client::start` to thread /// `ClientOptions::bundled_cli_extract_dir` through to -/// `embeddedcli::install_at`. +/// `embeddedcli::install_at`. `extract_dir` only applies when the +/// `bundled-cli` feature is on — with it off the binary lives at a +/// build-time-known conventional location and `extract_dir` is ignored +/// (there's no archive to re-extract; pointing the lookup elsewhere +/// would be exactly equivalent to setting `CliProgram::Path`). Set +/// `COPILOT_CLI_EXTRACT_DIR` at build time to relocate that extraction; +/// the same env var is honored at runtime to find binaries written +/// under it. pub(crate) fn copilot_binary_with_extract_dir( extract_dir: Option<&Path>, ) -> Result { @@ -35,16 +43,27 @@ pub(crate) fn copilot_binary_with_extract_dir( } warn!( path = %candidate.display(), - "COPILOT_CLI_PATH is set but does not point to a file; falling back to bundled CLI" + "COPILOT_CLI_PATH is set but does not point to a file; falling back" ); } - let bundled = match extract_dir { - Some(dir) => crate::embeddedcli::install_at(dir), - None => crate::embeddedcli::path(), - }; - if let Some(path) = bundled { - return Ok(path); + #[cfg(feature = "bundled-cli")] + { + let bundled = match extract_dir { + Some(dir) => crate::embeddedcli::install_at(dir), + None => crate::embeddedcli::path(), + }; + if let Some(path) = bundled { + return Ok(path); + } + } + + #[cfg(not(feature = "bundled-cli"))] + { + let _ = extract_dir; + if let Some(path) = extracted_cli_path() { + return Ok(path); + } } Err(Error::BinaryNotFound { @@ -55,3 +74,68 @@ pub(crate) fn copilot_binary_with_extract_dir( `CliProgram::Path(...)` on `ClientOptions::program`.", }) } + +/// Path to the CLI extracted into the per-user cache by `build.rs` when +/// `bundled-cli` is disabled. Returns `None` if the cached file is missing +/// (e.g. the user deleted the cache after building, or built with +/// `COPILOT_SKIP_CLI_DOWNLOAD`). +/// +/// The path is recomputed from the build-time-baked +/// `COPILOT_SDK_CLI_VERSION`, the OS-derived binary name, and the +/// optional `COPILOT_CLI_EXTRACT_DIR` env var. This must match +/// `build.rs::extracted_install_dir` exactly — both sides implement the +/// same convention. We deliberately don't bake the resolved path into +/// the crate at build time: an absolute path leaks the build machine's +/// `$HOME` / `$LOCALAPPDATA` into the artifact, breaks sccache across +/// machines, and prevents copying `target/` between hosts. +#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +fn extracted_cli_path() -> Option { + let version = env!("COPILOT_SDK_CLI_VERSION"); + let binary = if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }; + + let dir = match env::var_os("COPILOT_CLI_EXTRACT_DIR") { + Some(custom) => PathBuf::from(custom), + None => dirs::cache_dir() + .unwrap_or_else(env::temp_dir) + .join("github-copilot-sdk") + .join("cli") + .join(sanitize_version(version)), + }; + + let path = dir.join(binary); + if path.is_file() { + return Some(path); + } + warn!( + path = %path.display(), + "expected build-time-extracted CLI is missing; rebuild the crate or set COPILOT_CLI_PATH" + ); + None +} + +/// `has_extracted_cli` is absent when the target is unsupported or the +/// build opted out via `COPILOT_SKIP_CLI_DOWNLOAD`. In both cases there's +/// no binary to look up, so the resolver returns `None` immediately. +#[cfg(all(not(feature = "bundled-cli"), not(has_extracted_cli)))] +fn extracted_cli_path() -> Option { + None +} + +/// Replace characters outside `[a-zA-Z0-9._-]` with `_`. Kept in sync +/// with `build.rs::sanitize_version` and `embeddedcli::sanitize_version` +/// so all three resolve to the same cache directory for any given +/// version. +#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +fn sanitize_version(version: &str) -> String { + version + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, + _ => '_', + }) + .collect() +} diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs new file mode 100644 index 000000000..72bebdcb2 --- /dev/null +++ b/rust/tests/cli_resolution_test.rs @@ -0,0 +1,226 @@ +//! Tests for the build-time and runtime CLI provisioning path. +//! +//! Covers the `COPILOT_CLI_PATH` env override, the build-time-extracted +//! binary used when `bundled-cli` is off, and the embed-mode lazy +//! extraction. Mutating env vars is process-global, so all such tests +//! use `serial_test` to avoid races with each other (and with the e2e +//! tests which also read them). + +use std::path::PathBuf; + +use github_copilot_sdk::{CliProgram, Client, ClientOptions, Error}; +use serial_test::serial; + +fn unset_env(key: &str) { + // SAFETY: these tests are serialized with #[serial(copilot_cli_path)] + // so no other test in this binary mutates COPILOT_CLI_PATH while + // we hold the lock. POSIX `setenv`/`unsetenv` are generally + // thread-safe on modern platforms, and we use `current_thread` + // tokio runtimes to avoid concurrent reads from worker threads. + // This doesn't satisfy the strict Rust 2024 safety contract + // (other tests in the binary may read env vars), but the practical + // race window is negligible. + unsafe { std::env::remove_var(key) }; +} + +fn set_env(key: &str, value: &str) { + // SAFETY: see `unset_env`. + unsafe { std::env::set_var(key, value) }; +} + +/// COPILOT_CLI_PATH wins when it points at a real file, regardless of +/// build mode. +#[tokio::test(flavor = "current_thread")] +#[serial(copilot_cli_path)] +async fn env_override_resolves_to_pointed_file() { + let tmp = tempfile::NamedTempFile::new().expect("create tempfile"); + // resolve.rs only checks `is_file()` for COPILOT_CLI_PATH, so a plain + // tempfile is sufficient — we don't need it to be executable. The + // downstream `Client::start` call will fail to exec an empty file, + // which we tolerate below; we just need to observe that the resolver + // returned the env-override path rather than `BinaryNotFound`. + let path = tmp.path().to_path_buf(); + + set_env( + "COPILOT_CLI_PATH", + path.to_str().expect("utf-8 tempfile path"), + ); + let opts = ClientOptions::default().with_program(CliProgram::Resolve); + + // `Client::start` reads the env var via resolve.rs. We don't want to + // actually launch a subprocess against our empty temp file, so go + // through the public API just far enough to observe the resolution. + // The easiest observable behavior is that `Client::start` doesn't + // return `Error::BinaryNotFound` — it'll fail later trying to exec + // the empty file, which we tolerate. + let result = Client::start(opts).await; + unset_env("COPILOT_CLI_PATH"); + + match result { + Ok(_) => {} + Err(e) => { + let msg = format!("{e}"); + assert!( + !msg.contains("not found"), + "expected COPILOT_CLI_PATH to win; got {msg}" + ); + } + } + + // Drop tmp explicitly so the file outlives the assertions above. + drop(tmp); + let _ = path; +} + +/// A stale (non-existent) COPILOT_CLI_PATH falls through to the next +/// resolution source (embed or dev) rather than failing outright. +#[tokio::test(flavor = "current_thread")] +#[serial(copilot_cli_path)] +async fn stale_env_override_falls_through() { + set_env("COPILOT_CLI_PATH", "/definitely/does/not/exist/copilot"); + let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let result = Client::start(opts).await; + unset_env("COPILOT_CLI_PATH"); + + // In a normally-configured build (either `bundled-cli` on or off) + // the resolver should find a binary via the next source. Failing + // here would mean fallthrough is broken. + if let Err(e) = &result { + assert!( + !matches!(e, Error::BinaryNotFound { .. }), + "stale COPILOT_CLI_PATH should fall through; got BinaryNotFound: {e}" + ); + } +} + +/// With `bundled-cli` off, `build.rs` extracts the binary into the +/// per-user cache and the runtime resolver recomputes its location from +/// `COPILOT_SDK_CLI_VERSION` + the OS-derived binary name. This test +/// mirrors that convention and asserts the file is on disk where the +/// resolver expects to find it. +#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +#[test] +fn extracted_binary_present_at_conventional_path() { + let version = env!("COPILOT_SDK_CLI_VERSION"); + let binary = if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }; + let sanitized = sanitize_version_for_test(version); + let path = dirs::cache_dir() + .expect("platform cache dir") + .join("github-copilot-sdk") + .join("cli") + .join(sanitized) + .join(binary); + assert!( + path.is_file(), + "expected build.rs to extract the CLI to {} (`bundled-cli` off)", + path.display() + ); +} + +#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +fn sanitize_version_for_test(version: &str) -> String { + version + .chars() + .map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c, + _ => '_', + }) + .collect() +} + +/// With `bundled-cli` off, the resolver locates the build-time-extracted +/// binary without any runtime configuration. Observed via +/// `Client::start`: any outcome other than `BinaryNotFound` means the +/// resolver succeeded. +#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +#[tokio::test(flavor = "current_thread")] +#[serial(copilot_cli_path)] +async fn unbundled_resolver_finds_extracted_binary() { + unset_env("COPILOT_CLI_PATH"); + unset_env("COPILOT_CLI_EXTRACT_DIR"); + + let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let result = Client::start(opts).await; + if let Err(e) = result { + assert!( + !matches!(e, Error::BinaryNotFound { .. }), + "resolver returned BinaryNotFound with `bundled-cli` off: {e}" + ); + } +} + +/// With `bundled-cli` off, `COPILOT_CLI_EXTRACT_DIR` set at runtime +/// redirects the resolver to look directly under the named directory +/// (no per-version subdir, matching the build-time write semantics). +/// We place a fake `copilot[.exe]` there and assert the resolver picks +/// it up — failing here means the build-time / runtime convention has +/// drifted. +#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +#[tokio::test(flavor = "current_thread")] +#[serial(copilot_cli_path)] +async fn extract_dir_runtime_override_is_honored() { + let tmp = tempfile::tempdir().expect("create tempdir"); + let binary = if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }; + let fake = tmp.path().join(binary); + std::fs::write(&fake, b"").expect("write fake binary"); + + unset_env("COPILOT_CLI_PATH"); + set_env( + "COPILOT_CLI_EXTRACT_DIR", + tmp.path().to_str().expect("utf-8 tempdir path"), + ); + + let opts = ClientOptions::default().with_program(CliProgram::Resolve); + let result = Client::start(opts).await; + + unset_env("COPILOT_CLI_EXTRACT_DIR"); + + if let Err(e) = result { + assert!( + !matches!(e, Error::BinaryNotFound { .. }), + "EXTRACT_DIR-redirected resolver returned BinaryNotFound: {e}" + ); + } + + drop(tmp); + let _ = fake; +} + +/// Build-time version pin: `cli-version.txt` (when present) must be a +/// combined snapshot — a `version=X.Y.Z` line plus per-asset hash lines. +/// When absent, build.rs falls through to `../nodejs/package-lock.json` — +/// both are accepted, this test only checks the pin file's format if it's +/// there. +#[test] +fn pin_file_when_present_is_well_formed() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let pin = PathBuf::from(manifest_dir).join("cli-version.txt"); + if !pin.is_file() { + // Contributor build path — no assertion needed. + return; + } + let contents = std::fs::read_to_string(&pin).expect("read cli-version.txt"); + let mut saw_version = false; + for raw in contents.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line + .split_once('=') + .unwrap_or_else(|| panic!("malformed line: {raw:?}")); + assert!(!value.trim().is_empty(), "empty value for key {key:?}"); + if key.trim() == "version" { + saw_version = true; + } + } + assert!(saw_version, "cli-version.txt missing `version=` line"); +}