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/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/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/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..9775bfa54a 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -52,6 +52,8 @@ pub(crate) struct SessionCreateWire { pub session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + #[serde(rename = "allowedModels", 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")] @@ -210,6 +212,8 @@ pub(crate) struct SessionResumeWire { pub session_id: SessionId, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + #[serde(rename = "allowedModels", 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")] 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); } 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; 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(