diff --git a/.github/workflows/sdk-rust.yml b/.github/workflows/sdk-rust.yml index ff875cda73..e5f15088f4 100644 --- a/.github/workflows/sdk-rust.yml +++ b/.github/workflows/sdk-rust.yml @@ -80,6 +80,11 @@ jobs: run: cargo doc --no-deps --no-default-features --features local-runtime,derive - name: External-stream-only SDK run: cargo test --no-default-features --features test-support --test protocol_version_test + # Nested checkouts use unreleased artifacts; only standalone pins can + # exercise build.rs downloads from published releases. + - name: Native-only bundled runtime + if: inputs.sdk-home == '.' + run: bash scripts/check-bundled-runtime.sh default - run: npm ci --ignore-scripts working-directory: ${{ inputs.sdk-home }}/test/harness - if: runner.os == 'Windows' @@ -126,6 +131,9 @@ jobs: cache-bin: false - run: npm ci --ignore-scripts working-directory: ${{ inputs.sdk-home }}/test/harness + - name: Native-only bundled in-process runtime + if: inputs.sdk-home == '.' + run: bash scripts/check-bundled-runtime.sh inprocess - env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} run: >- @@ -195,6 +203,10 @@ jobs: export CARGO_INCREMENTAL=0 export CARGO_PROFILE_TEST_DEBUG=0 cd "$COPILOT_SDK_ROOT/rust" + # Nested checkouts have development pins, not published releases. + if [ "${{ inputs.sdk-home }}" = "." ]; then + bash scripts/check-bundled-runtime.sh "$COPILOT_SDK_TEST_TRANSPORT" + fi if [ "$COPILOT_SDK_TEST_TRANSPORT" = "inprocess" ]; then unset RUST_E2E_CONCURRENCY cargo test --no-default-features --features test-support,local-runtime,derive \ diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 0b5c85e472..dd19db7529 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -30,7 +30,8 @@ name = "github_copilot_sdk" [features] default = ["bundled-cli"] runtime = [] -bundled-cli = ["runtime", "dep:tar", "dep:flate2", "dep:zip"] +bundled-runtime = ["runtime", "dep:tar", "dep:flate2"] +bundled-cli = ["bundled-runtime", "dep:zip"] in-process = ["runtime", "dep:libloading"] local-runtime = ["in-process"] bundled-in-process = ["bundled-cli", "in-process"] diff --git a/rust/README.md b/rust/README.md index 6198f55b90..083c01e3a7 100644 --- a/rust/README.md +++ b/rust/README.md @@ -74,7 +74,7 @@ runtime without embedding its bundle, use `Client::start` should select this feature explicitly. The `in-process` and `local-runtime` features also enable runtime management; `local-runtime` continues to use application-supplied artifacts without downloading -them. Enabling `bundled-cli` alongside `local-runtime` retains normal bundling. +them. Enabling `bundled-runtime` or `bundled-cli` alongside `local-runtime` retains the selected bundling. ## API Reference @@ -1380,7 +1380,7 @@ none of them are scheduled for removal. | `tool.rs` | `ToolHandler` trait, `define_tool`, `schema_for::()` (with `derive` feature) | | `types.rs` | CLI protocol types (`SessionId`, `SessionEvent`, `SessionConfig`, `Tool`, etc.) | | `resolve.rs` | Bundled-CLI resolution (`copilot_binary`) | -| `embeddedcli.rs` | Embedded CLI extraction (gated on the default `bundled-cli` feature) | +| `embeddedcli.rs` | Embedded runtime and CLI extraction (gated on `bundled-runtime`) | | `router.rs` | Internal connection-global request dispatch and per-session event demux | | `jsonrpc.rs` | Internal Content-Length framed JSON-RPC transport | @@ -1398,6 +1398,21 @@ in the runtime bundle and use `Transport::InProcess`: github-copilot-sdk = { version = "1", features = ["bundled-in-process"] } ``` +To embed native assets while managing the Node executable yourself, opt in to +`bundled-runtime` with defaults disabled: + +```toml +github-copilot-sdk = { version = "1", default-features = false, features = ["bundled-runtime"] } +``` + +This downloads and embeds only the runtime package. `install_bundled_runtime` +and managed native startup work as usual; `HAS_BUNDLED_CLI` is `false` and +`install_bundled_cli()` returns `None`. `HAS_BUNDLED_RUNTIME` reports native +bundle availability. Add `in-process` to this configuration if you also need +the native runtime library. Existing defaults and `bundled-in-process` continue +to include both artifacts. Features are additive: another dependency enabling +`bundled-cli` also enables the full CLI archive. + `CliProgram::Path` and raw `ClientOptions::extra_args` apply only to child-process transports. Set `COPILOT_CLI_PATH` only when using an externally provisioned compatible runtime package with in-process transport. @@ -1410,21 +1425,21 @@ or embedding SDK-managed runtime artifacts: github-copilot-sdk = { version = "1", default-features = false, features = ["local-runtime"] } ``` -The default `bundled-cli` feature takes precedence when both features are -enabled, preserving bundled behavior for `--all-features` builds. +Bundling features take precedence over `local-runtime`, preserving the default +full bundle for `--all-features` builds. `COPILOT_CLI_PATH` must point to the application's CLI entrypoint, with the compatible native runtime library next to it. -For managed transports without embedded artifacts, disable `bundled-cli` while -enabling `runtime`: +For managed transports without embedded artifacts, disable the default features while +enabling only `runtime`: ```toml github-copilot-sdk = { version = "1", default-features = false, features = ["runtime"] } ``` > **You become responsible for supplying the runtime at deployment.** With -> `runtime` enabled and `bundled-cli` disabled, the produced binary does not contain these artifacts +> `runtime` enabled and `bundled-runtime` disabled, the produced binary does not contain these artifacts > and will not search the system for them. For managed child-process transports, > supply a compatible wrapper pair via an explicit [`CliProgram::Path`]. > `COPILOT_CLI_PATH` remains a direct program override. @@ -1436,7 +1451,7 @@ github-copilot-sdk = { version = "1", default-features = false, features = ["run > 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 runtime pair and set +> must either keep `bundled-runtime` enabled or ship the runtime pair and set > `CliProgram::Path`. With no features enabled (`default-features = false` alone), the SDK is @@ -1453,27 +1468,29 @@ explicit program path is supplied. When SDK-managed acquisition is enabled, the resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION`. 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-specific full CLI archive - and runtime package, then verifies both SHA-256 hashes against the release's +2. **Build time:** `build.rs` downloads the selected platform-specific archives + and verifies their SHA-256 hashes against the release's `SHA256SUMS.txt` or the publish snapshots. Then: - **`bundled-cli` on (default):** embeds the full CLI release archive and a separately filtered runtime archive containing `copilot-runtime[.exe]`, `runtime.node`, and required assets. + - **`bundled-runtime` on without `bundled-cli`:** embeds only the filtered + runtime archive; the full CLI archive is neither downloaded nor embedded. - **`in-process` on:** the runtime archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`). - - **`local-runtime` on and `bundled-cli` off:** skips this acquisition step + - **`local-runtime` on and `bundled-runtime` off:** skips this acquisition step entirely because the application supplies the runtime package. - **`runtime` off:** skips acquisition entirely; only externally supplied streams are supported. - - **`runtime` on, `bundled-cli` and `local-runtime` off:** downloads only the runtime package and extracts its + - **`runtime` on, `bundled-runtime` and `local-runtime` off:** downloads only the runtime package and extracts its managed runtime artifacts directly into the platform cache using staging files and atomic renames. 3. **Runtime:** embedded CLI artifacts and build-time-extracted hostless runtime artifacts use separate versioned namespaces: - | OS | `bundled-cli` on | `runtime` on, `bundled-cli` and `local-runtime` off | + | OS | `bundled-runtime` on | `runtime` on, `bundled-runtime` and `local-runtime` off | |----|------------------|-------------------| | macOS | `~/Library/Caches/github-copilot-sdk/cli//` | `~/Library/Caches/github-copilot-sdk/runtime//` | | Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli//` | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/runtime//` | @@ -1497,7 +1514,7 @@ let options = ClientOptions::new() let client = Client::start(options).await?; ``` -With `runtime` enabled and both `bundled-cli` and `local-runtime` 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`: +With `runtime` enabled and both `bundled-runtime` and `local-runtime` 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 @@ -1516,7 +1533,7 @@ resolution requires `COPILOT_CLI_PATH` to identify the supplied package. `COPILOT_SKIP_CLI_DOWNLOAD=1` remains available as an explicit build-time override for managed child-process consumers. It works regardless of the -`bundled-cli` feature state; runtime resolution falls through to +bundling feature state; runtime resolution falls through to `Error::BinaryNotFound` unless an applicable explicit source resolves. ### Resolution priority @@ -1525,8 +1542,8 @@ For managed child-process transports (`runtime` enabled), `Client::start` resolv 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 wrapper pair, lazily extracted on first call. -4. **`bundled-cli` and `local-runtime` off:** the build-time-extracted wrapper pair in the per-user cache. +3. **`bundled-runtime` on:** the embedded wrapper pair, lazily extracted on first call. +4. **`bundled-runtime` and `local-runtime` off:** the build-time-extracted wrapper pair in the per-user cache. In-process transport loads the native runtime library adjacent to the runtime wrapper selected from `COPILOT_CLI_PATH`, the embedded runtime archive, or the @@ -1571,12 +1588,12 @@ returns the wrapper path. ### Download cache (build-time, embed mode) -In embed mode `build.rs` downloads both verified archives on every clean build +In embed mode `build.rs` downloads the selected verified archives on every clean build by default. Set `BUNDLED_CLI_CACHE_DIR=` to cache them between builds (CI keys this on `-` for near-zero-cost rebuilds on cache hits). For Copilot CLI 1.0.83-5, the two upstream archives total roughly 132-157 MB per platform before the runtime package is filtered. With `runtime` enabled and both -`bundled-cli` and `local-runtime` disabled, +`bundled-runtime` and `local-runtime` disabled, there is no separate archive cache: the extracted runtime bundle is the cache. ### Platforms @@ -1591,9 +1608,10 @@ and `CARGO_CFG_TARGET_ENV` (cross-compilation works). | Feature | Default | Description | | ------- | ------- | ----------- | | `runtime` | ✓ (via `bundled-cli`) | Enables managed runtime startup and discovery. With no runtime feature, use `Client::from_streams`; no runtime artifacts are acquired. | -| `bundled-cli` | ✓ | Enables `runtime` and embeds the managed wrapper pair and compatible CLI artifact. | +| `bundled-runtime` | ✓ (via `bundled-cli`) | Enables `runtime` and embeds the native wrapper and required assets without the full CLI/Node SEA. | +| `bundled-cli` | ✓ | Enables `bundled-runtime` and also embeds the compatible full CLI/Node SEA. | | `in-process` | — | Enables `Transport::InProcess` while preserving the selected runtime acquisition policy. | -| `local-runtime` | — | Enables `in-process` and, when `bundled-cli` is disabled, disables SDK-managed runtime download, extraction, and embedding. The application must supply a compatible runtime package through `COPILOT_CLI_PATH`. | +| `local-runtime` | — | Enables `in-process` and, when `bundled-runtime` is disabled, disables SDK-managed runtime download, extraction, and embedding. The application must supply a compatible runtime package through `COPILOT_CLI_PATH`. | | `bundled-in-process` | — | Enables `in-process`, implies `bundled-cli`, and additionally embeds the platform-native runtime library. | | `derive` | — | `schema_for::()` for generating JSON Schema from Rust types (adds `schemars`). | diff --git a/rust/build/in_process.rs b/rust/build/in_process.rs index a4bafc35af..e2c2974471 100644 --- a/rust/build/in_process.rs +++ b/rust/build/in_process.rs @@ -16,6 +16,7 @@ pub(crate) fn main() { 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_bundled_runtime)"); println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); // Declare only the version sources that exist. Cargo treats a missing @@ -36,7 +37,7 @@ pub(crate) fn main() { } if std::env::var_os("CARGO_FEATURE_LOCAL_RUNTIME").is_some() - && std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_none() + && std::env::var_os("CARGO_FEATURE_BUNDLED_RUNTIME").is_none() { println!( "cargo:warning=local-runtime is enabled — using the runtime supplied by the application" @@ -55,8 +56,8 @@ pub(crate) fn main() { // 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 + // regardless of bundling features — without `has_bundled_cli`, + // `has_bundled_runtime`, or `has_extracted_cli`, 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() { @@ -92,7 +93,7 @@ pub(crate) fn main() { // 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` + // and the runtime path computation in resolve.rs (when `bundled-runtime` // 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. @@ -113,7 +114,7 @@ pub(crate) fn main() { let cache_key = format!("v{version}-{archive_name}"); let include_runtime = std::env::var_os("CARGO_FEATURE_IN_PROCESS").is_some(); - if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { + if std::env::var_os("CARGO_FEATURE_BUNDLED_RUNTIME").is_some() { let runtime_expected_hash = local_expected_hash .clone() .unwrap_or_else(|| fetch_in_process_release_hash(&version, platform.package_name)); @@ -125,29 +126,26 @@ pub(crate) fn main() { ); verify_runtime_package(&runtime_package, platform, &archive_name); - let cli_asset_name = platform.cli_asset_name(); - let cli_expected_hash = resolve_cli_hash(&version, &cli_asset_name); - let cli_archive = cached_download( - &format!( - "https://github.com/github/copilot-cli/releases/download/v{version}/{cli_asset_name}" - ), - &format!("v{version}-{cli_asset_name}"), - &cli_expected_hash, - &cache_dir, - ); - let cli_binary_size = verify_cli_archive(&cli_archive, platform, &cli_asset_name); - - emit_embedded( - out, - &cli_archive, - cli_binary_size, - &runtime_package, - platform, - include_runtime, - ); - println!("cargo:rustc-cfg=has_bundled_cli"); + emit_embedded_runtime(out, &runtime_package, platform, include_runtime); + println!("cargo:rustc-cfg=has_bundled_runtime"); + + if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { + let cli_asset_name = platform.cli_asset_name(); + let cli_expected_hash = resolve_cli_hash(&version, &cli_asset_name); + let cli_archive = cached_download( + &format!( + "https://github.com/github/copilot-cli/releases/download/v{version}/{cli_asset_name}" + ), + &format!("v{version}-{cli_asset_name}"), + &cli_expected_hash, + &cache_dir, + ); + let cli_binary_size = verify_cli_archive(&cli_archive, platform, &cli_asset_name); + emit_embedded_cli(out, &cli_archive, cli_binary_size); + println!("cargo:rustc-cfg=has_bundled_cli"); + } } else { - // With `bundled-cli` off the extracted runtime pair *is* the cache. + // Without bundling the extracted runtime pair *is* the cache. // Skip the upstream download entirely when both files already exist. // // Runtime resolution (see `src/resolve.rs::extracted_program`) @@ -210,31 +208,34 @@ pub(crate) fn main() { } } -/// Emit separate full-CLI and runtime payloads into `OUT_DIR` for embed mode. -fn emit_embedded( +fn emit_embedded_cli(out: &Path, archive: &[u8], binary_size: u64) { + 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. +pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive"); +pub(super) const CLI_BINARY_SIZE: u64 = {binary_size}; +"# + ); + std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); +} + +fn emit_embedded_runtime( out: &Path, - cli_archive: &[u8], - cli_binary_size: u64, runtime_package: &[u8], platform: Platform, include_runtime: bool, ) { - let runtime_archive = - build_embedded_runtime_archive(runtime_package, platform, include_runtime); - std::fs::write(out.join("copilot_cli.archive"), cli_archive) - .expect("failed to write copilot_cli.archive"); - std::fs::write(out.join("copilot_runtime.archive"), runtime_archive) + let archive = build_embedded_runtime_archive(runtime_package, platform, include_runtime); + std::fs::write(out.join("copilot_runtime.archive"), archive) .expect("failed to write copilot_runtime.archive"); - - let generated = format!( + std::fs::write( + out.join("bundled_runtime.rs"), 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 RUNTIME_ARCHIVE: &[u8] = include_bytes!("copilot_runtime.archive"); -pub(super) const CLI_BINARY_SIZE: u64 = {cli_binary_size}; -"# - ); - - std::fs::write(out.join("bundled_cli.rs"), generated).expect("failed to write bundled_cli.rs"); +"#, + ) + .expect("failed to write bundled_runtime.rs"); } fn build_embedded_runtime_archive( diff --git a/rust/scripts/check-bundled-runtime.sh b/rust/scripts/check-bundled-runtime.sh new file mode 100644 index 0000000000..478c8ea1e4 --- /dev/null +++ b/rust/scripts/check-bundled-runtime.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Exercise release bundling without the prepared CI runtime overriding it. +set -euo pipefail + +case "${1:-default}" in + default) features=bundled-runtime,test-support ;; + inprocess) features=bundled-runtime,in-process,test-support ;; + *) echo "Expected transport: default or inprocess" >&2; exit 1 ;; +esac + +# Unset rather than empty: build.rs opts out when these variables are present. +unset COPILOT_SKIP_CLI_DOWNLOAD DOCS_RS +unset COPILOT_CLI_PATH COPILOT_LEGACY_CLI_PATH COPILOT_CLI_EXTRACT_DIR +unset COPILOT_RUNTIME_BINARY_PATH COPILOT_RUNTIME_LIBRARY_PATH +unset COPILOT_RUNTIME_HOST_COMMAND COPILOT_RUNTIME_PROVIDER_LIB +unset COPILOT_EXTENSION_SDK_PATH COPILOT_SDK_DEFAULT_CONNECTION + +cargo test --locked --no-default-features --features "$features" \ + --test cli_resolution_test -- --test-threads=1 --nocapture diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index a0512200c0..43b81531fd 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -1,9 +1,9 @@ -//! 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). +//! Lazy installers for the native runtime and optional full CLI embedded by +//! build.rs. `bundled-runtime` embeds native assets; the default `bundled-cli` +//! feature additionally embeds the Node SEA. //! -//! Builds embed two platform release payloads from GitHub Releases: the full -//! CLI archive and a filtered runtime archive containing the wrapper, +//! Builds embed selected platform release payloads from GitHub Releases: the +//! optional full CLI archive and a filtered runtime archive containing the wrapper, //! `runtime.node`, auxiliary runtime assets, and optionally the in-process //! runtime library. Extraction to a real on-disk path is deferred until the //! relevant installer is called. @@ -31,61 +31,62 @@ // The atomic-publish + verify helpers (and their unit tests) are pure // std-only logic that doesn't touch the embedded archive, so they compile // whenever the binary is bundled *or* we're building the test harness — -// the standard `cargo test --no-default-features` job has `has_bundled_cli` +// the standard `cargo test --no-default-features` job has `has_bundled_runtime` // off but still needs to exercise them. -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] use std::collections::HashSet; -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] use std::fs; -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] use std::io::Read; -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::OnceLock; -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] use std::sync::atomic::{AtomicU64, Ordering}; -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] 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 both selected archives. +// On supported targets, build.rs generates glue for each selected archive. // 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)] +#[cfg(has_bundled_runtime)] mod build_time { + #[cfg(has_bundled_cli)] include!(concat!(env!("OUT_DIR"), "/bundled_cli.rs")); + include!(concat!(env!("OUT_DIR"), "/bundled_runtime.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)] +#[cfg(has_bundled_runtime)] 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))] +#[cfg(all(has_bundled_runtime, windows))] const CLI_BINARY_NAME: &str = "copilot.exe"; -#[cfg(all(has_bundled_cli, not(windows)))] +#[cfg(all(has_bundled_runtime, not(windows)))] const CLI_BINARY_NAME: &str = "copilot"; -#[cfg(all(has_bundled_cli, windows))] +#[cfg(all(has_bundled_runtime, windows))] const RUNTIME_BINARY_NAME: &str = "copilot-runtime.exe"; -#[cfg(all(has_bundled_cli, not(windows)))] +#[cfg(all(has_bundled_runtime, not(windows)))] const RUNTIME_BINARY_NAME: &str = "copilot-runtime"; -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] const RUNTIME_NODE_NAME: &str = "runtime.node"; -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] const RUNTIME_VERSION_MARKER: &str = ".copilot-runtime-version"; #[cfg(feature = "bundled-cli")] static INSTALLED_PATH: OnceLock> = OnceLock::new(); -#[cfg(feature = "bundled-cli")] +#[cfg(feature = "bundled-runtime")] static INSTALLED_RUNTIME_PATH: OnceLock> = OnceLock::new(); /// Returns the path to the installed CLI binary, lazily extracting the @@ -165,11 +166,11 @@ pub(crate) fn install_at(extract_dir: &Path) -> Option { /// Returns the path to the bundled runtime wrapper, extracting the wrapper and /// adjacent `runtime.node` on first call. -#[cfg(feature = "bundled-cli")] +#[cfg(feature = "bundled-runtime")] pub(crate) fn runtime_path() -> Option { INSTALLED_RUNTIME_PATH .get_or_init(|| { - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] { let dir = default_install_dir(CLI_VERSION); match install_runtime(&dir, build_time::RUNTIME_ARCHIVE) { @@ -189,9 +190,9 @@ pub(crate) fn runtime_path() -> Option { /// Installs the bundled runtime wrapper and adjacent `runtime.node` into a /// caller-specified directory. -#[cfg(feature = "bundled-cli")] +#[cfg(feature = "bundled-runtime")] pub(crate) fn install_runtime_at(extract_dir: &Path) -> Option { - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] { let install_dir = match runtime_install_dir(extract_dir, CLI_VERSION) { Ok(dir) => dir, @@ -210,14 +211,14 @@ pub(crate) fn install_runtime_at(extract_dir: &Path) -> Option { } } } - #[cfg(not(has_bundled_cli))] + #[cfg(not(has_bundled_runtime))] { let _ = extract_dir; } None } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn runtime_install_dir(base_dir: &Path, version: &str) -> Result { fs::create_dir_all(base_dir) .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; @@ -250,7 +251,7 @@ fn runtime_install_dir(base_dir: &Path, version: &str) -> Result PathBuf { let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir); let root = cache.join("github-copilot-sdk").join("cli"); @@ -269,19 +270,19 @@ fn default_install_dir(version: &str) -> PathBuf { const MAX_PUBLISH_ATTEMPTS: u32 = 3; // Natural platform shared-library name for the in-process FFI runtime. -#[cfg(all(has_bundled_cli, feature = "in-process", windows))] +#[cfg(all(has_bundled_runtime, feature = "in-process", windows))] const RUNTIME_LIBRARY_NAME: &str = "copilot_runtime.dll"; -#[cfg(all(has_bundled_cli, feature = "in-process", target_os = "macos"))] +#[cfg(all(has_bundled_runtime, feature = "in-process", target_os = "macos"))] const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.dylib"; #[cfg(all( - has_bundled_cli, + has_bundled_runtime, feature = "in-process", not(windows), not(target_os = "macos") ))] const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.so"; -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn install_runtime(install_dir: &Path, archive: &[u8]) -> Result { fs::create_dir_all(install_dir) .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; @@ -389,7 +390,7 @@ fn install_runtime(install_dir: &Path, archive: &[u8]) -> Result std::io::Result<()> { use std::ffi::{CString, c_char, c_int}; use std::os::unix::ffi::OsStrExt; @@ -422,7 +423,7 @@ fn check_runtime_wrapper_execute_access(path: &Path) -> std::io::Result<()> { } } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn selected_runtime_asset(path: &Path) -> bool { if path == Path::new(CLI_BINARY_NAME) { return false; @@ -439,7 +440,7 @@ fn selected_runtime_asset(path: &Path) -> bool { true } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn runtime_asset_path(path: &Path) -> Result { let invalid = || { EmbeddedCliError::with_message( @@ -495,7 +496,7 @@ fn runtime_asset_path(path: &Path) -> Result { Ok(normalized) } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn check_runtime_asset_parent( root: &Path, parent: &Path, @@ -533,13 +534,13 @@ fn check_runtime_asset_parent( Ok(()) } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] struct StagedRuntimeFile { temporary: PathBuf, target: PathBuf, } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] impl Drop for StagedRuntimeFile { fn drop(&mut self) { if let Err(error) = fs::remove_file(&self.temporary) @@ -550,7 +551,7 @@ impl Drop for StagedRuntimeFile { } } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn existing_runtime_file(target: &Path, size: u64) -> Result, EmbeddedCliError> { let metadata = match fs::symlink_metadata(target) { Ok(metadata) if metadata.is_file() => Some(metadata), @@ -578,7 +579,7 @@ fn existing_runtime_file(target: &Path, size: u64) -> Result, E Ok(None) } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn runtime_entry_matches( entry: &mut tar::Entry<'_, R>, installed: &mut fs::File, @@ -609,7 +610,7 @@ fn runtime_entry_matches( } } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn stage_runtime_entry( entry: &mut tar::Entry<'_, R>, target: &Path, @@ -627,7 +628,7 @@ fn stage_runtime_entry( Ok(staged) } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn write_runtime_entry( entry: &mut tar::Entry<'_, R>, file: &mut fs::File, @@ -826,7 +827,7 @@ fn write_temp_file(dir: &Path, contents: &[u8]) -> Result Result<(PathBuf, fs::File), EmbeddedCliError> { static COUNTER: AtomicU64 = AtomicU64::new(0); let nanos = std::time::SystemTime::now() @@ -852,7 +853,7 @@ fn create_temp_file(dir: &Path) -> Result<(PathBuf, fs::File), EmbeddedCliError> /// Rust uses rename on POSIX and MoveFileExW with MOVEFILE_REPLACE_EXISTING on /// Windows. Never unlink the destination on failure: readers must retain the /// previous complete file if replacement is blocked. -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] fn publish(tmp: &Path, final_path: &Path) -> Result<(), EmbeddedCliError> { fs::rename(tmp, final_path).map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Publish, e)) } @@ -964,7 +965,7 @@ fn extract_cli_binary(archive: &[u8]) -> Result, EmbeddedCliError> { Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into()) } -#[cfg(all(has_bundled_cli, any(not(windows), test)))] +#[cfg(any(all(has_bundled_cli, not(windows)), all(has_bundled_runtime, test)))] fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { let gz = flate2::read::GzDecoder::new(archive); let mut tar = tar::Archive::new(gz); @@ -989,7 +990,7 @@ fn extract_binary(archive: &[u8], binary_name: &str) -> Result, Embedded Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into()) } -#[cfg(has_bundled_cli)] +#[cfg(has_bundled_runtime)] fn sanitize_version(version: &str) -> String { version .chars() @@ -1000,7 +1001,7 @@ fn sanitize_version(version: &str) -> String { .collect() } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[allow(dead_code)] enum EmbeddedCliErrorKind { @@ -1017,7 +1018,7 @@ enum EmbeddedCliErrorKind { Blocked, } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] impl std::fmt::Display for EmbeddedCliErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -1041,13 +1042,13 @@ impl std::fmt::Display for EmbeddedCliErrorKind { } } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] #[allow(dead_code)] struct EmbeddedCliError { repr: crate::errors::Repr, } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] #[allow(dead_code)] impl EmbeddedCliError { fn new(kind: EmbeddedCliErrorKind, error: E) -> Self @@ -1083,7 +1084,7 @@ impl EmbeddedCliError { } } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] impl From for EmbeddedCliError { fn from(kind: EmbeddedCliErrorKind) -> Self { Self { @@ -1092,7 +1093,7 @@ impl From for EmbeddedCliError { } } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] impl std::fmt::Display for EmbeddedCliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.repr { @@ -1105,14 +1106,14 @@ impl std::fmt::Display for EmbeddedCliError { } } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] impl std::fmt::Debug for EmbeddedCliError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "EmbeddedCliError({self})") } } -#[cfg(any(has_bundled_cli, test))] +#[cfg(any(has_bundled_runtime, test))] impl std::error::Error for EmbeddedCliError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match &self.repr { @@ -1126,7 +1127,7 @@ impl std::error::Error for EmbeddedCliError { mod tests { use super::*; - #[cfg(all(has_bundled_cli, feature = "in-process"))] + #[cfg(has_bundled_runtime)] #[test] fn embedded_runtime_archive_contains_runtime_assets_and_excludes_cli() { let gz = flate2::read::GzDecoder::new(build_time::RUNTIME_ARCHIVE); @@ -1145,7 +1146,10 @@ mod tests { .collect(); names.sort(); + #[cfg(feature = "in-process")] assert!(names.contains(&RUNTIME_LIBRARY_NAME.to_string())); + #[cfg(not(feature = "in-process"))] + assert!(!names.iter().any(|name| name.contains("copilot_runtime"))); assert!(names.contains(&RUNTIME_BINARY_NAME.to_string())); assert!(names.contains(&RUNTIME_NODE_NAME.to_string())); assert!(names.iter().any(|name| name.starts_with("ripgrep/"))); @@ -1317,7 +1321,7 @@ mod tests { } } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_install_replaces_stale_pair() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1338,7 +1342,7 @@ mod tests { ); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn custom_runtime_install_dir_isolated_by_version() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1357,7 +1361,7 @@ mod tests { ); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] fn runtime_fixture(extra: &[(&str, &[u8], u32)]) -> Vec { let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); let mut archive = tar::Builder::new(encoder); @@ -1381,7 +1385,7 @@ mod tests { archive.into_inner().unwrap().finish().unwrap() } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn warm_runtime_rejects_same_size_corruption_despite_unchanged_metadata() { let dir = tempfile::tempdir().unwrap(); @@ -1402,7 +1406,7 @@ mod tests { assert_eq!(fs::read(&runtime).unwrap(), b"runtime"); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] fn assert_no_runtime_temps(dir: &Path) { for entry in fs::read_dir(dir).unwrap() { let entry = entry.unwrap(); @@ -1418,7 +1422,7 @@ mod tests { } } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_cold_install_and_warm_reuse_preserve_bytes_and_modes() { let dir = tempfile::tempdir().unwrap(); @@ -1455,7 +1459,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_repairs_same_size_corruption_truncation_and_extra_bytes() { let dir = tempfile::tempdir().unwrap(); @@ -1471,7 +1475,7 @@ mod tests { } } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_repairs_corruption_across_comparison_chunks() { let dir = tempfile::tempdir().unwrap(); @@ -1488,7 +1492,7 @@ mod tests { } } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn runtime_reuse_preserves_executable_caller_selected_permissions() { use std::os::unix::fs::PermissionsExt; @@ -1507,7 +1511,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn runtime_repairs_nonexecutable_wrapper() { use std::os::unix::fs::PermissionsExt; @@ -1527,7 +1531,7 @@ mod tests { } } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn runtime_rejects_nonexecutable_wrapper_in_readonly_cache() { use std::os::unix::fs::PermissionsExt; @@ -1561,7 +1565,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_archive_errors_do_not_publish_and_clean_up_staging() { let dir = tempfile::tempdir().unwrap(); @@ -1592,7 +1596,7 @@ mod tests { } } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_short_entry_is_rejected_without_publishing() { let dir = tempfile::tempdir().unwrap(); @@ -1611,7 +1615,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_missing_required_entry_is_rejected_before_publish() { let dir = tempfile::tempdir().unwrap(); @@ -1631,7 +1635,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_rejects_traversal_and_cleans_preceding_entries() { let dir = tempfile::tempdir().unwrap(); @@ -1643,7 +1647,7 @@ mod tests { assert_no_runtime_temps(&install_dir); } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn runtime_rejects_symlink_parents_and_targets() { use std::os::unix::fs::symlink; @@ -1668,7 +1672,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn concurrent_runtime_installers_publish_complete_files() { let dir = tempfile::tempdir().unwrap(); @@ -1691,7 +1695,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_duplicate_destinations_fail_on_cold_and_warm_installs() { for name in ["asset", "./asset"] { @@ -1720,7 +1724,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_case_aliases_cannot_overwrite_required_artifacts() { let names = [ @@ -1749,7 +1753,7 @@ mod tests { } } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_mixed_separator_and_case_aliases_are_rejected() { let dir = tempfile::tempdir().unwrap(); @@ -1771,7 +1775,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_nonportable_alias_paths_are_rejected_before_publish() { for name in [ @@ -1800,7 +1804,7 @@ mod tests { } } - #[cfg(all(has_bundled_cli, feature = "in-process"))] + #[cfg(all(has_bundled_runtime, feature = "in-process"))] #[test] fn runtime_library_aliases_are_rejected_before_repair() { let alias = format!("./{RUNTIME_LIBRARY_NAME}"); @@ -1822,26 +1826,26 @@ mod tests { } } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn warm_runtime_install_needs_no_writable_cache() { assert_read_only_runtime_cache(0o555, false); } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn warm_runtime_reuses_owner_only_immutable_cache() { assert_read_only_runtime_cache(0o500, false); } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn warm_runtime_reuses_nonexecutable_native_library() { assert_read_only_runtime_cache(0o555, true); assert_read_only_runtime_cache(0o500, true); } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] fn assert_read_only_runtime_cache(permission_mask: u32, readonly_native_library: bool) { use std::os::unix::fs::PermissionsExt; @@ -1890,7 +1894,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(all(has_bundled_cli, unix))] + #[cfg(all(has_bundled_runtime, unix))] #[test] fn runtime_repairs_unreadable_but_replaceable_file() { use std::os::unix::fs::PermissionsExt; @@ -1912,7 +1916,7 @@ mod tests { assert_no_runtime_temps(dir.path()); } - #[cfg(has_bundled_cli)] + #[cfg(has_bundled_runtime)] #[test] fn runtime_replacement_preserves_open_reader_contents() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 5c59b8c53b..2093d8921a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -13,15 +13,15 @@ pub use ahp_host::{ #[cfg(all( feature = "runtime", - not(feature = "bundled-cli"), + not(feature = "bundled-runtime"), not(feature = "local-runtime") ))] mod cache_paths; /// Canvas declarations, provider callbacks, and host-side canvas RPC types. pub mod canvas; mod canvas_dispatch; -/// Bundled CLI binary extraction and caching. -#[cfg(feature = "bundled-cli")] +/// Bundled runtime and CLI extraction and caching. +#[cfg(feature = "bundled-runtime")] pub(crate) mod embeddedcli; mod errors; /// Connection-level extension launch profile provider. @@ -243,6 +243,13 @@ impl From for CliProgram { /// extraction triggered by [`install_bundled_cli`]. pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli); +/// `true` when this build embeds the native runtime wrapper and its assets. +/// +/// Enabled by `bundled-runtime` (also implied by the default `bundled-cli`) +/// on supported targets unless build-time acquisition was disabled. This does +/// not trigger the lazy extraction performed by [`install_bundled_runtime`]. +pub const HAS_BUNDLED_RUNTIME: bool = cfg!(has_bundled_runtime); + /// Returns the path to the bundled Copilot CLI, extracting it from the /// embedded archive on first call. /// @@ -281,15 +288,15 @@ pub fn install_bundled_cli() -> Option { /// concrete managed runtime path before [`Client::start`]. Subsequent calls /// return the cached result. /// -/// Returns `None` when the `bundled-cli` feature is off, the target platform +/// Returns `None` when the `bundled-runtime` feature is off, the target platform /// isn't supported, or extraction failed. It does not fall back to the /// build-time extraction cache. pub fn install_bundled_runtime() -> Option { - #[cfg(feature = "bundled-cli")] + #[cfg(feature = "bundled-runtime")] { embeddedcli::runtime_path() } - #[cfg(not(feature = "bundled-cli"))] + #[cfg(not(feature = "bundled-runtime"))] { None } @@ -300,7 +307,7 @@ pub fn install_bundled_runtime() -> Option { /// When `program` is [`CliProgram::Resolve`] (the default), [`Client::start`] /// uses `COPILOT_CLI_PATH` when set to a real file. Managed child-process /// transports next use the bundled `copilot-runtime` wrapper. In-process -/// transport loads the wrapper's adjacent runtime library. With `bundled-cli` +/// transport loads the wrapper's adjacent runtime library. With `bundled-runtime` /// disabled, the corresponding artifact is resolved from the build-time /// extraction cache. /// @@ -432,8 +439,8 @@ pub struct ClientOptions { /// redirect the extraction (e.g. to a session-scoped temp directory in /// CI runners) without changing the global cache layout. /// - /// Only applies when the `bundled-cli` cargo feature is on (the - /// default). With `bundled-cli` disabled (`default-features = false`) + /// Only applies when the `bundled-runtime` cargo feature is on (the + /// default via `bundled-cli`). With bundling disabled /// 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 @@ -1070,8 +1077,8 @@ impl ClientOptions { /// Override the directory where bundled CLI and runtime artifacts are /// 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 + /// Only applies when the `bundled-runtime` cargo feature is on. With + /// bundling disabled, 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 diff --git a/rust/src/resolve.rs b/rust/src/resolve.rs index 1fbeb7c9f7..2f5328a112 100644 --- a/rust/src/resolve.rs +++ b/rust/src/resolve.rs @@ -6,9 +6,9 @@ //! [`CliProgram::Path`](crate::CliProgram::Path). //! 2. The `COPILOT_CLI_PATH` environment variable. //! 3. The bundled program embedded in this crate at build time (when the -//! `bundled-cli` cargo feature is on, the default). +//! `bundled-runtime` cargo feature is on, including the default `bundled-cli`). //! 4. The build-time-extracted program in the per-user cache (when -//! `bundled-cli` is off). +//! `bundled-runtime` is off). //! //! There is no PATH scanning and no walking of standard install locations. //! If none of the above resolves to a real file, @@ -26,7 +26,7 @@ use crate::{Error, ErrorKind}; /// CLI is extracted to. Called by `Client::start` to thread /// `ClientOptions::bundled_cli_extract_dir` through to /// `embeddedcli::install_at`. `extract_dir` only applies when the -/// `bundled-cli` feature is on — with it off the binary lives at a +/// `bundled-runtime` 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 @@ -48,7 +48,7 @@ pub(crate) fn copilot_binary_with_extract_dir( ); } - #[cfg(feature = "bundled-cli")] + #[cfg(feature = "bundled-runtime")] { let bundled = if use_runtime_wrapper { match extract_dir { @@ -56,9 +56,16 @@ pub(crate) fn copilot_binary_with_extract_dir( None => crate::embeddedcli::runtime_path(), } } else { - match extract_dir { - Some(dir) => crate::embeddedcli::install_at(dir), - None => crate::embeddedcli::path(), + #[cfg(feature = "bundled-cli")] + { + match extract_dir { + Some(dir) => crate::embeddedcli::install_at(dir), + None => crate::embeddedcli::path(), + } + } + #[cfg(not(feature = "bundled-cli"))] + { + None } }; if let Some(path) = bundled { @@ -69,7 +76,7 @@ pub(crate) fn copilot_binary_with_extract_dir( } } - #[cfg(not(feature = "bundled-cli"))] + #[cfg(not(feature = "bundled-runtime"))] { let _ = extract_dir; if let Some(program) = extracted_program(use_runtime_wrapper) { @@ -85,9 +92,10 @@ pub(crate) fn copilot_binary_with_extract_dir( Err(ErrorKind::BinaryNotFound { name: binary_name.into(), hint: Some( - "the Copilot CLI is not bundled in this build of github-copilot-sdk and \ - no applicable path override is set. Either keep the default `bundled-cli` cargo \ - feature enabled, set COPILOT_CLI_PATH, or supply an explicit path via \ + "no usable Copilot program could be resolved. Enable the `bundled-runtime` cargo \ + feature for the native runtime, or keep the default `bundled-cli` feature for \ + both the native runtime and Node CLI. If bundling is enabled, check the \ + extraction warnings. Alternatively, set COPILOT_CLI_PATH or supply an explicit path via \ `CliProgram::Path(...)` on `ClientOptions::program`." .into(), ), @@ -96,7 +104,7 @@ pub(crate) fn copilot_binary_with_extract_dir( } /// Path to the program extracted into the per-user cache by `build.rs` when -/// `bundled-cli` is disabled. Returns `None` if the cached file is missing +/// `bundled-runtime` 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`). /// @@ -108,7 +116,7 @@ pub(crate) fn copilot_binary_with_extract_dir( /// 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))] +#[cfg(all(not(feature = "bundled-runtime"), has_extracted_cli))] fn extracted_program(use_runtime_wrapper: bool) -> Option { let version = env!("COPILOT_SDK_CLI_VERSION"); let dir = crate::cache_paths::extracted_runtime_install_dir(version); @@ -135,12 +143,12 @@ fn extracted_program(use_runtime_wrapper: bool) -> Option { /// `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)))] +#[cfg(all(not(feature = "bundled-runtime"), not(has_extracted_cli)))] fn extracted_program(_use_runtime_wrapper: bool) -> Option { None } -#[cfg(any(feature = "bundled-cli", has_extracted_cli, test))] +#[cfg(any(feature = "bundled-runtime", has_extracted_cli, test))] fn validate_runtime_pair(wrapper: &Path) -> Result<(), Error> { let wrapper_valid = wrapper .metadata() diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index 97815a326e..f5e8c70f6e 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -9,15 +9,15 @@ use std::path::PathBuf; #[cfg(any( - all(feature = "bundled-cli", has_bundled_cli), - all(not(feature = "bundled-cli"), has_extracted_cli) + all(feature = "bundled-runtime", has_bundled_runtime), + all(not(feature = "bundled-runtime"), has_extracted_cli) ))] use github_copilot_sdk::ErrorKind; use github_copilot_sdk::{ - CliProgram, Client, ClientOptions, HAS_BUNDLED_CLI, install_bundled_cli, + CliProgram, Client, ClientOptions, HAS_BUNDLED_CLI, HAS_BUNDLED_RUNTIME, install_bundled_cli, install_bundled_runtime, }; -#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[cfg(all(feature = "bundled-runtime", has_bundled_runtime))] use github_copilot_sdk::{SessionConfig, Transport}; use serial_test::serial; @@ -85,8 +85,8 @@ async fn env_override_resolves_to_pointed_file() { /// A stale (non-existent) COPILOT_CLI_PATH falls through to the next /// resolution source (embed or dev) rather than failing outright. #[cfg(any( - all(feature = "bundled-cli", has_bundled_cli), - all(not(feature = "bundled-cli"), has_extracted_cli) + all(feature = "bundled-runtime", has_bundled_runtime), + all(not(feature = "bundled-runtime"), has_extracted_cli) ))] #[tokio::test(flavor = "current_thread")] #[serial(copilot_cli_path)] @@ -112,7 +112,7 @@ async fn stale_env_override_falls_through() { /// `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))] +#[cfg(all(not(feature = "bundled-runtime"), has_extracted_cli))] #[test] fn extracted_binary_present_at_conventional_path() { let version = env!("COPILOT_SDK_CLI_VERSION"); @@ -135,7 +135,7 @@ fn extracted_binary_present_at_conventional_path() { ); } -#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +#[cfg(all(not(feature = "bundled-runtime"), has_extracted_cli))] fn sanitize_version_for_test(version: &str) -> String { version .chars() @@ -150,7 +150,7 @@ fn sanitize_version_for_test(version: &str) -> String { /// 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))] +#[cfg(all(not(feature = "bundled-runtime"), has_extracted_cli))] #[tokio::test(flavor = "current_thread")] #[serial(copilot_cli_path)] async fn unbundled_resolver_finds_extracted_binary() { @@ -170,7 +170,7 @@ async fn unbundled_resolver_finds_extracted_binary() { /// 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). -#[cfg(all(not(feature = "bundled-cli"), has_extracted_cli))] +#[cfg(all(not(feature = "bundled-runtime"), has_extracted_cli))] #[tokio::test(flavor = "current_thread")] #[serial(copilot_cli_path)] async fn extract_dir_runtime_override_is_honored() { @@ -330,9 +330,10 @@ fn install_bundled_cli_is_none_without_embed() { ); } -#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[cfg(all(feature = "bundled-runtime", has_bundled_runtime))] #[test] fn install_bundled_runtime_returns_wrapper_bundle() { + const { assert!(HAS_BUNDLED_RUNTIME) }; let first = install_bundled_runtime().expect("bundled runtime should install"); assert_eq!( first.file_name().and_then(|name| name.to_str()), @@ -373,7 +374,7 @@ fn install_bundled_runtime_returns_wrapper_bundle() { assert_eq!(first, second); } -#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[cfg(all(feature = "bundled-runtime", has_bundled_runtime))] #[tokio::test(flavor = "current_thread")] #[serial(copilot_cli_path)] async fn bundled_runtime_clean_extract_starts_without_cli_host() { @@ -433,11 +434,66 @@ async fn bundled_runtime_clean_extract_starts_without_cli_host() { ); } -#[cfg(not(all(feature = "bundled-cli", has_bundled_cli)))] +#[cfg(not(all(feature = "bundled-runtime", has_bundled_runtime)))] #[test] fn install_bundled_runtime_is_none_without_embed() { + const { assert!(!HAS_BUNDLED_RUNTIME) }; assert!( install_bundled_runtime().is_none(), "install_bundled_runtime must not fall back to the dev-cache path" ); } + +/// Inspect the build output as well as the public installer: returning None +/// alone does not prove the unused SEA payload was omitted from the build. +#[cfg(all( + feature = "bundled-runtime", + not(feature = "bundled-cli"), + any(target_os = "macos", target_os = "windows", target_os = "linux"), + any(target_arch = "aarch64", target_arch = "x86_64") +))] +#[test] +fn native_only_build_does_not_generate_or_embed_cli_payload() { + if option_env!("COPILOT_SKIP_CLI_DOWNLOAD").is_some() || option_env!("DOCS_RS").is_some() { + return; + } + assert_eq!((HAS_BUNDLED_RUNTIME, HAS_BUNDLED_CLI), (true, false)); + let out = PathBuf::from(env!("OUT_DIR")); + assert!(out.join("copilot_runtime.archive").is_file()); + assert!(out.join("bundled_runtime.rs").is_file()); + assert!(!out.join("copilot_cli.archive").exists()); + assert!(!out.join("bundled_cli.rs").exists()); + assert!(install_bundled_cli().is_none()); +} + +#[cfg(all(has_bundled_runtime, feature = "in-process"))] +#[tokio::test(flavor = "current_thread")] +#[serial(copilot_cli_path)] +async fn bundled_runtime_clean_extract_starts_in_process() { + unset_env("COPILOT_CLI_PATH"); + let temp = tempfile::tempdir().expect("create tempdir"); + let options = ClientOptions::new() + .with_bundled_cli_extract_dir(temp.path()) + .with_transport(Transport::InProcess) + .with_use_logged_in_user(false); + let client = Client::start(options) + .await + .expect("start bundled in-process runtime from clean extraction"); + let response = client + .ping(Some("in-process runtime")) + .await + .expect("ping runtime"); + assert_eq!(response.message, "pong: in-process runtime"); + client.stop().await.expect("stop in-process runtime"); + assert!(temp.path().join("runtime.node").is_file()); + assert!( + !temp + .path() + .join(if cfg!(windows) { + "copilot.exe" + } else { + "copilot" + }) + .exists() + ); +}