From 283970daeee37b4d63e39bd8994e0d7d8ddca098 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:06:38 -0700 Subject: [PATCH] Restore Rust bundled CLI artifact Embed the full Copilot CLI separately from the managed runtime bundle so explicit callers receive the Node SEA while normal SDK resolution continues to use copilot-runtime. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/README.md | 52 ++++-- rust/build/in_process.rs | 171 ++++++++++++++----- rust/scripts/snapshot-bundled-cli-version.sh | 2 + rust/src/embeddedcli.rs | 131 +++++++++----- rust/src/lib.rs | 6 +- rust/tests/cli_resolution_test.rs | 138 +++++++++------ 6 files changed, 345 insertions(+), 155 deletions(-) diff --git a/rust/README.md b/rust/README.md index f236fc5b02..2de8a88a2d 100644 --- a/rust/README.md +++ b/rust/README.md @@ -103,7 +103,7 @@ transports. | `transport` | `Transport` | `Default`, `Stdio`, `InProcess`, `Tcp`, or `External` | | `extension_launch_provider` | `Option>` | Connection-global extension launch resolver | -With the default `CliProgram::Resolve`, managed stdio and TCP transports resolve an explicit `CliProgram::Path(path)`, `COPILOT_CLI_PATH`, then the bundled `copilot-runtime` wrapper and adjacent `runtime.node`. In-process transport retains its CLI-entrypoint resolution. There is no PATH scanning. +With the default `CliProgram::Resolve`, managed stdio and TCP transports resolve an explicit `CliProgram::Path(path)`, `COPILOT_CLI_PATH`, then the bundled `copilot-runtime` wrapper and adjacent `runtime.node`. In-process transport loads the native runtime library adjacent to that resolved runtime bundle. There is no PATH scanning. #### Extension launch provider @@ -977,12 +977,13 @@ none of them are scheduled for removal. ## Bundled runtime artifacts -The SDK provisions its runtime at build time. By default the `bundled-cli` -feature embeds the verified `copilot-runtime` wrapper and adjacent -`runtime.node` in your compiled crate. The compatible CLI artifact remains -available separately for `install_bundled_cli` and in-process hosting. -Enable `bundled-in-process` to additionally embed the native runtime library -and use `Transport::InProcess`: +The SDK provisions two verified artifacts at build time. By default the +`bundled-cli` feature embeds both the full Copilot CLI/Node SEA and a separate +runtime bundle containing `copilot-runtime`, adjacent `runtime.node`, and its +required assets. Managed transports use only the runtime bundle; the full CLI +is available through `install_bundled_cli` for diagnostics and version probes. +Enable `bundled-in-process` to additionally include the native runtime library +in the runtime bundle and use `Transport::InProcess`: ```toml github-copilot-sdk = { version = "1", features = ["bundled-in-process"] } @@ -1017,17 +1018,24 @@ github-copilot-sdk = { version = "1", default-features = false } ### How it works 1. **Version pin.** `build.rs` reads the CLI version from one of two sources: - - `cli-version.txt` at the crate root (present in published crate tarballs and vendored slots). + - `cli-version.txt` and `cli-version-in-process.txt` at the crate root + (present in published crate tarballs and vendored slots). - Otherwise, `../nodejs/package.json` (contributor build inside the github/copilot-sdk repo). The resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` regardless of mode. The runtime resolver consumes it to recompute the on-disk path by convention, so no absolute paths leak into the rlib. -2. **Build time:** `build.rs` downloads the platform-specific release archive and - verifies its SHA-256 against the release's `SHA256SUMS.txt` or the publish snapshot. +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 + `SHA256SUMS.txt` or the publish snapshots. Then: - - **`bundled-cli` on (default):** creates and embeds a minimal archive containing the CLI executable, `copilot-runtime[.exe]`, and `runtime.node`. - - **`bundled-in-process` on:** the archive additionally contains the platform-native runtime library (`.dll`, `.so`, or `.dylib`). - - **`bundled-cli` off:** extracts the same artifacts directly into the platform cache using staging files and atomic renames. + - **`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-in-process` on:** the runtime archive additionally contains the + platform-native runtime library (`.dll`, `.so`, or `.dylib`). + - **`bundled-cli` 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:** in both modes the artifacts share one versioned directory: @@ -1075,9 +1083,9 @@ For managed child-process transports, `Client::start` resolves the program in th 3. **`bundled-cli` on:** the embedded wrapper pair, lazily extracted on first call. 4. **`bundled-cli` off:** the build-time-extracted wrapper pair in the per-user cache. -In-process transport resolves the compatible CLI artifact from -`COPILOT_CLI_PATH`, the embedded archive, or the build-time cache. There is no -PATH scanning. +In-process transport loads the native runtime library adjacent to the runtime +wrapper selected from `COPILOT_CLI_PATH`, the embedded runtime archive, or the +build-time cache. There is no PATH scanning. ### Reaching the bundled binary without a `Client` @@ -1118,11 +1126,19 @@ returns the wrapper path. ### Download cache (build-time, embed mode) -In embed mode `build.rs` re-downloads on every clean build by default. Set `BUNDLED_CLI_CACHE_DIR=` to cache the verified archive between builds (CI keys this on `-` for ~zero-cost rebuilds on cache hits). With `bundled-cli` disabled there is no separate archive cache — the extracted binary itself is the cache. +In embed mode `build.rs` downloads both 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 `bundled-cli` disabled +there is no separate archive cache: the extracted runtime bundle is the cache. ### Platforms -Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`, `win32-arm64`. The target platform is auto-detected from `CARGO_CFG_TARGET_OS` and `CARGO_CFG_TARGET_ARCH` (cross-compilation works). +Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, +`linuxmusl-x64`, `linuxmusl-arm64`, `win32-x64`, and `win32-arm64`. The target +platform is auto-detected from `CARGO_CFG_TARGET_OS`, `CARGO_CFG_TARGET_ARCH`, +and `CARGO_CFG_TARGET_ENV` (cross-compilation works). ## Features diff --git a/rust/build/in_process.rs b/rust/build/in_process.rs index c01e7ffc4f..c1aa583f9c 100644 --- a/rust/build/in_process.rs +++ b/rust/build/in_process.rs @@ -11,6 +11,7 @@ pub(crate) fn main() { println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR"); println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)"); println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)"); + println!("cargo:rerun-if-changed=cli-version.txt"); println!("cargo:rerun-if-changed=cli-version-in-process.txt"); // Only declare the package metadata rerun when it actually exists. @@ -19,7 +20,7 @@ pub(crate) fn main() { // `nodejs/` (vendored slots, published crates) would force build.rs // to re-run on every `cargo build` even when nothing has changed. // The package file is only the source-of-truth in this repo's - // contributor builds; everywhere else `cli-version-in-process.txt` is canonical. + // contributor builds; everywhere else the snapshot files are canonical. let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); let package_json = Path::new(&manifest_dir) .join("..") @@ -92,12 +93,37 @@ pub(crate) fn main() { let include_runtime = std::env::var_os("CARGO_FEATURE_BUNDLED_IN_PROCESS").is_some(); if std::env::var_os("CARGO_FEATURE_BUNDLED_CLI").is_some() { - let expected_hash = local_expected_hash + let runtime_expected_hash = local_expected_hash .clone() .unwrap_or_else(|| fetch_in_process_release_hash(&version, platform.package_name)); - let archive = cached_download(&download_url, &cache_key, &expected_hash, &cache_dir); - verify_runtime_package(&archive, platform, &archive_name); - emit_embedded(out, &archive, platform, include_runtime); + let runtime_package = cached_download( + &download_url, + &cache_key, + &runtime_expected_hash, + &cache_dir, + ); + 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!( + "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/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"); } else { // With `bundled-cli` off the extracted runtime pair *is* the cache. @@ -185,30 +211,43 @@ fn extracted_install_dir(version: &str) -> PathBuf { } } -/// Emit the `bundled_cli.rs` glue + `copilot_cli.archive` blob into `OUT_DIR` -/// for embed mode (`bundled-cli` cargo feature on). The version is exposed -/// crate-wide via the unconditional `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` -/// emit; the binary name is OS-derived at runtime — so all we need to -/// generate here is the archive blob include. -fn emit_embedded(out: &Path, package: &[u8], platform: Platform, include_runtime: bool) { - let archive = build_embedded_archive(package, platform, include_runtime); - std::fs::write(out.join("copilot_cli.archive"), archive) +/// Emit separate full-CLI and runtime payloads into `OUT_DIR` for embed mode. +fn emit_embedded( + 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) + .expect("failed to write copilot_runtime.archive"); - let generated = r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit. + 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) 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"); } -fn build_embedded_archive(package: &[u8], platform: Platform, include_runtime: bool) -> Vec { +fn build_embedded_runtime_archive( + package: &[u8], + platform: Platform, + include_runtime: bool, +) -> Vec { let encoder = flate2::GzBuilder::new() .mtime(0) .write(Vec::new(), flate2::Compression::default()); let mut archive = tar::Builder::new(encoder); - let (runtime, wrapper) = append_hostless_runtime_tree(&mut archive, package, platform); - append_archive_file(&mut archive, platform.binary_name, &wrapper, 0o755); + let runtime = append_hostless_runtime_tree(&mut archive, package, platform); if include_runtime { append_archive_file( &mut archive, @@ -229,11 +268,10 @@ fn append_hostless_runtime_tree( archive: &mut tar::Builder, package: &[u8], platform: Platform, -) -> (Vec, Vec) { +) -> Vec { let decoder = flate2::read::GzDecoder::new(package); let mut source = tar::Archive::new(decoder); let mut runtime = None; - let mut wrapper = None; for entry in source .entries() .unwrap_or_else(|e| panic!("failed to read npm package entries: {e}")) @@ -257,9 +295,6 @@ fn append_hostless_runtime_tree( if destination == Path::new("runtime.node") { runtime = Some(bytes.clone()); } - if destination == Path::new(platform.runtime_wrapper_name()) { - wrapper = Some(bytes.clone()); - } append_archive_file( archive, destination @@ -269,21 +304,12 @@ fn append_hostless_runtime_tree( mode, ); } - ( - runtime.unwrap_or_else(|| { - panic!( - "package `{}` does not contain prebuilds//runtime.node", - platform.package_name - ) - }), - wrapper.unwrap_or_else(|| { - panic!( - "package `{}` does not contain prebuilds//{}", - platform.package_name, - platform.runtime_wrapper_name() - ) - }), - ) + runtime.unwrap_or_else(|| { + panic!( + "package `{}` does not contain prebuilds//runtime.node", + platform.package_name + ) + }) } fn hostless_runtime_path(source: &str, platform: Platform) -> Option { @@ -397,6 +423,25 @@ fn fetch_in_process_release_hash(version: &str, package_name: &str) -> String { fetch_release_hash(version, &asset_name) } +fn resolve_cli_hash(version: &str, asset_name: &str) -> String { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"); + let snapshot = Path::new(&manifest_dir).join("cli-version.txt"); + if snapshot.is_file() { + let contents = std::fs::read_to_string(&snapshot) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", snapshot.display())); + let (snapshot_version, hash) = parse_snapshot(&contents, asset_name) + .unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display())); + assert_eq!( + snapshot_version, + version, + "{} and the selected runtime version source must pin the same version", + snapshot.display() + ); + return hash; + } + fetch_release_hash(version, asset_name) +} + fn marker_matches_version(contents: &str, version: &str) -> bool { let mut lines = contents.lines(); lines.next() == Some(version) @@ -471,6 +516,19 @@ struct Platform { } impl Platform { + fn cli_asset_name(&self) -> String { + let platform = self + .package_name + .strip_prefix("copilot-") + .expect("platform package name has copilot- prefix"); + let extension = if self.package_name.contains("win32") { + "zip" + } else { + "tar.gz" + }; + format!("copilot-{platform}.{extension}") + } + fn runtime_wrapper_name(&self) -> &'static str { if self.package_name.contains("win32") { "copilot-runtime.exe" @@ -889,11 +947,29 @@ fn verify_runtime_package(archive: &[u8], platform: Platform, package_name: &str } } +fn verify_cli_archive(archive: &[u8], platform: Platform, archive_name: &str) -> u64 { + let binary_size = if platform.package_name.contains("win32") { + archive_zip_entry_size(archive, platform.binary_name) + } else { + archive_tar_entry_size(archive, platform.binary_name) + }; + binary_size.unwrap_or_else(|| { + panic!( + "Copilot CLI archive `{archive_name}` does not contain an entry named `{}`", + platform.binary_name + ) + }) +} + fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { + archive_tar_entry_size(targz, binary_name).is_some() +} + +fn archive_tar_entry_size(targz: &[u8], binary_name: &str) -> Option { let gz = flate2::read::GzDecoder::new(targz); let mut archive = tar::Archive::new(gz); let Ok(entries) = archive.entries() else { - return false; + return None; }; for entry in entries.flatten() { let Ok(path) = entry.path() else { @@ -901,10 +977,23 @@ fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool { }; let name = path.to_string_lossy(); if name == binary_name || name.ends_with(&format!("/{binary_name}")) { - return true; + return Some(entry.size()); } } - false + None +} + +fn archive_zip_entry_size(zip_bytes: &[u8], binary_name: &str) -> Option { + let reader = std::io::Cursor::new(zip_bytes); + let Ok(mut archive) = zip::ZipArchive::new(reader) else { + return None; + }; + (0..archive.len()).find_map(|index| { + archive.by_index(index).ok().and_then(|entry| { + (entry.name() == binary_name || entry.name().ends_with(&format!("/{binary_name}"))) + .then(|| entry.size()) + }) + }) } fn verify_hash(data: &[u8], expected: &str) -> bool { diff --git a/rust/scripts/snapshot-bundled-cli-version.sh b/rust/scripts/snapshot-bundled-cli-version.sh index 08b19ebc2b..0045f5e6c8 100755 --- a/rust/scripts/snapshot-bundled-cli-version.sh +++ b/rust/scripts/snapshot-bundled-cli-version.sh @@ -38,6 +38,8 @@ ASSETS=( "copilot-darwin-x64.tar.gz" "copilot-linux-arm64.tar.gz" "copilot-linux-x64.tar.gz" + "copilot-linuxmusl-arm64.tar.gz" + "copilot-linuxmusl-x64.tar.gz" "copilot-win32-arm64.zip" "copilot-win32-x64.zip" ) diff --git a/rust/src/embeddedcli.rs b/rust/src/embeddedcli.rs index 3cc527a2e2..574ed42a6f 100644 --- a/rust/src/embeddedcli.rs +++ b/rust/src/embeddedcli.rs @@ -2,11 +2,11 @@ //! crate (gated on the `bundled-cli` cargo feature, which is in the default //! feature set). //! -//! Normal builds embed the platform release archive from GitHub Releases. -//! Builds with `bundled-in-process` instead embed a filtered archive from the -//! platform npm package containing the CLI executable, runtime wrapper, native -//! runtime artifacts, and auxiliary runtime assets. Extraction to a real -//! on-disk path is deferred until the relevant installer is called. +//! Builds embed two platform release payloads from GitHub Releases: the 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. //! //! The embedded bytes are part of the consumer's signed binary and therefore //! trusted *as the source of truth* — but the bytes that land on disk are not. @@ -41,7 +41,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use tracing::{info, warn}; // When the `bundled-cli` cargo feature is enabled and the target platform is -// supported, build.rs generates `bundled_cli.rs` exposing the selected archive. +// supported, build.rs generates `bundled_cli.rs` exposing both selected archives. // 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. @@ -101,7 +101,11 @@ pub(crate) fn path() -> Option { #[cfg(has_bundled_cli)] { let dir = default_install_dir(CLI_VERSION); - match install_cli_bundle(&dir, build_time::CLI_ARCHIVE) { + match install_cli( + &dir, + build_time::CLI_ARCHIVE, + build_time::CLI_BINARY_SIZE, + ) { Ok(path) => { info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed"); return Some(path); @@ -129,7 +133,11 @@ pub(crate) fn path() -> Option { pub(crate) fn install_at(extract_dir: &Path) -> Option { #[cfg(has_bundled_cli)] { - match install_cli_bundle(extract_dir, build_time::CLI_ARCHIVE) { + match install_cli( + extract_dir, + build_time::CLI_ARCHIVE, + build_time::CLI_BINARY_SIZE, + ) { Ok(path) => { info!(path = %path.display(), version = CLI_VERSION, "embedded CLI installed"); return Some(path); @@ -155,7 +163,7 @@ pub(crate) fn runtime_path() -> Option { #[cfg(has_bundled_cli)] { let dir = default_install_dir(CLI_VERSION); - match install_runtime(&dir, build_time::CLI_ARCHIVE) { + match install_runtime(&dir, build_time::RUNTIME_ARCHIVE) { Ok(path) => { info!(path = %path.display(), version = CLI_VERSION, "embedded runtime installed"); return Some(path); @@ -183,7 +191,7 @@ pub(crate) fn install_runtime_at(extract_dir: &Path) -> Option { return None; } }; - match install_runtime(&install_dir, build_time::CLI_ARCHIVE) { + match install_runtime(&install_dir, build_time::RUNTIME_ARCHIVE) { Ok(path) => { info!(path = %path.display(), version = CLI_VERSION, "embedded runtime installed"); return Some(path); @@ -264,23 +272,14 @@ const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.dylib"; ))] const RUNTIME_LIBRARY_NAME: &str = "libcopilot_runtime.so"; -#[cfg(has_bundled_cli)] -fn install_cli_bundle(install_dir: &Path, archive: &[u8]) -> Result { - install_cli(install_dir, archive)?; - install_hostless_assets(install_dir, archive)?; - #[cfg(feature = "bundled-in-process")] - { - install_runtime_library(install_dir, archive)?; - } - Ok(install_dir.join(CLI_BINARY_NAME)) -} - #[cfg(has_bundled_cli)] fn install_runtime(install_dir: &Path, archive: &[u8]) -> Result { fs::create_dir_all(install_dir) .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::CreateDir, e))?; install_hostless_assets(install_dir, archive)?; install_runtime_pair(install_dir, archive)?; + #[cfg(feature = "bundled-in-process")] + install_runtime_library(install_dir, archive)?; Ok(install_dir.join(RUNTIME_BINARY_NAME)) } @@ -414,7 +413,11 @@ fn install_adjacent_file( } #[cfg(has_bundled_cli)] -fn install_cli(install_dir: &Path, archive: &[u8]) -> Result { +fn install_cli( + install_dir: &Path, + archive: &[u8], + expected_binary_size: u64, +) -> Result { let verbose = std::env::var("COPILOT_CLI_INSTALL_VERBOSE").ok().as_deref() == Some("1"); fs::create_dir_all(install_dir) @@ -427,7 +430,7 @@ fn install_cli(install_dir: &Path, archive: &[u8]) -> Result Result PathBuf { /// modes (zero-length / truncated / quarantined-to-garbage) without re-reading /// the whole file. #[cfg(any(has_bundled_cli, test))] -fn existing_install_is_valid(final_path: &Path, marker_path: &Path) -> bool { +fn existing_install_is_valid( + final_path: &Path, + marker_path: &Path, + expected_binary_size: u64, +) -> bool { let Ok(meta) = fs::metadata(final_path) else { return false; }; @@ -503,7 +510,9 @@ fn existing_install_is_valid(final_path: &Path, marker_path: &Path) -> bool { return false; } match read_marker_len(marker_path) { - Some(expected) if expected == meta.len() => looks_like_valid_image(final_path), + Some(expected) if expected == expected_binary_size && expected == meta.len() => { + looks_like_valid_image(final_path) + } _ => false, } } @@ -694,6 +703,32 @@ fn read_marker_len(marker_path: &Path) -> Option { .ok() } +#[cfg(all(has_bundled_cli, not(windows)))] +fn extract_cli_binary(archive: &[u8]) -> Result, EmbeddedCliError> { + extract_binary(archive, CLI_BINARY_NAME) +} + +#[cfg(all(has_bundled_cli, windows))] +fn extract_cli_binary(archive: &[u8]) -> Result, EmbeddedCliError> { + let reader = std::io::Cursor::new(archive); + let mut zip = zip::ZipArchive::new(reader) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + for index in 0..zip.len() { + let mut entry = zip + .by_index(index) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + if entry.name() == CLI_BINARY_NAME || entry.name().ends_with(&format!("/{CLI_BINARY_NAME}")) + { + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .map_err(|e| EmbeddedCliError::new(EmbeddedCliErrorKind::Archive, e))?; + return Ok(bytes); + } + } + Err(EmbeddedCliErrorKind::BinaryNotFoundInArchive.into()) +} + #[cfg(has_bundled_cli)] fn extract_binary(archive: &[u8], binary_name: &str) -> Result, EmbeddedCliError> { let gz = flate2::read::GzDecoder::new(archive); @@ -858,8 +893,8 @@ mod tests { #[cfg(all(has_bundled_cli, feature = "bundled-in-process"))] #[test] - fn embedded_archive_contains_runtime_assets_and_excludes_cli_only_files() { - let gz = flate2::read::GzDecoder::new(build_time::CLI_ARCHIVE); + fn embedded_runtime_archive_contains_runtime_assets_and_excludes_cli() { + let gz = flate2::read::GzDecoder::new(build_time::RUNTIME_ARCHIVE); let mut archive = tar::Archive::new(gz); let mut names: Vec = archive .entries() @@ -875,12 +910,12 @@ mod tests { .collect(); names.sort(); - assert!(names.contains(&CLI_BINARY_NAME.to_string())); assert!(names.contains(&RUNTIME_LIBRARY_NAME.to_string())); 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/"))); assert!(names.iter().any(|name| name.starts_with("definitions/"))); + assert!(!names.contains(&CLI_BINARY_NAME.to_string())); assert!(!names.contains(&"app.js".to_string())); } @@ -911,7 +946,11 @@ mod tests { assert!(final_path.is_file(), "binary should be published"); assert_eq!(fs::read(&final_path).expect("read"), bytes); assert_eq!(read_marker_len(&marker), Some(bytes.len() as u64)); - assert!(existing_install_is_valid(&final_path, &marker)); + assert!(existing_install_is_valid( + &final_path, + &marker, + bytes.len() as u64 + )); // No leftover temp files in the install dir. let leftovers: Vec<_> = fs::read_dir(dir.path()) @@ -945,28 +984,40 @@ mod tests { let bytes = fake_image(4096); // Missing binary entirely. - assert!(!existing_install_is_valid(&final_path, &marker)); + assert!(!existing_install_is_valid(&final_path, &marker, 1)); // Valid binary but no marker (e.g. installed by an older SDK). fs::write(&final_path, &bytes).expect("write binary"); assert!( - !existing_install_is_valid(&final_path, &marker), + !existing_install_is_valid(&final_path, &marker, bytes.len() as u64), "an install without a marker must not be trusted" ); // Marker present but the binary was later truncated (partial write / // antivirus). Marker still records the original full size. write_marker(&marker, bytes.len() as u64).expect("marker"); - assert!(existing_install_is_valid(&final_path, &marker)); + assert!(existing_install_is_valid( + &final_path, + &marker, + bytes.len() as u64 + )); + assert!( + !existing_install_is_valid(&final_path, &marker, bytes.len() as u64 + 1), + "a marker from the wrapper-as-CLI regression must not validate the full CLI" + ); fs::write(&final_path, &bytes[..bytes.len() / 2]).expect("truncate"); assert!( - !existing_install_is_valid(&final_path, &marker), + !existing_install_is_valid(&final_path, &marker, bytes.len() as u64), "a truncated binary must be detected via the size marker" ); // Zero-length binary (quarantined to empty). fs::write(&final_path, b"").expect("empty"); - assert!(!existing_install_is_valid(&final_path, &marker)); + assert!(!existing_install_is_valid( + &final_path, + &marker, + bytes.len() as u64 + )); } #[test] @@ -981,7 +1032,7 @@ mod tests { write_marker(&marker, garbage.len() as u64).expect("marker"); assert!( - !existing_install_is_valid(&final_path, &marker), + !existing_install_is_valid(&final_path, &marker, garbage.len() as u64), "a non-executable image must be rejected even with a matching marker" ); } @@ -1038,15 +1089,17 @@ mod tests { fs::write(dir.path().join(RUNTIME_NODE_NAME), b"stale runtime").expect("seed runtime"); fs::write(dir.path().join(RUNTIME_BINARY_NAME), b"stale wrapper").expect("seed wrapper"); - install_runtime(dir.path(), build_time::CLI_ARCHIVE).expect("install runtime"); + install_runtime(dir.path(), build_time::RUNTIME_ARCHIVE).expect("install runtime"); assert_eq!( fs::read(dir.path().join(RUNTIME_NODE_NAME)).expect("read runtime"), - extract_binary(build_time::CLI_ARCHIVE, RUNTIME_NODE_NAME).expect("extract runtime") + extract_binary(build_time::RUNTIME_ARCHIVE, RUNTIME_NODE_NAME) + .expect("extract runtime") ); assert_eq!( fs::read(dir.path().join(RUNTIME_BINARY_NAME)).expect("read wrapper"), - extract_binary(build_time::CLI_ARCHIVE, RUNTIME_BINARY_NAME).expect("extract wrapper") + extract_binary(build_time::RUNTIME_ARCHIVE, RUNTIME_BINARY_NAME) + .expect("extract wrapper") ); } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7d15d8f35c..c95ed2087a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -215,9 +215,9 @@ pub const HAS_BUNDLED_CLI: bool = cfg!(has_bundled_cli); /// Returns the path to the bundled Copilot CLI, extracting it from the /// embedded archive on first call. /// -/// This exposes the CLI artifact directly for callers such as health checks, -/// diagnostics, version probes, and in-process hosting. Managed child-process -/// transports resolve the bundled `copilot-runtime` wrapper instead. +/// This exposes the full CLI artifact directly for callers such as health +/// checks, diagnostics, and version probes. Managed child-process and +/// in-process transports resolve the bundled runtime artifacts instead. /// /// Subsequent calls return the cached result. Extraction is skipped when /// an already-published binary passes a cheap integrity re-check; a diff --git a/rust/tests/cli_resolution_test.rs b/rust/tests/cli_resolution_test.rs index 847ac7a4d0..846fbacd27 100644 --- a/rust/tests/cli_resolution_test.rs +++ b/rust/tests/cli_resolution_test.rs @@ -206,45 +206,45 @@ async fn extract_dir_runtime_override_is_honored() { #[test] fn pin_file_when_present_is_well_formed() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let (filename, expected_package_count) = if cfg!(feature = "bundled-in-process") { - ("cli-version-in-process.txt", 8) - } else { - ("cli-version.txt", 6) - }; - let pin = PathBuf::from(manifest_dir).join(filename); - if !pin.is_file() { - // Contributor build path — no assertion needed. - return; - } - let contents = std::fs::read_to_string(&pin).expect("read CLI version snapshot"); - let mut saw_version = false; - let mut package_count = 0; - for raw in contents.lines() { - let line = raw.trim(); - if line.is_empty() || line.starts_with('#') { + for filename in ["cli-version.txt", "cli-version-in-process.txt"] { + let pin = PathBuf::from(manifest_dir).join(filename); + if !pin.is_file() { + // Contributor build path — no assertion needed. continue; } - let (key, value) = line - .split_once('=') - .unwrap_or_else(|| panic!("malformed line: {raw:?}")); - assert!(!value.trim().is_empty(), "empty value for key {key:?}"); - if key.trim() == "version" { - saw_version = true; - } else { - assert_eq!( - value.trim().len(), - 64, - "invalid SHA-256 hash for key {key:?}" - ); - assert!( - value.trim().bytes().all(|byte| byte.is_ascii_hexdigit()), - "invalid SHA-256 hash for key {key:?}" - ); - package_count += 1; + let contents = std::fs::read_to_string(&pin).expect("read CLI version snapshot"); + let mut saw_version = false; + let mut package_count = 0; + for raw in contents.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line + .split_once('=') + .unwrap_or_else(|| panic!("malformed line: {raw:?}")); + assert!(!value.trim().is_empty(), "empty value for key {key:?}"); + if key.trim() == "version" { + saw_version = true; + } else { + assert_eq!( + value.trim().len(), + 64, + "invalid SHA-256 hash for key {key:?}" + ); + assert!( + value.trim().bytes().all(|byte| byte.is_ascii_hexdigit()), + "invalid SHA-256 hash for key {key:?}" + ); + package_count += 1; + } } + assert!(saw_version, "{filename} missing `version=` line"); + assert_eq!( + package_count, 8, + "{filename} has incomplete platform hashes" + ); } - assert!(saw_version, "{filename} missing `version=` line"); - assert_eq!(package_count, expected_package_count); } /// With `bundled-cli` on AND a supported target, `install_bundled_cli` @@ -274,26 +274,38 @@ fn install_bundled_cli_returns_extracted_path() { first, second, "install_bundled_cli must be idempotent across calls" ); +} - #[cfg(feature = "bundled-in-process")] - { - let runtime_name = if cfg!(windows) { - "copilot_runtime.dll" - } else if cfg!(target_os = "macos") { - "libcopilot_runtime.dylib" - } else { - "libcopilot_runtime.so" - }; - let runtime = first - .parent() - .expect("install directory") - .join(runtime_name); - assert!( - runtime.is_file(), - "bundled runtime library was not installed: {}", - runtime.display() - ); - } +#[cfg(all(feature = "bundled-cli", has_bundled_cli))] +#[test] +fn bundled_cli_is_distinct_from_runtime_and_supports_version_probe() { + let cli = install_bundled_cli().expect("bundled CLI should install"); + let runtime = install_bundled_runtime().expect("bundled runtime should install"); + + assert_ne!(cli, runtime); + assert_ne!( + std::fs::metadata(&cli).expect("CLI metadata").len(), + std::fs::metadata(&runtime) + .expect("runtime wrapper metadata") + .len(), + "the full CLI must not alias the runtime wrapper bytes" + ); + + let output = std::process::Command::new(&cli) + .arg("--binary-version") + .output() + .expect("run bundled CLI version probe"); + assert!( + output.status.success(), + "bundled CLI version probe failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains(env!("COPILOT_SDK_CLI_VERSION")), + "bundled CLI version output did not contain {}: {}", + env!("COPILOT_SDK_CLI_VERSION"), + String::from_utf8_lossy(&output.stdout) + ); } /// With `bundled-cli` off (or the target unsupported), the public API @@ -330,6 +342,24 @@ fn install_bundled_runtime_returns_wrapper_bundle() { "runtime.node was not installed: {}", runtime_node.display() ); + #[cfg(feature = "bundled-in-process")] + { + let runtime_library = first + .parent() + .expect("install directory") + .join(if cfg!(windows) { + "copilot_runtime.dll" + } else if cfg!(target_os = "macos") { + "libcopilot_runtime.dylib" + } else { + "libcopilot_runtime.so" + }); + assert!( + runtime_library.is_file(), + "bundled runtime library was not installed: {}", + runtime_library.display() + ); + } let second = install_bundled_runtime().expect("second call should also succeed"); assert_eq!(first, second); }