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 1/3] 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!( + "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"); } 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); } From bfc138dfe13be707b0187058ea317eff144d453a Mon Sep 17 00:00:00 2001 From: "John Bufe (he/him) (from Dev Box)" Date: Tue, 1 Sep 2026 13:06:58 -0400 Subject: [PATCH 2/3] Add Rust model allowlist support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/generated/api_types.rs | 68 +++++++++++++++++++++++ rust/src/generated/rpc.rs | 36 +++++++++++++ rust/src/types.rs | 91 +++++++++++++++++++++++++++++++ rust/src/wire.rs | 4 ++ rust/tests/session_test.rs | 95 ++++++++++++++++++++++++++++++++- 5 files changed, 293 insertions(+), 1 deletion(-) diff --git a/rust/src/generated/api_types.rs b/rust/src/generated/api_types.rs index 1bd83a50f7..072bc6b956 100644 --- a/rust/src/generated/api_types.rs +++ b/rust/src/generated/api_types.rs @@ -296,6 +296,8 @@ pub mod rpc_methods { pub const SESSION_MODEL_SETREASONINGEFFORT: &str = "session.model.setReasoningEffort"; /// `session.model.list` pub const SESSION_MODEL_LIST: &str = "session.model.list"; + /// `session.model.setAllowedModels` + pub const SESSION_MODEL_SETALLOWEDMODELS: &str = "session.model.setAllowedModels"; /// `session.mode.get` pub const SESSION_MODE_GET: &str = "session.mode.get"; /// `session.mode.set` @@ -22088,6 +22090,47 @@ pub struct WorkspacesWriteAutopilotObjectiveResult { pub operation: String, } +/// Host-supplied exact CAPI model IDs to allow for this running session. The runtime intersects the list with repository `.github/allowed_models.txt` policy. Omit or pass null to clear the host restriction; an explicit empty or disjoint list is rejected. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelSetAllowedModelsRequest { + /// Exact model IDs to permit, or null to clear the host restriction. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, +} + +/// The applied host allowlist and effective session model policy after intersection. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelSetAllowedModelsResult { + /// Normalized host allowlist. Omitted when the host restriction was cleared. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + /// Effective exact IDs or repository policy patterns after applying the host restriction. Omitted by relay clients whose AHP host applies the policy asynchronously. + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_allowed_models: Option>, + /// Effective deterministic fallback model, when the policy defines one. + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_model: Option, + /// Selected session model after reconciling a now-disallowed concrete selection. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + /// List of Copilot models available to the resolved user, including capabilities and billing metadata. /// ///
@@ -23642,6 +23685,31 @@ pub struct SessionModelListResult { pub quota_snapshots: Option>, } +/// The applied host allowlist and effective session model policy after intersection. +/// +///
+/// +/// **Experimental.** This type is part of an experimental wire-protocol surface +/// and may change or be removed in future SDK or CLI releases. +/// +///
+#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionModelSetAllowedModelsResult { + /// Normalized host allowlist. Omitted when the host restriction was cleared. + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + /// Effective exact IDs or repository policy patterns after applying the host restriction. Omitted by relay clients whose AHP host applies the policy asynchronously. + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_allowed_models: Option>, + /// Effective deterministic fallback model, when the policy defines one. + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_model: Option, + /// Selected session model after reconciling a now-disallowed concrete selection. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + /// Identifies the target session. /// ///
diff --git a/rust/src/generated/rpc.rs b/rust/src/generated/rpc.rs index 50eee0f1fb..d6d25d93a2 100644 --- a/rust/src/generated/rpc.rs +++ b/rust/src/generated/rpc.rs @@ -7717,6 +7717,42 @@ impl<'a> SessionRpcModel<'a> { .await?; Ok(serde_json::from_value(_value)?) } + + /// Replaces or clears the host-supplied model allowlist for a running session. + /// + /// Wire method: `session.model.setAllowedModels`. + /// + /// # Parameters + /// + /// * `params` - Host-supplied exact CAPI model IDs to allow for this running session. The runtime intersects the list with repository `.github/allowed_models.txt` policy. Omit or pass null to clear the host restriction; an explicit empty or disjoint list is rejected. + /// + /// # Returns + /// + /// The applied host allowlist and effective session model policy after intersection. + /// + ///
+ /// + /// **Experimental.** This API is part of an experimental wire-protocol surface + /// and may change or be removed in future SDK or CLI releases. Pin both the + /// SDK and CLI versions if your code depends on it. + /// + ///
+ pub async fn set_allowed_models( + &self, + params: ModelSetAllowedModelsRequest, + ) -> Result { + let mut wire_params = serde_json::to_value(params)?; + wire_params["sessionId"] = serde_json::Value::String(self.session.id().to_string()); + let _value = self + .session + .client() + .call( + rpc_methods::SESSION_MODEL_SETALLOWEDMODELS, + Some(wire_params), + ) + .await?; + Ok(serde_json::from_value(_value)?) + } } /// `session.name.*` RPCs. diff --git a/rust/src/types.rs b/rust/src/types.rs index a99f00a19f..b6829cea90 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1944,6 +1944,9 @@ pub struct SessionConfig { pub session_id: Option, /// Model to use (e.g. `"gpt-4"`, `"claude-sonnet-4"`). pub model: Option, + /// Exact model identifiers permitted for this session. When unset, the SDK + /// does not restrict model selection. + pub allowed_models: Option>, /// Application name sent as `User-Agent` context. pub client_name: Option, /// Reasoning effort level (e.g. `"low"`, `"medium"`, `"high"`). @@ -2295,6 +2298,7 @@ impl std::fmt::Debug for SessionConfig { f.debug_struct("SessionConfig") .field("session_id", &self.session_id) .field("model", &self.model) + .field("allowed_models", &self.allowed_models) .field("client_name", &self.client_name) .field("reasoning_effort", &self.reasoning_effort) .field("reasoning_summary", &self.reasoning_summary) @@ -2438,6 +2442,7 @@ impl Default for SessionConfig { Self { session_id: None, model: None, + allowed_models: None, client_name: None, reasoning_effort: None, reasoning_summary: None, @@ -2608,6 +2613,7 @@ impl SessionConfig { let wire = crate::wire::SessionCreateWire { session_id, model: self.model, + allowed_models: self.allowed_models, client_name: self.client_name, reasoning_effort: self.reasoning_effort, reasoning_summary: self.reasoning_summary, @@ -2831,6 +2837,16 @@ impl SessionConfig { self } + /// Set the exact model identifiers permitted for this session. + pub fn with_allowed_models(mut self, allowed_models: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.allowed_models = Some(allowed_models.into_iter().map(Into::into).collect()); + self + } + /// Set the application name sent as `User-Agent` context. pub fn with_client_name(mut self, name: impl Into) -> Self { self.client_name = Some(name.into()); @@ -3383,6 +3399,9 @@ pub struct ResumeSessionConfig { /// Model to use for this session (e.g. `"gpt-4"`, `"claude-sonnet-4"`). /// Can change the model when resuming. pub model: Option, + /// Exact model identifiers permitted for the resumed session. When unset, + /// the SDK does not restrict model selection. + pub allowed_models: Option>, /// Application name sent as User-Agent context. pub client_name: Option, /// Desired reasoning effort to apply after resuming the session. @@ -3647,6 +3666,7 @@ impl std::fmt::Debug for ResumeSessionConfig { f.debug_struct("ResumeSessionConfig") .field("session_id", &self.session_id) .field("model", &self.model) + .field("allowed_models", &self.allowed_models) .field("client_name", &self.client_name) .field("reasoning_effort", &self.reasoning_effort) .field("reasoning_summary", &self.reasoning_summary) @@ -3833,6 +3853,7 @@ impl ResumeSessionConfig { let wire = crate::wire::SessionResumeWire { session_id: self.session_id, model: self.model, + allowed_models: self.allowed_models, client_name: self.client_name, reasoning_effort: self.reasoning_effort, reasoning_summary: self.reasoning_summary, @@ -3941,6 +3962,7 @@ impl ResumeSessionConfig { Self { session_id, model: None, + allowed_models: None, client_name: None, reasoning_effort: None, reasoning_summary: None, @@ -4135,6 +4157,16 @@ impl ResumeSessionConfig { self } + /// Set the exact model identifiers permitted for the resumed session. + pub fn with_allowed_models(mut self, allowed_models: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.allowed_models = Some(allowed_models.into_iter().map(Into::into).collect()); + self + } + /// Set the application name sent as `User-Agent` context. pub fn with_client_name(mut self, name: impl Into) -> Self { self.client_name = Some(name.into()); @@ -6436,6 +6468,65 @@ mod tests { assert!(json.get("askUserVariant").is_none()); } + #[test] + fn session_config_allowed_models_builder_debug_and_wire() { + let default = SessionConfig::default(); + assert_eq!(default.allowed_models, None); + assert!(format!("{default:?}").contains("allowed_models: None")); + + let config = SessionConfig::default().with_allowed_models(["gpt-5", "claude-sonnet-5"]); + assert_eq!( + config.allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-sonnet-5".to_string()][..]) + ); + assert!( + format!("{config:?}") + .contains("allowed_models: Some([\"gpt-5\", \"claude-sonnet-5\"])") + ); + + let (wire, _) = config + .into_wire(Some(SessionId::from("allowed-models-create"))) + .expect("allowed models do not add SDK validation"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["allowedModels"], json!(["gpt-5", "claude-sonnet-5"])); + + let (default_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("unrestricted-create"))) + .expect("default config has no duplicate handlers"); + let default_json = serde_json::to_value(&default_wire).unwrap(); + assert!(default_json.get("allowedModels").is_none()); + } + + #[test] + fn resume_session_config_allowed_models_builder_debug_and_wire() { + let default = ResumeSessionConfig::new(SessionId::from("unrestricted-resume")); + assert_eq!(default.allowed_models, None); + assert!(format!("{default:?}").contains("allowed_models: None")); + + let config = ResumeSessionConfig::new(SessionId::from("allowed-models-resume")) + .with_allowed_models(vec!["gpt-5".to_string(), "claude-sonnet-5".to_string()]); + assert_eq!( + config.allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-sonnet-5".to_string()][..]) + ); + assert!( + format!("{config:?}") + .contains("allowed_models: Some([\"gpt-5\", \"claude-sonnet-5\"])") + ); + + let (wire, _) = config + .into_wire() + .expect("allowed models do not add SDK validation"); + let json = serde_json::to_value(&wire).unwrap(); + assert_eq!(json["allowedModels"], json!(["gpt-5", "claude-sonnet-5"])); + + let (default_wire, _) = ResumeSessionConfig::new(SessionId::from("unrestricted-resume")) + .into_wire() + .expect("default resume config has no duplicate handlers"); + let default_json = serde_json::to_value(&default_wire).unwrap(); + assert!(default_json.get("allowedModels").is_none()); + } + #[test] fn custom_agents_local_only_serializes_on_create_and_resume() { let (create_wire, _) = SessionConfig::default() diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 75e17f4e9c..3a09bab77c 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -53,6 +53,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, @@ -211,6 +213,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_models: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index e7bafc683c..fb6c045b72 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -19,7 +19,7 @@ use github_copilot_sdk::handler::{ }; use github_copilot_sdk::rpc::{ CanvasProviderInvokeActionRequest, CanvasProviderOpenRequest, CanvasProviderOpenResult, - OpenCanvasInstance, + ModelSetAllowedModelsRequest, ModelSetAllowedModelsResult, OpenCanvasInstance, }; use github_copilot_sdk::session_events::{ ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, @@ -2790,6 +2790,52 @@ async fn set_model_sends_switch_to_request() { timeout(TIMEOUT, handle).await.unwrap().unwrap(); } +#[test] +fn model_set_allowed_models_types_serialize_replacement_and_clear() { + let replacement = ModelSetAllowedModelsRequest { + allowed_models: Some(vec!["gpt-5".to_string(), "claude-sonnet-5".to_string()]), + }; + assert_eq!( + serde_json::to_value(replacement).unwrap(), + serde_json::json!({ + "allowedModels": ["gpt-5", "claude-sonnet-5"] + }) + ); + + assert_eq!( + serde_json::to_value(ModelSetAllowedModelsRequest { + allowed_models: None, + }) + .unwrap(), + serde_json::json!({}) + ); + + let result: ModelSetAllowedModelsResult = serde_json::from_value(serde_json::json!({ + "allowedModels": ["gpt-5"], + "effectiveAllowedModels": ["gpt-5", "claude-*"], + "fallbackModel": "gpt-5", + "modelId": "gpt-5" + })) + .unwrap(); + assert_eq!( + result.allowed_models.as_deref(), + Some(&["gpt-5".to_string()][..]) + ); + assert_eq!( + result.effective_allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-*".to_string()][..]) + ); + assert_eq!(result.fallback_model.as_deref(), Some("gpt-5")); + assert_eq!(result.model_id.as_deref(), Some("gpt-5")); + + let cleared: ModelSetAllowedModelsResult = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(cleared.allowed_models, None); + assert_eq!(cleared.effective_allowed_models, None); + assert_eq!(cleared.fallback_model, None); + assert_eq!(cleared.model_id, None); +} + #[tokio::test] async fn elicitation_returns_typed_result() { let (session, mut server) = @@ -5146,6 +5192,53 @@ async fn rpc_namespace_session_tasks_list_dispatches_correctly() { assert!(result.tasks.is_empty()); } +#[tokio::test] +async fn rpc_namespace_session_model_set_allowed_models_dispatches_correctly() { + let (session, mut server) = create_session_pair().await; + let session = Arc::new(session); + + let s = session.clone(); + let handle = tokio::spawn(async move { + s.rpc() + .model() + .set_allowed_models(ModelSetAllowedModelsRequest { + allowed_models: Some(vec!["gpt-5".to_string(), "claude-sonnet-5".to_string()]), + }) + .await + }); + + let request = server.read_request().await; + assert_eq!(request["method"], "session.model.setAllowedModels"); + assert_eq!(request["params"]["sessionId"], server.session_id); + assert_eq!( + request["params"]["allowedModels"], + serde_json::json!(["gpt-5", "claude-sonnet-5"]) + ); + server + .respond( + &request, + serde_json::json!({ + "allowedModels": ["gpt-5", "claude-sonnet-5"], + "effectiveAllowedModels": ["gpt-5"], + "fallbackModel": "gpt-5", + "modelId": "gpt-5" + }), + ) + .await; + + let result = timeout(TIMEOUT, handle).await.unwrap().unwrap().unwrap(); + assert_eq!( + result.allowed_models.as_deref(), + Some(&["gpt-5".to_string(), "claude-sonnet-5".to_string()][..]) + ); + assert_eq!( + result.effective_allowed_models.as_deref(), + Some(&["gpt-5".to_string()][..]) + ); + assert_eq!(result.fallback_model.as_deref(), Some("gpt-5")); + assert_eq!(result.model_id.as_deref(), Some("gpt-5")); +} + #[tokio::test] async fn rpc_namespace_client_models_list_dispatches_correctly() { let (session, mut server) = create_session_pair().await; From 67519be48b8283c4638187e61797f1fa2d193b78 Mon Sep 17 00:00:00 2001 From: "John Bufe (he/him) (from Dev Box)" Date: Thu, 3 Sep 2026 22:30:09 -0400 Subject: [PATCH 3/3] Make Rust allowlist codegen reproducible Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/wire.rs | 4 +- scripts/codegen/rust.ts | 114 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 3a09bab77c..9775bfa54a 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -52,7 +52,7 @@ pub(crate) struct SessionCreateWire { pub session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "allowedModels", skip_serializing_if = "Option::is_none")] pub allowed_models: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, @@ -212,7 +212,7 @@ pub(crate) struct SessionResumeWire { pub session_id: SessionId, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "allowedModels", skip_serializing_if = "Option::is_none")] pub allowed_models: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub client_name: Option, diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index b3cc5d5753..238817a743 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -69,6 +69,114 @@ const EXTERNAL_SCHEMA_RUST_TYPE_MODULE: Record> = }, }; +/** + * Add the live model-allowlist RPC until the pinned CLI schema includes it. + * + * This is Rust-only because the compatibility surface is currently exposed + * only by the Rust SDK. + */ +function addModelSetAllowedModelsToApiSchema(schema: ApiSchema): ApiSchema { + const session = (schema.session ??= {}); + const model = (session.model ??= {}) as Record; + if (model.setAllowedModels !== undefined) return schema; + + const definitions = (schema.definitions ??= {}); + if ( + definitions.ModelSetAllowedModelsRequest !== undefined || + definitions.ModelSetAllowedModelsResult !== undefined + ) { + throw new Error( + "Model allowlist schema definitions exist without session.model.setAllowedModels", + ); + } + + const allowedModelsProperty = { + anyOf: [ + { + type: "array", + items: { + type: "string", + }, + }, + { + type: "null", + }, + ], + description: "Exact model IDs to permit, or null to clear the host restriction.", + }; + const requestDescription = + "Host-supplied exact CAPI model IDs to allow for this running session. The runtime intersects the list with repository `.github/allowed_models.txt` policy. Omit or pass null to clear the host restriction; an explicit empty or disjoint list is rejected."; + + definitions.ModelSetAllowedModelsRequest = { + type: "object", + properties: { + allowedModels: allowedModelsProperty, + }, + additionalProperties: false, + description: requestDescription, + title: "ModelSetAllowedModelsRequest", + stability: "experimental", + } as JSONSchema7Definition; + definitions.ModelSetAllowedModelsResult = { + type: "object", + properties: { + allowedModels: { + type: "array", + items: { + type: "string", + }, + description: "Normalized host allowlist. Omitted when the host restriction was cleared.", + }, + effectiveAllowedModels: { + type: "array", + items: { + type: "string", + }, + description: + "Effective exact IDs or repository policy patterns after applying the host restriction. Omitted by relay clients whose AHP host applies the policy asynchronously.", + }, + fallbackModel: { + type: "string", + description: "Effective deterministic fallback model, when the policy defines one.", + }, + modelId: { + type: "string", + description: + "Selected session model after reconciling a now-disallowed concrete selection.", + }, + }, + additionalProperties: false, + description: "The applied host allowlist and effective session model policy after intersection.", + title: "ModelSetAllowedModelsResult", + } as JSONSchema7Definition; + model.setAllowedModels = { + rpcMethod: "session.model.setAllowedModels", + description: "Replaces or clears the host-supplied model allowlist for a running session.", + params: { + type: "object", + properties: { + sessionId: { + type: "string", + description: "Target session identifier", + }, + allowedModels: allowedModelsProperty, + }, + required: ["sessionId"], + additionalProperties: false, + description: requestDescription, + title: "ModelSetAllowedModelsRequest", + stability: "experimental", + }, + result: { + $ref: "#/definitions/ModelSetAllowedModelsResult", + description: "The applied host allowlist and effective session model policy after intersection.", + }, + stability: "experimental", + }; + + return schema; +} + function rustDeprecatedAttributes(indent = ""): string[] { return [`${indent}#[doc(hidden)]`, `${indent}#[deprecated]`]; } @@ -2219,8 +2327,10 @@ async function generate(): Promise { const sessionEventsRaw = normalizeSchemaBrandCasing( JSON.parse(await fs.readFile(sessionEventsSchemaPath, "utf-8")), ); - const apiRaw = normalizeSchemaBrandCasing( - JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema, + const apiRaw = addModelSetAllowedModelsToApiSchema( + normalizeSchemaBrandCasing( + JSON.parse(await fs.readFile(apiSchemaPath, "utf-8")) as ApiSchema, + ), ); const sessionEventsSchema = propagateInternalVisibility(