diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e78dbbda1..0c192b50c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -167,6 +167,14 @@ jobs: workspaces: "rust" - name: Set version 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 + run: | + if [[ ! -f bundled_cli_version.txt ]]; then + echo "::error::bundled_cli_version.txt was not generated. The Snapshot step must run before packaging." + exit 1 + fi - name: Package (dry run) run: cargo publish --dry-run --allow-dirty - name: Upload artifact diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 393db2d18..952294e13 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -101,15 +101,17 @@ jobs: RUST_E2E_CONCURRENCY: 4 COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }} - run: cargo test --features test-support -- --test-threads=4 --nocapture - - # Validates the `embedded-cli` build path on all three supported - # platforms. This is the only place `build.rs` actually runs (the - # default `cargo test` job above has `COPILOT_CLI_VERSION` unset, so - # `build.rs` returns immediately). Catches regressions in the - # download / verify / extract / embed pipeline before they ship to - # crates.io and before bundling consumers (e.g. github-app's - # bundled-CLI release pipeline) hit them downstream. + # `--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. + run: cargo test --no-default-features --features test-support -- --test-threads=4 --nocapture + + # Validates the bundled-CLI build path on all three supported + # platforms. While the regular `cargo test` job above also exercises + # build.rs (bundling is on by default now), this matrix job is the + # dedicated cross-platform smoke test for the download / verify / + # extract / embed pipeline. Catches regressions before they ship to + # crates.io and before bundling consumers hit them downstream. bundle: name: "Rust SDK Bundled CLI Build" if: github.event.repository.fork == false @@ -144,23 +146,21 @@ jobs: id: cli-version working-directory: ./nodejs run: | - version=$(node -p "require('./package.json').dependencies['@github/copilot'].replace(/^[\^~]/, '')") + version=$(node -p "require('./package-lock.json').packages['node_modules/@github/copilot'].version") echo "version=$version" >> "$GITHUB_OUTPUT" echo "Pinned CLI version: $version" # Cache the downloaded archive across runs so we don't refetch - # ~130 MB on every CI invocation. Keyed by OS + CLI version; on - # cache miss the bundle job exercises the full ureq download + - # SHA-256 + retry path, which is exactly the regression surface - # we want validated. + # ~130 MB on every CI invocation. Keyed by OS + CLI version so old + # archives drop out when the pinned version bumps, keeping the + # cache bounded. - 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 build --features embedded-cli + - name: cargo build (bundled-cli is the default feature) env: - COPILOT_CLI_VERSION: ${{ steps.cli-version.outputs.version }} BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache - run: cargo build --features embedded-cli + run: cargo build diff --git a/rust/.gitignore b/rust/.gitignore index c17da7f58..03bbce707 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock.bak +bundled_cli_version.txt diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9c4790a6e..56e658ad1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -83,8 +83,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -325,18 +323,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -345,7 +331,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] @@ -376,7 +362,6 @@ dependencies = [ "ureq", "uuid", "zip", - "zstd", ] [[package]] @@ -536,16 +521,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -731,12 +706,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1810,31 +1779,3 @@ dependencies = [ "log", "simd-adler32", ] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b2a2b4f54..1e02f267c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,21 +11,23 @@ homepage = "https://github.com/github/copilot-sdk" documentation = "https://docs.rs/github-copilot-sdk" readme = "README.md" license = "MIT" -exclude = [ - "RELEASING.md", - "rust-toolchain.toml", - ".rustfmt.toml", - ".rustfmt.nightly.toml", - "clippy.toml", - ".gitignore", +include = [ + "src/**/*", + "examples/**/*", + "tests/**/*", + "build.rs", + "Cargo.toml", + "README.md", + "LICENSE", + "bundled_cli_version.txt", ] [lib] name = "github_copilot_sdk" [features] -default = [] -embedded-cli = ["dep:sha2", "dep:zstd"] +default = ["bundled-cli"] +bundled-cli = ["dep:dirs", "dep:tar", "dep:flate2", "dep:zip"] derive = ["dep:schemars"] test-support = [] @@ -46,22 +48,25 @@ 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 = "5" +dirs = { version = "5", optional = true } parking_lot = "0.12" regex = "1" -sha2 = { version = "0.10", optional = true } getrandom = "0.2" uuid = { version = "1", default-features = false, features = ["v4"] } -zstd = { version = "0.13", optional = true } + +[target.'cfg(windows)'.dependencies] +zip = { version = "2", default-features = false, features = ["deflate"], optional = true } + +[target.'cfg(not(windows))'.dependencies] +flate2 = { version = "1", optional = true } +tar = { version = "0.4", optional = true } [dev-dependencies] rusqlite = { version = "0.35", features = ["bundled"] } schemars = "1" serial_test = "3" tempfile = "3" -sha2 = "0.10" tokio = { version = "1", features = ["rt-multi-thread"] } -zstd = "0.13" [build-dependencies] flate2 = "1" @@ -69,4 +74,3 @@ sha2 = "0.10" tar = "0.4" ureq = { version = "2", default-features = false, features = ["tls"] } zip = { version = "2", default-features = false, features = ["deflate"] } -zstd = "0.13" diff --git a/rust/README.md b/rust/README.md index 2de08f35d..4ed046230 100644 --- a/rust/README.md +++ b/rust/README.md @@ -78,7 +78,7 @@ client.stop().await?; | `extra_args` | `Vec` | Extra CLI flags | | `transport` | `Transport` | `Stdio` (default), `Tcp { port }`, or `External { host, port }` | -With the default `CliProgram::Resolve`, `Client::start()` automatically resolves the binary via `github_copilot_sdk::resolve::copilot_binary()` — checking `COPILOT_CLI_PATH`, the [embedded CLI](#embedded-cli), and then the system PATH. Use `CliProgram::Path(path)` to skip resolution. +With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in this order: an explicit `CliProgram::Path(path)`, the `COPILOT_CLI_PATH` env var, then the bundled CLI that was embedded at build time. There is no PATH scanning — if you've opted out of bundling (`default-features = false`) you must supply either `CliProgram::Path` or `COPILOT_CLI_PATH`. ### Session @@ -740,34 +740,56 @@ none of them are scheduled for removal. | `transforms.rs` | `SystemMessageTransform` trait, section-level system message customization | | `tool.rs` | `ToolHandler` trait, `define_tool`, `schema_for::()` (with `derive` feature) | | `types.rs` | CLI protocol types (`SessionId`, `SessionEvent`, `SessionConfig`, `Tool`, etc.) | -| `resolve.rs` | Binary resolution (`copilot_binary`, `node_binary`, `extended_path`) | -| `embeddedcli.rs` | Embedded CLI extraction (`embedded-cli` feature) | +| `resolve.rs` | Bundled-CLI resolution (`copilot_binary`) | +| `embeddedcli.rs` | Embedded CLI extraction (gated on the default `bundled-cli` feature) | | `router.rs` | Internal per-session event demux | | `jsonrpc.rs` | Internal Content-Length framed JSON-RPC transport | ## Embedded CLI -By default, `copilot_binary()` searches `COPILOT_CLI_PATH`, the system PATH, and common install locations. To **ship with a specific CLI version** embedded in the binary, set `COPILOT_CLI_VERSION` at build time: +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. -```bash -COPILOT_CLI_VERSION=1.0.15 cargo build +To opt out (e.g. for binary-size-sensitive consumers, or environments that provide the CLI via PATH), set `default-features = false`: + +```toml +github-copilot-sdk = { version = "0.1", default-features = false } ``` ### How it works -1. **Build time:** The SDK's `build.rs` detects `COPILOT_CLI_VERSION`, 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 archive's SHA-256 against the release's `SHA256SUMS.txt`, extracts the `copilot` binary, compresses it with zstd, and embeds via `include_bytes!()`. No extra steps or tools needed — just the env var. +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. + +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`. + + 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!()`. -2. **Runtime:** On the first call to `github_copilot_sdk::resolve::copilot_binary()`, the embedded binary is lazily extracted to `~/.cache/github-copilot-sdk-{version}/copilot` (or `copilot.exe` on Windows), SHA-256 verified, and cached. Subsequent calls return the cached path. +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. -3. **Dev builds:** Without the env var, `build.rs` does nothing. The binary is resolved from PATH as usual — zero friction. +### 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.): + +```rust,ignore +use std::path::PathBuf; +use github_copilot_sdk::{Client, ClientOptions}; + +let options = ClientOptions::new() + .with_bundled_cli_extract_dir(PathBuf::from("/var/run/my-app/copilot")); +let client = Client::start(options).await?; +``` ### Resolution priority `copilot_binary()` checks these sources in order: -1. `COPILOT_CLI_PATH` environment variable -2. Embedded CLI (build-time, via `COPILOT_CLI_VERSION`) -3. System PATH + common install locations +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) + +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`. ### Platforms @@ -775,26 +797,21 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64` ## Features -No features are enabled by default — the bare SDK resolves the CLI from `COPILOT_CLI_PATH` or the system PATH without pulling in additional feature-gated dependencies. - | Feature | Default | Description | | -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `embedded-cli` | — | Build-time CLI embedding via `COPILOT_CLI_VERSION` (adds `sha2`, `zstd`). Enable when you need to ship a self-contained binary with a pinned CLI version. | +| `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`). | | `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). Enable when defining [tool parameters](#tool-registration). | ```toml # These examples use registry syntax for illustration; until the crate is # published, use a path or git dependency instead. -# Minimal — resolve CLI from PATH +# Default — bundles the Copilot CLI in your binary. github-copilot-sdk = "0.1" -# Ship a pinned CLI version in your binary -github-copilot-sdk = { version = "0.1", features = ["embedded-cli"] } +# Opt out of bundling — resolve CLI from COPILOT_CLI_PATH or system PATH instead. +github-copilot-sdk = { version = "0.1", default-features = false } -# Derive JSON Schema for tool parameters +# Derive JSON Schema for tool parameters (adds to default bundled-cli). github-copilot-sdk = { version = "0.1", features = ["derive"] } - -# Both -github-copilot-sdk = { version = "0.1", features = ["embedded-cli", "derive"] } ``` diff --git a/rust/build.rs b/rust/build.rs index 22463c9a9..c64dbff9b 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -9,75 +9,66 @@ fn main() { println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); - let Ok(version) = std::env::var("COPILOT_CLI_VERSION") else { + // 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() { 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=COPILOT_CLI_VERSION set but unsupported target platform, skipping CLI bundling" - ); + println!("cargo:warning=Unsupported target platform for Copilot CLI bundling — skipping"); return; }; 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) + let (version, expected_hash) = resolve_version_and_hash(platform.asset_name); + 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); - // Download SHA256SUMS and find the expected hash for our platform's tarball. let asset_name = platform.asset_name; - println!("cargo:warning=Bundling GitHub Copilot CLI v{version} ({asset_name})"); - // Download checksums and find the expected hash for our platform's archive. - let checksums_url = format!("{base_url}/SHA256SUMS.txt"); - let checksums = download_with_retry(&checksums_url); - let checksums_text = - std::str::from_utf8(&checksums).expect("checksums file is not valid UTF-8"); - let expected_hash = find_sha256_for_asset(checksums_text, asset_name); - - // Use a versioned cache key since copilot asset names don't include the version. + // 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 integrity. + // 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, ); - println!("cargo:warning=SHA-256 verified ({expected_hash})"); - - // Extract the binary from the archive. - let binary = extract_binary(&archive, platform.binary_name, platform.is_zip); - println!( - "cargo:warning=Extracted {} ({} bytes)", - platform.binary_name, - binary.len() - ); - // Compress and embed. - let hash = sha256(&binary); - let compressed = zstd::encode_all(&binary[..], 19).expect("zstd compression failed"); - println!( - "cargo:warning=Compressed to {} bytes ({:.1}%)", - compressed.len(), - compressed.len() as f64 / binary.len() as f64 * 100.0 - ); + // 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); - std::fs::write(out.join("copilot_cli.zst"), &compressed) - .expect("failed to write copilot_cli.zst"); + std::fs::write(out.join("copilot_cli.archive"), &archive) + .expect("failed to write copilot_cli.archive"); - let hash_tokens: Vec = hash.iter().map(|b| format!("0x{b:02x}")).collect(); let generated = format!( r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. -pub(super) static CLI_BYTES: &[u8] = include_bytes!("copilot_cli.zst"); -pub(super) static CLI_HASH: [u8; 32] = [{}]; +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}"; "#, - hash_tokens.join(", ") + binary_name = platform.binary_name, ); std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); @@ -85,10 +76,113 @@ pub(super) static CLI_VERSION: &str = "{version}"; 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. +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"); + if snapshot.is_file() { + let contents = std::fs::read_to_string(&snapshot) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); + return parse_snapshot(&contents, asset_name) + .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); + } + + // 3. Mono-repo lockfile — read version, fetch live SHA256SUMS. + let lockfile = Path::new(&manifest_dir) + .join("..") + .join("nodejs") + .join("package-lock.json"); + if lockfile.is_file() { + let version = read_version_from_package_lock(&lockfile); + let hash = fetch_live_sha256(&version, asset_name); + return (version, hash); + } + + panic!( + "Could not resolve the Copilot CLI version to bundle.\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.", + 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 `#` +/// are skipped. +fn parse_snapshot(contents: &str, asset_name: &str) -> Result<(String, String), String> { + let mut version: Option = None; + let mut hash: Option = None; + for (line_no, raw) in contents.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line + .split_once('=') + .ok_or_else(|| format!("line {}: expected `key=value`, got `{raw}`", line_no + 1))?; + match key.trim() { + "version" => version = Some(value.trim().to_string()), + k if k == asset_name => hash = Some(value.trim().to_string()), + _ => {} + } + } + let version = version.ok_or("missing `version=` line")?; + let hash = hash.ok_or_else(|| format!("missing hash for asset `{asset_name}`"))?; + Ok((version, hash)) +} + +/// Read the `@github/copilot` version from `nodejs/package-lock.json`. +fn read_version_from_package_lock(path: &Path) -> String { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + // Minimal JSON walk: find `"node_modules/@github/copilot"` object and + // its `"version"` field. Full JSON parsing keeps build.rs dep-light by + // using a regex; the file is generated by npm and we're matching an + // exact key path. + let key = "\"node_modules/@github/copilot\""; + let key_pos = contents + .find(key) + .unwrap_or_else(|| panic!("{} does not contain {key}", path.display())); + let after_key = &contents[key_pos + key.len()..]; + let version_key = "\"version\""; + let v_pos = after_key + .find(version_key) + .unwrap_or_else(|| panic!("no `version` field found near {key} in {}", path.display())); + let after_v = &after_key[v_pos + version_key.len()..]; + let q1 = after_v.find('"').expect("malformed version"); + let after_q1 = &after_v[q1 + 1..]; + let q2 = after_q1.find('"').expect("malformed version"); + after_q1[..q2].to_string() +} + +/// Fetch the live `SHA256SUMS.txt` for the given version from GitHub Releases +/// and pluck out the entry for `asset_name`. +fn fetch_live_sha256(version: &str, asset_name: &str) -> String { + let base_url = format!("https://github.com/github/copilot-cli/releases/download/v{version}"); + let checksums_url = format!("{base_url}/SHA256SUMS.txt"); + let checksums = download_with_retry(&checksums_url); + let checksums_text = + std::str::from_utf8(&checksums).expect("checksums file is not valid UTF-8"); + find_sha256_for_asset(checksums_text, asset_name) +} + struct Platform { asset_name: &'static str, binary_name: &'static str, - is_zip: bool, } fn target_platform() -> Option { @@ -99,32 +193,26 @@ fn target_platform() -> Option { ("macos", "aarch64") => Some(Platform { asset_name: "copilot-darwin-arm64.tar.gz", binary_name: "copilot", - is_zip: false, }), ("macos", "x86_64") => Some(Platform { asset_name: "copilot-darwin-x64.tar.gz", binary_name: "copilot", - is_zip: false, }), ("linux", "x86_64") => Some(Platform { asset_name: "copilot-linux-x64.tar.gz", binary_name: "copilot", - is_zip: false, }), ("linux", "aarch64") => Some(Platform { asset_name: "copilot-linux-arm64.tar.gz", binary_name: "copilot", - is_zip: false, }), ("windows", "x86_64") => Some(Platform { asset_name: "copilot-win32-x64.zip", binary_name: "copilot.exe", - is_zip: true, }), ("windows", "aarch64") => Some(Platform { asset_name: "copilot-win32-arm64.zip", binary_name: "copilot.exe", - is_zip: true, }), _ => None, } @@ -145,10 +233,7 @@ fn cached_download( if cached_path.is_file() { match std::fs::read(&cached_path) { Ok(data) if hex_sha256(&data) == expected_hash => { - println!( - "cargo:warning=Using cached archive: {}", - cached_path.display() - ); + // Silent cache hit — nothing to surface. return data; } Ok(_) => { @@ -165,6 +250,7 @@ fn cached_download( } } + println!("cargo:warning=Downloading {url}"); let data = download_with_retry(url); let actual_hash = hex_sha256(&data); if actual_hash != expected_hash { @@ -182,13 +268,12 @@ fn cached_download( ); } else { let cached_path = dir.join(cache_key); + println!("cargo:warning=Caching archive at {}", cached_path.display()); if let Err(e) = std::fs::write(&cached_path, &data) { println!( "cargo:warning=Failed to write cache file {}: {e}", cached_path.display() ); - } else { - println!("cargo:warning=Cached archive to: {}", cached_path.display()); } } } @@ -278,61 +363,63 @@ fn find_sha256_for_asset(sums: &str, asset_name: &str) -> String { panic!("SHA256SUMS.txt does not contain an entry for {asset_name}"); } -fn extract_binary(archive_bytes: &[u8], binary_name: &str, is_zip: bool) -> Vec { - if is_zip { - extract_from_zip(archive_bytes, binary_name) +fn sha256(data: &[u8]) -> [u8; 32] { + let mut hasher = sha2::Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +/// Walks the downloaded archive at build time to confirm an entry matching +/// `binary_name` exists. Panics with a clear message if not — defends against +/// silent breakage if the upstream archive layout ever changes. +fn verify_binary_present_in_archive(archive: &[u8], binary_name: &str, asset_name: &str) { + let found = if asset_name.ends_with(".zip") { + archive_contains_zip_entry(archive, binary_name) } else { - extract_from_tarball(archive_bytes, binary_name) + archive_contains_tar_entry(archive, binary_name) + }; + if !found { + panic!( + "Copilot CLI archive `{asset_name}` does not contain an entry named `{binary_name}`. \ + The upstream archive layout may have changed; runtime extraction would fail. \ + Update `verify_binary_present_in_archive` in build.rs and the matching `extract_binary` in src/embeddedcli.rs." + ); } } -fn extract_from_tarball(tarball: &[u8], binary_name: &str) -> Vec { - let gz = flate2::read::GzDecoder::new(tarball); +fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { + let gz = flate2::read::GzDecoder::new(targz); let mut archive = tar::Archive::new(gz); - - for entry in archive.entries().expect("failed to read tarball entries") { - let mut entry = entry.expect("failed to read tarball entry"); - let path = entry - .path() - .expect("entry has no path") - .to_string_lossy() - .to_string(); - if path == binary_name || path.ends_with(&format!("/{binary_name}")) { - let mut bytes = Vec::new(); - entry - .read_to_end(&mut bytes) - .expect("failed to read binary from tarball"); - return bytes; + let Ok(entries) = archive.entries() else { + return false; + }; + for entry in entries.flatten() { + let Ok(path) = entry.path() else { + continue; + }; + let name = path.to_string_lossy(); + if name == binary_name || name.ends_with(&format!("/{binary_name}")) { + return true; } } - - panic!("'{binary_name}' not found in tarball"); + false } -fn extract_from_zip(zip_bytes: &[u8], binary_name: &str) -> Vec { - // Minimal zip extraction — find the binary by name. - // The Windows assets are .zip files with just copilot.exe at the root. +fn archive_contains_zip_entry(zip_bytes: &[u8], binary_name: &str) -> bool { let cursor = std::io::Cursor::new(zip_bytes); - let mut archive = zip::ZipArchive::new(cursor).expect("failed to read zip archive"); - + let Ok(mut archive) = zip::ZipArchive::new(cursor) else { + return false; + }; for i in 0..archive.len() { - let mut file = archive.by_index(i).expect("failed to read zip entry"); - let name = file.name().to_string(); + let Ok(entry) = archive.by_index(i) else { + continue; + }; + let name = entry.name(); if name == binary_name || name.ends_with(&format!("/{binary_name}")) { - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .expect("failed to read binary from zip"); - return bytes; + return true; } } - - panic!("'{binary_name}' not found in zip"); -} - -fn sha256(data: &[u8]) -> [u8; 32] { - let mut hasher = sha2::Sha256::new(); - hasher.update(data); - hasher.finalize().into() + false } fn hex_sha256(data: &[u8]) -> String { diff --git a/rust/scripts/snapshot-bundled-cli-version.sh b/rust/scripts/snapshot-bundled-cli-version.sh new file mode 100644 index 000000000..b1eb1a2a1 --- /dev/null +++ b/rust/scripts/snapshot-bundled-cli-version.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Snapshot the Copilot CLI version + per-platform SHA-256 hashes for the +# rust crate's bundled-CLI build.rs. Runs at SDK publish time, mirroring +# how .NET's _GenerateVersionProps BeforeTargets="Pack" target writes +# GitHub.Copilot.SDK.props before NuGet packing. +# +# Inputs: +# - ../nodejs/package-lock.json (sibling) - source of the pinned version. +# - https://github.com/github/copilot-cli/releases/v{version}/SHA256SUMS.txt - +# authoritative per-platform hashes. +# +# Output: +# - bundled_cli_version.txt (in the rust crate root). Gitignored. + +set -euo pipefail + +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" + +if [[ ! -f "${LOCKFILE}" ]]; then + echo "error: ${LOCKFILE} not found" >&2 + exit 1 +fi + +VERSION="$(node -e "console.log(require('${LOCKFILE}').packages['node_modules/@github/copilot'].version)")" +if [[ -z "${VERSION}" ]]; then + echo "error: could not read @github/copilot version from ${LOCKFILE}" >&2 + exit 1 +fi + +CHECKSUMS_URL="https://github.com/github/copilot-cli/releases/download/v${VERSION}/SHA256SUMS.txt" +echo "Fetching ${CHECKSUMS_URL}" +SHA256SUMS="$(curl -fsSL --retry 3 --retry-delay 2 "${CHECKSUMS_URL}")" + +ASSETS=( + "copilot-darwin-arm64.tar.gz" + "copilot-darwin-x64.tar.gz" + "copilot-linux-arm64.tar.gz" + "copilot-linux-x64.tar.gz" + "copilot-win32-arm64.zip" + "copilot-win32-x64.zip" +) + +declare -A HASHES +for asset in "${ASSETS[@]}"; do + hash="$(printf '%s\n' "${SHA256SUMS}" | awk -v a="${asset}" '$2 == a { print $1 }')" + if [[ -z "${hash}" ]]; then + echo "error: SHA256SUMS.txt missing entry for ${asset}" >&2 + exit 1 + fi + HASHES[$asset]="${hash}" +done + +{ + echo "# Auto-generated by rust/scripts/snapshot-bundled-cli-version.sh" + echo "# Do not edit. Regenerated by the publish workflow on every release." + echo "version=${VERSION}" + for asset in "${ASSETS[@]}"; do + echo "${asset}=${HASHES[$asset]}" + done +} > "${OUTPUT}" + +echo "Wrote ${OUTPUT} (version=${VERSION}, ${#ASSETS[@]} hashes)" \ No newline at end of file diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index d0e5ea9ff..e1eb147dc 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -1,17 +1,31 @@ -#[cfg(any(has_bundled_cli, test))] +//! Lazy runtime installer for the CLI binary that build.rs embedded in this +//! crate (gated on the `bundled-cli` cargo feature, which is in the default +//! feature set). +//! +//! 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** +//! 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 +//! consumer's signed binary and trusted, so no further hashing is done. + +#[cfg(has_bundled_cli)] use std::fs; -#[cfg(any(has_bundled_cli, test))] -use std::io::{self, Read, Write}; -#[cfg(any(has_bundled_cli, test))] -use std::path::Path; -use std::path::PathBuf; +#[cfg(all(has_bundled_cli, not(windows)))] +use std::io::Read; +#[cfg(has_bundled_cli)] +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::OnceLock; #[cfg(has_bundled_cli)] use tracing::{info, warn}; -// When the SDK is built with COPILOT_CLI_VERSION set, build.rs generates -// bundled_cli.rs with the compressed binary bytes, hash, and version. +// 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. #[cfg(has_bundled_cli)] mod build_time { include!(concat!(env!("OUT_DIR"), "/bundled_cli.rs")); @@ -19,36 +33,27 @@ mod build_time { static INSTALLED_PATH: OnceLock> = OnceLock::new(); -/// Returns the bundled CLI version string, if one was embedded at build time. -pub fn bundled_version() -> Option<&'static str> { - #[cfg(has_bundled_cli)] - { - Some(build_time::CLI_VERSION) - } - #[cfg(not(has_bundled_cli))] - { - None - } -} - -/// Returns the path to the installed CLI binary, lazily extracting on first call. +/// Returns the path to the installed CLI binary, lazily extracting the +/// embedded archive on first call. /// -/// When the SDK was built with `COPILOT_CLI_VERSION` set, this extracts the -/// embedded binary to `~/.cache/github-copilot-sdk-{version}/copilot` (or -/// `copilot.exe` on Windows), verifies the SHA-256 hash, and returns the -/// path. Subsequent calls return the cached result. +/// 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 +/// [`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 +/// is skipped when the target file already exists — the per-version +/// install directory and the assumption that the consumer's binary is +/// trusted mean no further hashing is needed. /// /// Returns `None` if no CLI was embedded at build time. -pub fn path() -> Option { +pub(crate) fn path() -> Option { INSTALLED_PATH .get_or_init(|| { #[cfg(has_bundled_cli)] { - match install( - build_time::CLI_BYTES, - build_time::CLI_HASH, - build_time::CLI_VERSION, - ) { + let dir = default_install_dir(build_time::CLI_VERSION); + match install(&dir, build_time::CLI_ARCHIVE) { Ok(path) => { info!(path = %path.display(), version = build_time::CLI_VERSION, "embedded CLI installed"); return Some(path); @@ -63,55 +68,67 @@ pub fn path() -> Option { .clone() } -#[cfg(has_bundled_cli)] -fn install( - compressed: &[u8], - expected_hash: [u8; 32], - version: &str, -) -> Result { - let verbose = std::env::var("COPILOT_CLI_INSTALL_VERBOSE").ok().as_deref() == Some("1"); +/// Install the embedded CLI binary into the given directory instead of the +/// default `/github-copilot-sdk-{version}/` 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. +#[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"); + return Some(path); + } + Err(e) => { + warn!(error = %e, "embedded CLI installation failed"); + } + } + } + #[cfg(not(has_bundled_cli))] + { + let _ = extract_dir; + } + None +} +#[cfg(has_bundled_cli)] +fn default_install_dir(version: &str) -> PathBuf { let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); - // Use a versioned directory so multiple versions can coexist, - // but keep the binary named `copilot` — the CLI checks argv[0] - // for this exact name. - let install_dir = if version.is_empty() { + if version.is_empty() { cache.join("github-copilot-sdk") } else { cache.join(format!("github-copilot-sdk-{}", sanitize_version(version))) - }; - fs::create_dir_all(&install_dir).map_err(EmbeddedCliError::CreateDir)?; + } +} - let binary_name = binary_name(); - let final_path = install_dir.join(&binary_name); +#[cfg(has_bundled_cli)] +fn install(install_dir: &Path, archive: &[u8]) -> Result { + let verbose = std::env::var("COPILOT_CLI_INSTALL_VERBOSE").ok().as_deref() == Some("1"); + + fs::create_dir_all(install_dir).map_err(EmbeddedCliError::CreateDir)?; + + let final_path = install_dir.join(build_time::CLI_BINARY_NAME); - // If the binary already exists and hash matches, skip extraction. + // Per-version install dir means a present file at this path is the + // binary we want — no need to hash-verify the bytes are unchanged. if final_path.is_file() { - let existing_hash = hash_file(&final_path)?; - if existing_hash == expected_hash { - if verbose { - eprintln!("embedded CLI already installed at {}", final_path.display()); - } - return Ok(final_path); - } if verbose { - eprintln!("embedded CLI hash mismatch, reinstalling"); + eprintln!("embedded CLI already installed at {}", final_path.display()); } + return Ok(final_path); } let start = std::time::Instant::now(); - let decompressed = decompress(compressed)?; - - let actual_hash = sha256(&decompressed); - if actual_hash != expected_hash { - return Err(EmbeddedCliError::HashMismatch); - } - - write_binary(&final_path, &decompressed)?; + let bytes = extract_binary(archive, build_time::CLI_BINARY_NAME)?; + write_binary(&final_path, &bytes)?; if verbose { eprintln!( - "embedded CLI installed at {} in {:?}", + "embedded CLI extracted to {} in {:?}", final_path.display(), start.elapsed() ); @@ -120,13 +137,39 @@ fn install( Ok(final_path) } -#[cfg(any(has_bundled_cli, test))] -fn binary_name() -> String { - if cfg!(target_os = "windows") { - "copilot.exe".to_string() - } else { - "copilot".to_string() +#[cfg(all(has_bundled_cli, not(windows)))] +fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + for entry in tar.entries().map_err(EmbeddedCliError::Archive)? { + let mut entry = entry.map_err(EmbeddedCliError::Archive)?; + let path = entry.path().map_err(EmbeddedCliError::Archive)?; + let name = path.to_string_lossy(); + if name == binary_name || name.ends_with(&format!("/{binary_name}")) { + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .map_err(EmbeddedCliError::Archive)?; + return Ok(bytes); + } } + Err(EmbeddedCliError::BinaryNotFoundInArchive) +} + +#[cfg(all(has_bundled_cli, windows))] +fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { + let cursor = std::io::Cursor::new(archive); + let mut zip = zip::ZipArchive::new(cursor).map_err(EmbeddedCliError::Zip)?; + for i in 0..zip.len() { + let mut entry = zip.by_index(i).map_err(EmbeddedCliError::Zip)?; + let name = entry.name().to_string(); + if name == binary_name || name.ends_with(&format!("/{binary_name}")) { + let mut bytes = Vec::with_capacity(entry.size() as usize); + std::io::copy(&mut entry, &mut bytes).map_err(EmbeddedCliError::Io)?; + return Ok(bytes); + } + } + Err(EmbeddedCliError::BinaryNotFoundInArchive) } #[cfg(has_bundled_cli)] @@ -140,41 +183,7 @@ fn sanitize_version(version: &str) -> String { .collect() } -#[cfg(any(has_bundled_cli, test))] -fn decompress(data: &[u8]) -> Result, EmbeddedCliError> { - let mut decoder = zstd::Decoder::new(data).map_err(EmbeddedCliError::Decompress)?; - let mut out = Vec::new(); - decoder - .read_to_end(&mut out) - .map_err(EmbeddedCliError::Decompress)?; - Ok(out) -} - -#[cfg(any(has_bundled_cli, test))] -fn sha256(data: &[u8]) -> [u8; 32] { - use sha2::Digest; - let mut hasher = sha2::Sha256::new(); - hasher.update(data); - hasher.finalize().into() -} - #[cfg(has_bundled_cli)] -fn hash_file(path: &Path) -> Result<[u8; 32], EmbeddedCliError> { - use sha2::Digest; - let mut file = fs::File::open(path).map_err(EmbeddedCliError::Io)?; - let mut hasher = sha2::Sha256::new(); - let mut buf = [0u8; 8192]; - loop { - let n = file.read(&mut buf).map_err(EmbeddedCliError::Io)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(hasher.finalize().into()) -} - -#[cfg(any(has_bundled_cli, test))] fn write_binary(path: &Path, data: &[u8]) -> Result<(), EmbeddedCliError> { let mut file = fs::OpenOptions::new() .write(true) @@ -195,84 +204,24 @@ fn write_binary(path: &Path, data: &[u8]) -> Result<(), EmbeddedCliError> { Ok(()) } -#[cfg(any(has_bundled_cli, test))] +#[cfg(has_bundled_cli)] #[derive(Debug, thiserror::Error)] #[allow(dead_code)] enum EmbeddedCliError { #[error("failed to create install directory: {0}")] CreateDir(io::Error), - #[error("decompression failed: {0}")] - Decompress(io::Error), + #[cfg(not(windows))] + #[error("failed to read archive entry: {0}")] + Archive(io::Error), + + #[cfg(windows)] + #[error("failed to read zip archive: {0}")] + Zip(zip::result::ZipError), - #[error("SHA-256 hash of decompressed binary does not match expected hash")] - HashMismatch, + #[error("CLI binary not found in embedded archive")] + BinaryNotFoundInArchive, #[error("I/O error: {0}")] Io(io::Error), } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn install_extracts_to_cache_dir() { - let temp = tempfile::tempdir().expect("should create temp dir"); - let original = b"fake copilot binary"; - let hash = sha256(original); - let compressed = zstd::encode_all(&original[..], 3).expect("compression should succeed"); - - // Override cache dir via env for test isolation. - let path = install_to_dir(&temp, &compressed, hash); - let expected_name = binary_name(); - assert!(path.is_file()); - assert_eq!( - path.file_name().and_then(|s| s.to_str()), - Some(expected_name.as_str()) - ); - - let installed_content = fs::read(&path).expect("should read installed binary"); - assert_eq!(installed_content, original); - - // Second install should be idempotent (hash matches, skips extraction). - let path2 = install_to_dir(&temp, &compressed, hash); - assert_eq!(path, path2); - } - - #[test] - fn install_rejects_hash_mismatch() { - let temp = tempfile::tempdir().expect("should create temp dir"); - let original = b"fake copilot binary"; - let wrong_hash = [0u8; 32]; - let compressed = zstd::encode_all(&original[..], 3).expect("compression should succeed"); - - let result = install_to_dir_result(&temp, &compressed, wrong_hash); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("SHA-256"),); - } - - // Test helpers that install to a specific directory instead of the global cache. - fn install_to_dir(temp: &tempfile::TempDir, compressed: &[u8], hash: [u8; 32]) -> PathBuf { - install_to_dir_result(temp, compressed, hash).expect("install should succeed") - } - - fn install_to_dir_result( - temp: &tempfile::TempDir, - compressed: &[u8], - hash: [u8; 32], - ) -> Result { - let install_dir = temp.path().to_path_buf(); - fs::create_dir_all(&install_dir).expect("create dir"); - let binary_name = binary_name(); - let final_path = install_dir.join(&binary_name); - - let decompressed = decompress(compressed)?; - let actual_hash = sha256(&decompressed); - if actual_hash != hash { - return Err(EmbeddedCliError::HashMismatch); - } - write_binary(&final_path, &decompressed)?; - Ok(final_path) - } -} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 708f7864f..c7294c3c3 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -4,7 +4,7 @@ #![cfg_attr(test, allow(clippy::unwrap_used))] /// Bundled CLI binary extraction and caching. -pub mod embeddedcli; +pub(crate) mod embeddedcli; /// Event handler traits for session lifecycle. pub mod handler; /// Lifecycle hook callbacks (pre/post tool use, prompt submission, session start/end). @@ -13,7 +13,7 @@ mod jsonrpc; /// Permission-policy helpers that produce a [`handler::PermissionHandler`]. pub mod permission; /// GitHub Copilot CLI binary resolution (env var, embedded, PATH search). -pub mod resolve; +pub(crate) mod resolve; mod router; /// Session management — create, resume, send messages, and interact with the agent. pub mod session; @@ -326,12 +326,13 @@ impl From for CliProgram { /// Options for starting a [`Client`]. /// -/// When `program` is [`CliProgram::Resolve`] (the default), -/// [`Client::start`] automatically resolves the binary via -/// [`resolve::copilot_binary()`] — checking `COPILOT_CLI_PATH`, the -/// embedded CLI, and then the system PATH and common install locations. +/// 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). /// -/// Set `program` to [`CliProgram::Path`] to use an explicit binary. +/// 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`. #[non_exhaustive] pub struct ClientOptions { /// How to locate the CLI binary. @@ -408,6 +409,20 @@ pub struct ClientOptions { /// GitHub web and mobile. Ignored when connecting to an external server /// via [`Transport::External`]. pub enable_remote_sessions: bool, + /// Override the directory where the bundled CLI binary is extracted on + /// first use. + /// + /// When `None` (the default), the SDK extracts the embedded CLI to + /// `/github-copilot-sdk-{version}/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). + pub bundled_cli_extract_dir: Option, } impl std::fmt::Debug for ClientOptions { @@ -442,6 +457,7 @@ impl std::fmt::Debug for ClientOptions { .field("telemetry", &self.telemetry) .field("base_directory", &self.base_directory) .field("enable_remote_sessions", &self.enable_remote_sessions) + .field("bundled_cli_extract_dir", &self.bundled_cli_extract_dir) .finish() } } @@ -648,6 +664,7 @@ impl Default for ClientOptions { telemetry: None, base_directory: None, enable_remote_sessions: false, + bundled_cli_extract_dir: None, } } } @@ -804,6 +821,13 @@ impl ClientOptions { self.enable_remote_sessions = enabled; self } + + /// Override the directory where the bundled CLI binary is extracted on + /// first use. See [`Self::bundled_cli_extract_dir`]. + pub fn with_bundled_cli_extract_dir(mut self, dir: impl Into) -> Self { + self.bundled_cli_extract_dir = Some(dir.into()); + self + } } /// Validate a [`SessionFsConfig`] before sending `sessionFs.setProvider`. @@ -962,7 +986,9 @@ impl Client { path.clone() } CliProgram::Resolve => { - let resolved = resolve::copilot_binary()?; + let resolved = resolve::copilot_binary_with_extract_dir( + options.bundled_cli_extract_dir.as_deref(), + )?; info!(path = %resolved.display(), "resolved copilot CLI"); #[cfg(windows)] { diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs index 8521a4b55..7a1b29a04 100644 --- a/rust/src/resolve.rs +++ b/rust/src/resolve.rs @@ -1,677 +1,57 @@ -use std::collections::HashSet; +//! Internal resolution of the GitHub Copilot CLI binary. +//! +//! Resolution order (matches the .NET and TypeScript SDKs): +//! +//! 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). +//! +//! 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, +//! [`Client::start`](crate::Client::start) returns +//! [`Error::BinaryNotFound`](crate::Error::BinaryNotFound). + use std::env; -use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use serde::Serialize; use tracing::warn; use crate::Error; -/// How the copilot binary was resolved. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum BinarySource { - /// Extracted from the build-time embedded binary. - Bundled, - /// Set via `COPILOT_CLI_PATH` environment variable. - EnvOverride, - /// Found on PATH or standard search locations. - Local, -} - -/// Find the `copilot` CLI binary on the system. -/// -/// Checks `COPILOT_CLI_PATH` env var first, then searches PATH and common -/// install locations (homebrew, nvm, nodenv, fnm, volta, cargo, etc.). -/// Use `COPILOT_CLI_NAME` to override the binary name (default: `copilot`). -pub fn copilot_binary() -> Result { - copilot_binary_with_source().map(|(path, _)| path) -} - -/// Like [`copilot_binary`] but also reports how the binary was resolved. -pub fn copilot_binary_with_source() -> Result<(PathBuf, BinarySource), 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`. +pub(crate) fn copilot_binary_with_extract_dir( + extract_dir: Option<&Path>, +) -> Result { if let Ok(value) = env::var("COPILOT_CLI_PATH") { - let candidate = PathBuf::from(value); + let candidate = PathBuf::from(&value); if candidate.is_file() { - return Ok((candidate, BinarySource::EnvOverride)); - } - if candidate.is_dir() - && let Some(found) = find_copilot_in_dir(&candidate) - { - return Ok((found, BinarySource::EnvOverride)); - } - warn!(path = %candidate.display(), "COPILOT_CLI_PATH set but not usable"); - } - - if let Some(path) = crate::embeddedcli::path() { - return Ok((path, BinarySource::Bundled)); - } - - for dir in standard_search_paths() { - if let Some(found) = find_copilot_in_dir(&dir) { - return Ok((found, BinarySource::Local)); + return Ok(candidate); } + warn!( + path = %candidate.display(), + "COPILOT_CLI_PATH is set but does not point to a file; falling back to bundled CLI" + ); } - Err(Error::BinaryNotFound { - name: "copilot", - hint: "ensure the GitHub Copilot CLI is installed and on PATH, or set COPILOT_CLI_PATH. use COPILOT_CLI_NAME to override the binary name (default: copilot)", - }) -} - -/// Find the `copilot` CLI binary using only the current PATH entries. -/// -/// This is intentionally narrower than [`copilot_binary`]: it does not honor -/// override env vars and does not search inferred install locations. -pub fn copilot_binary_on_path() -> Result { - if let Some(found) = find_executable_in_path( - env::var_os("PATH").as_deref(), - &literal_copilot_executable_names(), - ) { - return Ok(found); + let bundled = match extract_dir { + Some(dir) => crate::embeddedcli::install_at(dir), + None => crate::embeddedcli::path(), + }; + if let Some(path) = bundled { + return Ok(path); } Err(Error::BinaryNotFound { name: "copilot", - hint: "ensure the `copilot` command is installed and available on PATH", + hint: "the Copilot CLI is not bundled in this build of github-copilot-sdk and \ + COPILOT_CLI_PATH is not set. Either keep the default `bundled-cli` cargo \ + feature enabled, set COPILOT_CLI_PATH, or supply an explicit path via \ + `CliProgram::Path(...)` on `ClientOptions::program`.", }) } - -/// Build an extended `PATH` by prepending `extra` dirs to the standard -/// search paths (current PATH + common install locations). -pub fn extended_path(extra: &[PathBuf]) -> Option { - let mut paths = SearchPaths::new(); - for p in extra { - paths.push(p.clone()); - } - paths.append_standard(); - if paths.is_empty() { - return None; - } - env::join_paths(paths).ok() -} - -fn copilot_executable_names() -> Vec { - let base = env::var("COPILOT_CLI_NAME").unwrap_or_else(|_| "copilot".to_string()); - executable_names_for_base(&base) -} - -fn literal_copilot_executable_names() -> Vec { - executable_names_for_base("copilot") -} - -fn executable_names_for_base(base: &str) -> Vec { - #[cfg(target_os = "windows")] - { - vec![ - format!("{}.exe", base), - format!("{}.cmd", base), - format!("{}.bat", base), - ] - } - #[cfg(not(target_os = "windows"))] - { - vec![base.to_string()] - } -} - -fn find_executable(dir: &Path, names: &[impl AsRef]) -> Option { - if dir.as_os_str().is_empty() { - return None; - } - names - .iter() - .map(|n| dir.join(n.as_ref())) - .find(|c| c.is_file()) -} - -fn find_copilot_in_dir(dir: &Path) -> Option { - find_executable(dir, &copilot_executable_names()) -} - -fn find_executable_in_path( - path_env: Option<&OsStr>, - names: &[impl AsRef], -) -> Option { - let path_env = path_env?; - for dir in env::split_paths(path_env) { - if let Some(found) = find_executable(&dir, names) { - return Some(found); - } - } - None -} - -/// Ordered, deduplicated collection of directory paths to search for binaries. -/// -/// Paths are stored in insertion order. Duplicates and empty paths are -/// silently dropped on `push`. Implements `Iterator` so it can be passed -/// directly to `env::join_paths` or used in a `for` loop. -struct SearchPaths { - seen: HashSet, - paths: Vec, -} - -impl SearchPaths { - fn new() -> Self { - Self { - seen: HashSet::new(), - paths: Vec::new(), - } - } - - /// Add a path if it hasn't been seen before. Empty paths are ignored. - fn push(&mut self, path: PathBuf) { - if !path.as_os_str().is_empty() && self.seen.insert(path.clone()) { - self.paths.push(path); - } - } - - fn is_empty(&self) -> bool { - self.paths.is_empty() - } - - /// Append the standard search paths: current PATH, home-relative dirs, - /// version manager paths (nvm, nodenv, fnm), and platform-specific dirs. - fn append_standard(&mut self) { - if let Some(existing) = env::var_os("PATH") { - for p in env::split_paths(&existing) { - self.push(p); - } - } - - if let Some(home) = dirs::home_dir() { - self.push(home.join(".local/bin")); - self.push(home.join(".cargo/bin")); - self.push(home.join(".bun/bin")); - self.push(home.join(".npm-global/bin")); - self.push(home.join(".yarn/bin")); - self.push(home.join(".volta/bin")); - self.push(home.join(".asdf/shims")); - self.push(home.join("bin")); - } - - // Platform-specific standard dirs come before version-manager paths - // so that the system-installed node (e.g. /opt/homebrew/bin/node) - // takes precedence over arbitrary old versions found under - // ~/.nvm/versions, ~/.nodenv/versions, etc. - #[cfg(target_os = "macos")] - { - self.push(PathBuf::from("/opt/homebrew/bin")); - self.push(PathBuf::from("/usr/local/bin")); - self.push(PathBuf::from("/usr/bin")); - self.push(PathBuf::from("/bin")); - self.push(PathBuf::from("/usr/sbin")); - self.push(PathBuf::from("/sbin")); - } - - #[cfg(target_os = "linux")] - { - self.push(PathBuf::from("/usr/local/bin")); - self.push(PathBuf::from("/usr/bin")); - self.push(PathBuf::from("/bin")); - self.push(PathBuf::from("/snap/bin")); - } - - #[cfg(target_os = "windows")] - { - if let Some(appdata) = env::var_os("APPDATA") { - self.push(PathBuf::from(appdata).join("npm")); - } - if let Some(local) = env::var_os("LOCALAPPDATA") { - let local = PathBuf::from(local); - self.push(local.join("Programs")); - // User-scope winget install of Git for Windows. - self.push(local.join("Programs").join("Git").join("cmd")); - self.push(local.join("Programs").join("Git").join("bin")); - } - // Git for Windows standard machine-scope install locations. - for env_var in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] { - if let Some(program_files) = env::var_os(env_var) { - let program_files = PathBuf::from(program_files); - self.push(program_files.join("Git").join("cmd")); - self.push(program_files.join("Git").join("bin")); - } - } - } - - // Version manager paths are a fallback for binary discovery — - // they enumerate every installed version, so an arbitrary old - // node/copilot can appear first if filesystem ordering is unlucky. - for p in collect_nvm_paths() { - self.push(p); - } - for p in collect_nodenv_paths() { - self.push(p); - } - for p in collect_fnm_paths() { - self.push(p); - } - } -} - -impl IntoIterator for SearchPaths { - type IntoIter = std::vec::IntoIter; - type Item = PathBuf; - - fn into_iter(self) -> Self::IntoIter { - self.paths.into_iter() - } -} - -/// Collect standard search paths for binary resolution. -fn standard_search_paths() -> SearchPaths { - let mut paths = SearchPaths::new(); - paths.append_standard(); - paths -} - -fn collect_nvm_paths() -> Vec { - let mut paths = Vec::new(); - let nvm_dir = env::var_os("NVM_DIR") - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|home| home.join(".nvm"))); - let Some(nvm_dir) = nvm_dir else { - return paths; - }; - let versions_dir = nvm_dir.join("versions").join("node"); - let entries = match std::fs::read_dir(&versions_dir) { - Ok(entries) => entries, - Err(_) => return paths, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - paths.push(path.join("bin")); - } - } - paths -} - -fn collect_nodenv_paths() -> Vec { - let mut paths = Vec::new(); - let root = env::var_os("NODENV_ROOT") - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|home| home.join(".nodenv"))); - let Some(root) = root else { - return paths; - }; - let versions_dir = root.join("versions"); - let entries = match std::fs::read_dir(&versions_dir) { - Ok(entries) => entries, - Err(_) => return paths, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - paths.push(path.join("bin")); - } - } - paths -} - -fn fnm_root_candidates_from( - fnm_dir: Option, - xdg_data_home: Option, - home: Option, -) -> Vec { - let mut roots = SearchPaths::new(); - - if let Some(fnm_dir) = fnm_dir.filter(|path| !path.as_os_str().is_empty()) { - roots.push(fnm_dir); - } - - if let Some(xdg_data_home) = xdg_data_home.filter(|path| !path.as_os_str().is_empty()) { - roots.push(xdg_data_home.join("fnm")); - } - - if let Some(home) = home { - roots.push(home.join(".local").join("share").join("fnm")); - roots.push(home.join(".fnm")); - } - - roots.paths -} - -fn collect_fnm_paths() -> Vec { - let roots = fnm_root_candidates_from( - env::var_os("FNM_DIR").map(PathBuf::from), - env::var_os("XDG_DATA_HOME").map(PathBuf::from), - dirs::home_dir(), - ); - - let mut paths = SearchPaths::new(); - for root in &roots { - paths.push(root.join("aliases").join("default").join("bin")); - - let versions_dir = root.join("node-versions"); - let entries = match std::fs::read_dir(&versions_dir) { - Ok(entries) => entries, - Err(_) => continue, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - paths.push(path.join("installation").join("bin")); - } - } - } - - paths.paths -} - -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - use std::{env, fs}; - - use serial_test::serial; - use tempfile::tempdir; - - use super::{ - copilot_binary_on_path, find_executable_in_path, fnm_root_candidates_from, - literal_copilot_executable_names, - }; - - #[test] - fn fnm_root_candidates_include_xdg_and_legacy_locations() { - let home = PathBuf::from("/tmp/copilot-home"); - - let roots = fnm_root_candidates_from(None, None, Some(home.clone())); - - assert_eq!( - roots, - vec![ - home.join(".local").join("share").join("fnm"), - home.join(".fnm"), - ] - ); - } - - #[test] - fn fnm_root_candidates_prefer_explicit_locations_first() { - let home = PathBuf::from("/tmp/copilot-home"); - let explicit_fnm_dir = PathBuf::from("/tmp/custom-fnm"); - let xdg_data_home = PathBuf::from("/tmp/xdg-data"); - - let roots = fnm_root_candidates_from( - Some(explicit_fnm_dir.clone()), - Some(xdg_data_home.clone()), - Some(home.clone()), - ); - - assert_eq!( - roots, - vec![ - explicit_fnm_dir, - xdg_data_home.join("fnm"), - home.join(".local").join("share").join("fnm"), - home.join(".fnm"), - ] - ); - } - - #[test] - fn fnm_root_candidates_ignore_empty_xdg_data_home() { - let home = PathBuf::from("/tmp/copilot-home"); - - let roots = fnm_root_candidates_from(None, Some(PathBuf::new()), Some(home.clone())); - - assert_eq!( - roots, - vec![ - home.join(".local").join("share").join("fnm"), - home.join(".fnm"), - ] - ); - assert!(!roots.iter().any(|path| path == &PathBuf::from("fnm"))); - } - - #[test] - fn fnm_root_produces_expected_bin_paths() { - let temp_dir = tempdir().expect("should create temp dir"); - let root = temp_dir.path().join("fnm-root"); - let alias_bin = root.join("aliases").join("default").join("bin"); - let version_bin = root - .join("node-versions") - .join("v22.18.0") - .join("installation") - .join("bin"); - - fs::create_dir_all(&alias_bin).expect("should create fnm alias bin"); - fs::create_dir_all(&version_bin).expect("should create fnm version bin"); - - let roots = fnm_root_candidates_from(Some(root.clone()), None, None); - assert_eq!(roots, vec![root.clone()]); - - // Verify the expected bin paths exist under the root structure - assert!(alias_bin.is_dir()); - assert!(version_bin.is_dir()); - } - - #[test] - fn find_copilot_in_path_finds_binary_in_path_entries() { - let temp_dir = tempdir().expect("should create temp dir"); - let bin_dir = temp_dir.path().join("bin"); - fs::create_dir_all(&bin_dir).expect("should create bin dir"); - - let executable_name = literal_copilot_executable_names() - .into_iter() - .next() - .expect("should provide a copilot executable name"); - let executable_path = bin_dir.join(&executable_name); - fs::write(&executable_path, "#!/bin/sh\n").expect("should create fake binary"); - - let path_env = - env::join_paths([Path::new("/missing"), bin_dir.as_path()]).expect("should build PATH"); - - assert_eq!( - find_executable_in_path( - Some(path_env.as_os_str()), - &literal_copilot_executable_names() - ), - Some(executable_path) - ); - } - - #[test] - fn find_copilot_in_path_ignores_missing_entries() { - let path_env = env::join_paths([Path::new("/missing-one"), Path::new("/missing-two")]) - .expect("should build PATH"); - - assert_eq!( - find_executable_in_path( - Some(path_env.as_os_str()), - &literal_copilot_executable_names() - ), - None - ); - } - - #[test] - #[serial] - #[cfg(target_os = "macos")] - fn platform_dirs_precede_version_manager_dirs() { - let temp = tempdir().expect("should create temp dir"); - let fake_home = temp.path().join("home"); - - // Create fake nvm version dirs so collect_nvm_paths() returns entries. - let nvm_dir = fake_home.join(".nvm"); - let nvm_version_bin = nvm_dir - .join("versions") - .join("node") - .join("v18.0.0") - .join("bin"); - fs::create_dir_all(&nvm_version_bin).expect("should create nvm version bin"); - - // Create fake nodenv version dirs. - let nodenv_root = fake_home.join(".nodenv"); - let nodenv_version_bin = nodenv_root.join("versions").join("20.0.0").join("bin"); - fs::create_dir_all(&nodenv_version_bin).expect("should create nodenv version bin"); - - // Create fake fnm version dirs. - let fnm_root = fake_home.join(".local").join("share").join("fnm"); - let fnm_version_bin = fnm_root - .join("node-versions") - .join("v22.0.0") - .join("installation") - .join("bin"); - fs::create_dir_all(&fnm_version_bin).expect("should create fnm version bin"); - - // Save env vars. - let prev_path = env::var_os("PATH"); - let prev_home = env::var_os("HOME"); - let prev_nvm_dir = env::var_os("NVM_DIR"); - let prev_nodenv_root = env::var_os("NODENV_ROOT"); - let prev_fnm_dir = env::var_os("FNM_DIR"); - let prev_xdg_data_home = env::var_os("XDG_DATA_HOME"); - - // Set env: empty PATH so only append_standard() dirs appear, - // HOME to our fake home, and explicit version-manager roots. - // Safety: test-only, single-threaded via #[serial]. - unsafe { - env::set_var("PATH", ""); - env::set_var("HOME", &fake_home); - env::set_var("NVM_DIR", &nvm_dir); - env::set_var("NODENV_ROOT", &nodenv_root); - env::remove_var("FNM_DIR"); - env::remove_var("XDG_DATA_HOME"); - } - - let paths: Vec = super::standard_search_paths().into_iter().collect(); - - // Restore env vars. - // Safety: test-only, single-threaded via #[serial]. - unsafe { - match prev_path { - Some(v) => env::set_var("PATH", v), - None => env::remove_var("PATH"), - } - match prev_home { - Some(v) => env::set_var("HOME", v), - None => env::remove_var("HOME"), - } - match prev_nvm_dir { - Some(v) => env::set_var("NVM_DIR", v), - None => env::remove_var("NVM_DIR"), - } - match prev_nodenv_root { - Some(v) => env::set_var("NODENV_ROOT", v), - None => env::remove_var("NODENV_ROOT"), - } - match prev_fnm_dir { - Some(v) => env::set_var("FNM_DIR", v), - None => env::remove_var("FNM_DIR"), - } - match prev_xdg_data_home { - Some(v) => env::set_var("XDG_DATA_HOME", v), - None => env::remove_var("XDG_DATA_HOME"), - } - } - - let platform_dirs: Vec = vec![ - PathBuf::from("/opt/homebrew/bin"), - PathBuf::from("/usr/local/bin"), - PathBuf::from("/usr/bin"), - PathBuf::from("/bin"), - PathBuf::from("/usr/sbin"), - PathBuf::from("/sbin"), - ]; - - // Find the last platform dir index and the first version-manager dir index. - let last_platform_idx = platform_dirs - .iter() - .filter_map(|d| paths.iter().position(|p| p == d)) - .max() - .expect("at least one platform dir should be present"); - - let version_manager_prefixes = [ - nvm_version_bin.parent().unwrap().parent().unwrap(), // .nvm/versions/node - nodenv_version_bin.parent().unwrap().parent().unwrap(), // .nodenv/versions - fnm_version_bin - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap(), // .local/share/fnm - ]; - - let first_version_mgr_idx = paths - .iter() - .position(|p| { - version_manager_prefixes - .iter() - .any(|prefix| p.starts_with(prefix)) - }) - .expect("at least one version-manager dir should be present"); - - assert!( - last_platform_idx < first_version_mgr_idx, - "Platform dirs (last at index {last_platform_idx}) must precede \ - version-manager dirs (first at index {first_version_mgr_idx}).\n\ - Full path list: {paths:#?}" - ); - } - - #[test] - #[serial] - fn find_executable_in_path_can_ignore_copilot_name_override() { - let temp_dir = tempdir().expect("should create temp dir"); - let bin_dir = temp_dir.path().join("bin"); - fs::create_dir_all(&bin_dir).expect("should create bin dir"); - - let path_executable_name = literal_copilot_executable_names() - .into_iter() - .next() - .expect("should provide a literal copilot executable name"); - #[cfg(target_os = "windows")] - let overridden_executable_name = "my-copilot.exe"; - - #[cfg(not(target_os = "windows"))] - let overridden_executable_name = "my-copilot"; - - let path_executable_path = bin_dir.join(&path_executable_name); - let overridden_executable_path = bin_dir.join(overridden_executable_name); - - fs::write(&path_executable_path, "#!/bin/sh\n").expect("should create literal fake binary"); - fs::write(&overridden_executable_path, "#!/bin/sh\n") - .expect("should create overridden fake binary"); - - let path_env = - env::join_paths([Path::new("/missing"), bin_dir.as_path()]).expect("should build PATH"); - - let previous_path = env::var_os("PATH"); - let previous_copilot_cli_name = env::var_os("COPILOT_CLI_NAME"); - // Safety: test-only, single-threaded via #[serial]. - unsafe { - env::set_var("PATH", &path_env); - env::set_var("COPILOT_CLI_NAME", "my-copilot"); - } - - let resolved_path = copilot_binary_on_path(); - - // Safety: test-only, single-threaded via #[serial]. - unsafe { - if let Some(previous_path) = previous_path { - env::set_var("PATH", previous_path); - } else { - env::remove_var("PATH"); - } - - if let Some(previous_copilot_cli_name) = previous_copilot_cli_name { - env::set_var("COPILOT_CLI_NAME", previous_copilot_cli_name); - } else { - env::remove_var("COPILOT_CLI_NAME"); - } - } - - assert_eq!( - resolved_path.expect("should find the literal copilot binary on PATH"), - path_executable_path - ); - } -} diff --git a/rust/tests/integration_test.rs b/rust/tests/integration_test.rs index a02bf01c0..9dd71223b 100644 --- a/rust/tests/integration_test.rs +++ b/rust/tests/integration_test.rs @@ -2,7 +2,6 @@ use std::time::Instant; -use github_copilot_sdk::resolve::copilot_binary_with_source; use github_copilot_sdk::{Client, ClientOptions, SDK_PROTOCOL_VERSION}; fn default_options() -> ClientOptions { @@ -89,11 +88,8 @@ async fn cli_operation_latency() { client2.stop().await.expect("stop second client failed"); - let (cli_path, source) = copilot_binary_with_source().expect("copilot binary not found"); - eprintln!(); eprintln!("=== CLI operation latency ==="); - eprintln!(" binary: {} ({:?})", cli_path.display(), source); eprintln!(" cold Client::start: {:>8.1?}", cold_start); eprintln!(" warm ping(): {:>8.1?}", warm_ping); eprintln!(