use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use sha2::Digest;
pub(crate) fn main() {
println!("cargo:rerun-if-env-changed=DOCS_RS");
println!("cargo:rerun-if-env-changed=COPILOT_SKIP_CLI_DOWNLOAD");
println!("cargo:rerun-if-env-changed=COPILOT_CLI_EXTRACT_DIR");
println!("cargo:rerun-if-env-changed=BUNDLED_CLI_CACHE_DIR");
println!("cargo::rustc-check-cfg=cfg(has_bundled_cli)");
println!("cargo::rustc-check-cfg=cfg(has_extracted_cli)");
println!("cargo:rerun-if-changed=cli-version-in-process.txt");
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set");
let package_json = Path::new(&manifest_dir)
.join("..")
.join("nodejs")
.join("package.json");
if package_json.is_file() {
println!("cargo:rerun-if-changed={}", package_json.display());
}
if std::env::var_os("COPILOT_SKIP_CLI_DOWNLOAD").is_some() {
println!(
"cargo:warning=COPILOT_SKIP_CLI_DOWNLOAD is set — skipping runtime download/bundle/cache"
);
return;
}
if std::env::var_os("DOCS_RS").is_some() {
println!("cargo:warning=DOCS_RS is set — skipping CLI download/bundle/cache");
return;
}
let Some(platform) = target_platform() else {
println!("cargo:warning=Unsupported target platform for Copilot CLI bundling — skipping");
return;
};
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is always set by cargo");
let out = Path::new(&out_dir);
let (version, local_expected_hash) = resolve_version_and_optional_hash(platform.package_name);
println!("cargo:rustc-env=COPILOT_SDK_CLI_VERSION={version}");
let asset_platform = platform
.package_name
.strip_prefix("copilot-")
.expect("platform package names start with copilot-");
let archive_name = format!("github-copilot-{version}-{asset_platform}.tgz");
let download_url = format!(
"https://github.com/github/copilot-cli/releases/download/v{version}/{archive_name}"
);
let cache_dir = std::env::var("BUNDLED_CLI_CACHE_DIR")
.ok()
.map(std::path::PathBuf::from);
let cache_key = format!("v{version}-{archive_name}");
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
.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);
println!("cargo:rustc-cfg=has_bundled_cli");
} else {
let install_dir = extracted_install_dir(&version);
let required_paths = [
install_dir.join(platform.runtime_wrapper_name()),
install_dir.join("runtime.node"),
install_dir.join(".hostless-runtime-assets-v1"),
];
for path in &required_paths {
println!("cargo:rerun-if-changed={}", path.display());
}
let marker = std::fs::read_to_string(&required_paths[2]).ok();
let cache_is_current = required_paths.iter().all(|path| path.is_file())
&& match local_expected_hash.as_deref() {
Some(expected_hash) => {
marker.as_deref() == Some(&format!("{version}\n{expected_hash}\n"))
}
None => marker
.as_deref()
.is_some_and(|contents| marker_matches_version(contents, &version)),
};
if !cache_is_current {
let expected_hash = local_expected_hash
.unwrap_or_else(|| fetch_in_process_release_hash(&version, platform.package_name));
let expected_marker = format!("{version}\n{expected_hash}\n");
if install_dir.exists() {
std::fs::remove_dir_all(&install_dir).unwrap_or_else(|e| {
panic!(
"failed to clear stale runtime bundle {}: {e}",
install_dir.display()
)
});
}
let archive = cached_download(&download_url, &cache_key, &expected_hash, &cache_dir);
verify_runtime_package(&archive, platform, &archive_name);
extract_to_cache(
&archive,
&install_dir,
platform,
include_runtime,
&expected_marker,
);
}
if required_paths.iter().all(|path| path.is_file()) {
println!("cargo:rustc-cfg=has_extracted_cli");
}
}
}
fn extracted_install_dir(version: &str) -> PathBuf {
if let Some(custom) = std::env::var_os("COPILOT_CLI_EXTRACT_DIR") {
PathBuf::from(custom)
} else {
let cache = dirs::cache_dir().unwrap_or_else(std::env::temp_dir);
cache
.join("github-copilot-sdk")
.join("cli")
.join(sanitize_version(version))
}
}
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)
.expect("failed to write copilot_cli.archive");
let generated = r#"// Auto-generated by github-copilot-sdk build.rs. Do not edit.
pub(super) static CLI_ARCHIVE: &[u8] = include_bytes!("copilot_cli.archive");
"#;
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<u8> {
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);
if include_runtime {
append_archive_file(
&mut archive,
platform.runtime_library_name(),
&runtime,
0o644,
);
}
let encoder = archive
.into_inner()
.expect("failed to finish minimal embedded CLI archive");
encoder
.finish()
.expect("failed to compress minimal embedded CLI archive")
}
fn append_hostless_runtime_tree<W: Write>(
archive: &mut tar::Builder<W>,
package: &[u8],
platform: Platform,
) -> (Vec<u8>, Vec<u8>) {
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}"))
{
let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}"));
if !entry.header().entry_type().is_file() {
continue;
}
let source_path = entry
.path()
.unwrap_or_else(|e| panic!("failed to read npm package path: {e}"));
let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform)
else {
continue;
};
let mut bytes = Vec::with_capacity(entry.size() as usize);
entry
.read_to_end(&mut bytes)
.unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}"));
let mode = entry.header().mode().unwrap_or(0o644);
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
.to_str()
.expect("npm package paths are valid UTF-8"),
&bytes,
mode,
);
}
(
runtime.unwrap_or_else(|| {
panic!(
"package `{}` does not contain prebuilds/<platform>/runtime.node",
platform.package_name
)
}),
wrapper.unwrap_or_else(|| {
panic!(
"package `{}` does not contain prebuilds/<platform>/{}",
platform.package_name,
platform.runtime_wrapper_name()
)
}),
)
}
fn hostless_runtime_path(source: &str, platform: Platform) -> Option<PathBuf> {
let relative = source.strip_prefix("package/")?;
let parts: Vec<&str> = relative.split('/').collect();
if parts.iter().any(|part| part.is_empty() || *part == "..") {
return None;
}
let top_level = *parts.first()?;
let file_name = *parts.last()?;
const EXCLUDED_TOP_LEVEL: &[&str] = &[
"app.js",
"assets",
"changelog.json",
"foundry-local-sdk",
"index.js",
"LICENSE.md",
"napi-oop-runtime",
"npm-loader.js",
"package.json",
"pvrecorder",
"queries",
"README.md",
"sea-loader.js",
"webview",
];
if EXCLUDED_TOP_LEVEL.contains(&top_level)
|| (top_level.starts_with("tree-sitter") && top_level.ends_with(".wasm"))
|| (top_level.starts_with("voice-") && top_level.ends_with(".js"))
|| file_name == "cli-native.node"
|| parts.contains(&"mediaremote-adapter")
|| file_name.starts_with("copilot-runtime-bin")
{
return None;
}
if top_level == "prebuilds" {
let npm_platform = platform
.package_name
.strip_prefix("copilot-")
.expect("platform package name has copilot- prefix");
if parts.get(1) != Some(&npm_platform) || parts.len() < 3 {
return None;
}
return Some(parts[2..].iter().copied().collect());
}
Some(parts.iter().copied().collect())
}
fn append_archive_file<W: Write>(
archive: &mut tar::Builder<W>,
path: &str,
bytes: &[u8],
mode: u32,
) {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(mode);
header.set_uid(0);
header.set_gid(0);
header.set_mtime(0);
header.set_cksum();
archive
.append_data(&mut header, path, bytes)
.unwrap_or_else(|e| panic!("failed to add `{path}` to embedded CLI archive: {e}"));
}
fn resolve_version_and_optional_hash(package_name: &str) -> (String, Option<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-in-process.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 (version, hash) = parse_snapshot(&contents, package_name)
.unwrap_or_else(|e| panic!("invalid {}: {e}", snapshot.display()));
return (version, Some(hash));
}
let package_json = Path::new(&manifest_dir)
.join("..")
.join("nodejs")
.join("package.json");
if package_json.is_file() {
let version = read_version_from_package_json(&package_json);
return (version, None);
}
panic!(
"Could not resolve the Copilot CLI version.\n\
Tried:\n\
- {} (missing)\n\
- {} (missing)\n\
In a published crate or vendored slot, `cli-version-in-process.txt` should be present.\n\
Inside the github/copilot-sdk repo, `../nodejs/package.json` is the version source.",
snapshot.display(),
package_json.display(),
);
}
fn fetch_in_process_release_hash(version: &str, package_name: &str) -> String {
let platform = package_name
.strip_prefix("copilot-")
.expect("platform package names start with copilot-");
let asset_name = format!("github-copilot-{version}-{platform}.tgz");
fetch_release_hash(version, &asset_name)
}
fn marker_matches_version(contents: &str, version: &str) -> bool {
let mut lines = contents.lines();
lines.next() == Some(version)
&& lines.next().is_some_and(|hash| {
hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
})
&& lines.next().is_none()
}
fn parse_snapshot(contents: &str, package_name: &str) -> Result<(String, String), String> {
let mut version: Option<String> = None;
let mut hash: Option<String> = None;
for (line_no, raw) in contents.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
return Err(format!(
"line {}: expected `key=value`, got `{raw}`",
line_no + 1
));
};
match key.trim() {
"version" => version = Some(value.trim().to_string()),
k if k == package_name => hash = Some(value.trim().to_string()),
_ => {}
}
}
let version = version.ok_or("missing `version=` line")?;
let hash = hash.ok_or_else(|| format!("missing hash for package `{package_name}`"))?;
Ok((version, hash))
}
fn read_version_from_package_json(path: &Path) -> String {
let contents = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
let package_json: serde_json::Value = serde_json::from_str(&contents)
.unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()));
package_json["copilotCliVersion"]
.as_str()
.unwrap_or_else(|| panic!("copilotCliVersion is missing in {}", path.display()))
.to_string()
}
fn fetch_release_hash(version: &str, asset_name: &str) -> String {
let url = format!(
"https://github.com/github/copilot-cli/releases/download/v{version}/SHA256SUMS.txt"
);
let checksums = download_with_retry(&url);
let checksums = std::str::from_utf8(&checksums).expect("SHA256SUMS.txt is not valid UTF-8");
find_sha256_for_asset(checksums, asset_name)
}
fn find_sha256_for_asset(sums: &str, asset_name: &str) -> String {
sums.lines()
.find_map(|line| {
let (hash, name) = line.split_once(char::is_whitespace)?;
(name.trim_start().trim_start_matches('*') == asset_name).then(|| hash.to_string())
})
.unwrap_or_else(|| panic!("SHA256SUMS.txt does not contain {asset_name}"))
}
#[derive(Clone, Copy)]
struct Platform {
package_name: &'static str,
binary_name: &'static str,
}
impl Platform {
fn runtime_wrapper_name(&self) -> &'static str {
if self.package_name.contains("win32") {
"copilot-runtime.exe"
} else {
"copilot-runtime"
}
}
fn runtime_library_name(&self) -> &'static str {
if self.package_name.contains("win32") {
"copilot_runtime.dll"
} else if self.package_name.contains("darwin") {
"libcopilot_runtime.dylib"
} else {
"libcopilot_runtime.so"
}
}
}
fn target_platform() -> Option<Platform> {
let os = std::env::var("CARGO_CFG_TARGET_OS").ok()?;
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").ok()?;
let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
match (os.as_str(), arch.as_str(), target_env.as_str()) {
("macos", "aarch64", _) => Some(Platform {
package_name: "copilot-darwin-arm64",
binary_name: "copilot",
}),
("macos", "x86_64", _) => Some(Platform {
package_name: "copilot-darwin-x64",
binary_name: "copilot",
}),
("linux", "x86_64", "musl") => Some(Platform {
package_name: "copilot-linuxmusl-x64",
binary_name: "copilot",
}),
("linux", "aarch64", "musl") => Some(Platform {
package_name: "copilot-linuxmusl-arm64",
binary_name: "copilot",
}),
("linux", "x86_64", _) => Some(Platform {
package_name: "copilot-linux-x64",
binary_name: "copilot",
}),
("linux", "aarch64", _) => Some(Platform {
package_name: "copilot-linux-arm64",
binary_name: "copilot",
}),
("windows", "x86_64", _) => Some(Platform {
package_name: "copilot-win32-x64",
binary_name: "copilot.exe",
}),
("windows", "aarch64", _) => Some(Platform {
package_name: "copilot-win32-arm64",
binary_name: "copilot.exe",
}),
_ => None,
}
}
fn extract_to_cache(
archive: &[u8],
install_dir: &Path,
platform: Platform,
include_runtime: bool,
marker: &str,
) -> PathBuf {
std::fs::create_dir_all(install_dir).unwrap_or_else(|e| {
panic!(
"failed to create install dir {}: {e}",
install_dir.display()
)
});
let decoder = flate2::read::GzDecoder::new(archive);
let mut source = tar::Archive::new(decoder);
let mut runtime = None;
for entry in source
.entries()
.unwrap_or_else(|e| panic!("failed to read npm package entries: {e}"))
{
let mut entry = entry.unwrap_or_else(|e| panic!("failed to read npm package entry: {e}"));
if !entry.header().entry_type().is_file() {
continue;
}
let source_path = entry
.path()
.unwrap_or_else(|e| panic!("failed to read npm package path: {e}"));
let Some(destination) = hostless_runtime_path(&source_path.to_string_lossy(), platform)
else {
continue;
};
if destination == Path::new(platform.binary_name) {
continue;
}
let mut bytes = Vec::with_capacity(entry.size() as usize);
entry
.read_to_end(&mut bytes)
.unwrap_or_else(|e| panic!("failed to read npm package entry bytes: {e}"));
let executable = entry.header().mode().unwrap_or(0o644) & 0o111 != 0;
if destination == Path::new("runtime.node") {
runtime = Some(bytes.clone());
}
install_cached_file_path(install_dir, &destination, &bytes, executable);
}
let runtime = runtime.expect("verified runtime.node is present");
if include_runtime {
install_cached_file(
install_dir,
platform.runtime_library_name(),
&runtime,
false,
);
}
install_cached_file(
install_dir,
".hostless-runtime-assets-v1",
marker.as_bytes(),
false,
);
let final_path = install_dir.join(platform.runtime_wrapper_name());
println!(
"cargo:warning=Extracted Copilot runtime bundle to {}",
install_dir.display()
);
final_path
}
fn install_cached_file(install_dir: &Path, file_name: &str, bytes: &[u8], executable: bool) {
install_cached_file_path(install_dir, Path::new(file_name), bytes, executable);
}
fn install_cached_file_path(
install_dir: &Path,
relative_path: &Path,
bytes: &[u8],
executable: bool,
) {
#[cfg(not(unix))]
let _ = executable;
assert!(
!relative_path.is_absolute()
&& !relative_path.components().any(|component| {
matches!(
component,
std::path::Component::Prefix(_)
| std::path::Component::RootDir
| std::path::Component::ParentDir
)
}),
"unsafe runtime package path: {}",
relative_path.display()
);
let final_path = install_dir.join(relative_path);
if final_path.is_file() {
return;
}
std::fs::create_dir_all(final_path.parent().expect("runtime asset has parent"))
.unwrap_or_else(|e| panic!("failed to create runtime asset directory: {e}"));
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let staging_path = install_dir.join(format!(
".{}.staging-{}-{nanos}",
relative_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("runtime-asset"),
std::process::id(),
));
{
let mut f = std::fs::File::create(&staging_path).unwrap_or_else(|e| {
let _ = std::fs::remove_file(&staging_path);
panic!(
"failed to create staging file {}: {e}",
staging_path.display()
);
});
if let Err(e) = f.write_all(bytes) {
let _ = std::fs::remove_file(&staging_path);
panic!(
"failed to write staging file {}: {e}",
staging_path.display()
);
}
#[cfg(unix)]
if executable {
use std::os::unix::fs::PermissionsExt;
if let Err(e) = f.set_permissions(std::fs::Permissions::from_mode(0o755)) {
let _ = std::fs::remove_file(&staging_path);
panic!("failed to chmod {}: {e}", staging_path.display());
}
}
if let Err(e) = f.set_modified(std::time::SystemTime::UNIX_EPOCH) {
println!(
"cargo:warning=Could not backdate {} (a redundant rebuild may occur): {e}",
staging_path.display()
);
}
}
if let Err(e) = std::fs::rename(&staging_path, &final_path) {
let _ = std::fs::remove_file(&staging_path);
panic!(
"failed to rename {} -> {}: {e}",
staging_path.display(),
final_path.display()
);
}
}
fn sanitize_version(version: &str) -> String {
version
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => c,
_ => '_',
})
.collect()
}
fn cached_download(
url: &str,
cache_key: &str,
expected_hash: &str,
cache_dir: &Option<std::path::PathBuf>,
) -> Vec<u8> {
if let Some(dir) = cache_dir {
let cached_path = dir.join(cache_key);
if cached_path.is_file() {
match std::fs::read(&cached_path) {
Ok(data) if verify_hash(&data, expected_hash) => {
return data;
}
Ok(_) => {
println!("cargo:warning=Cached archive hash mismatch, re-downloading");
let _ = std::fs::remove_file(&cached_path);
}
Err(e) => {
println!(
"cargo:warning=Failed to read cache {}, re-downloading: {e}",
cached_path.display()
);
}
}
}
}
println!("cargo:warning=Downloading {url}");
let data = download_with_retry(url);
if !verify_hash(&data, expected_hash) {
panic!(
"Archive integrity check failed for {url}!\n expected: {expected_hash}\n \
This could indicate a corrupted download or a supply-chain attack."
);
}
if let Some(dir) = cache_dir {
if let Err(e) = std::fs::create_dir_all(dir) {
println!(
"cargo:warning=Failed to create cache directory {}: {e}",
dir.display()
);
} else {
let cached_path = dir.join(cache_key);
println!("cargo:warning=Caching archive at {}", cached_path.display());
if let Err(e) = std::fs::write(&cached_path, &data) {
println!(
"cargo:warning=Failed to write cache file {}: {e}",
cached_path.display()
);
}
}
}
data
}
const MAX_RETRIES: u32 = 3;
fn download_with_retry(url: &str) -> Vec<u8> {
let mut attempt = 0u32;
loop {
attempt += 1;
match try_download(url) {
Ok(bytes) => return bytes,
Err(err) if err.transient && attempt <= MAX_RETRIES => {
let backoff = Duration::from_secs(1u64 << (attempt - 1));
println!(
"cargo:warning=Transient download failure for {url} (attempt {attempt}/{}): {} — retrying in {}s",
MAX_RETRIES + 1,
err.message,
backoff.as_secs(),
);
std::thread::sleep(backoff);
}
Err(err) => panic!("Failed to download {url}: {}", err.message),
}
}
}
struct DownloadError {
message: String,
transient: bool,
}
fn try_download(url: &str) -> Result<Vec<u8>, DownloadError> {
let connector = native_tls::TlsConnector::new().map_err(|e| DownloadError {
message: format!("native-tls init error: {e}"),
transient: false,
})?;
let agent = ureq::AgentBuilder::new()
.tls_connector(std::sync::Arc::new(connector))
.timeout_connect(Duration::from_secs(30))
.timeout_read(Duration::from_secs(120))
.build();
match agent.get(url).call() {
Ok(response) => {
let mut bytes = Vec::new();
response
.into_reader()
.read_to_end(&mut bytes)
.map_err(|e| DownloadError {
message: format!("read error: {e}"),
transient: true,
})?;
Ok(bytes)
}
Err(ureq::Error::Status(code, response)) if (500..600).contains(&code) => {
Err(DownloadError {
message: format!("HTTP {code} {}", response.status_text()),
transient: true,
})
}
Err(ureq::Error::Status(code, response)) => Err(DownloadError {
message: format!("HTTP {code} {}", response.status_text()),
transient: false,
}),
Err(ureq::Error::Transport(t)) => Err(DownloadError {
message: format!("transport error: {t}"),
transient: true,
}),
}
}
fn verify_runtime_package(archive: &[u8], platform: Platform, package_name: &str) {
for file_name in ["runtime.node", platform.runtime_wrapper_name()] {
if archive_contains_tar_entry(archive, file_name) {
continue;
}
panic!(
"Copilot runtime package `{package_name}` does not contain an entry named `{file_name}`"
);
}
}
fn archive_contains_tar_entry(targz: &[u8], binary_name: &str) -> bool {
let gz = flate2::read::GzDecoder::new(targz);
let mut archive = tar::Archive::new(gz);
let Ok(entries) = archive.entries() else {
return false;
};
for entry in entries.flatten() {
let Ok(path) = entry.path() else {
continue;
};
let name = path.to_string_lossy();
if name == binary_name || name.ends_with(&format!("/{binary_name}")) {
return true;
}
}
false
}
fn verify_hash(data: &[u8], expected: &str) -> bool {
let mut hasher = sha2::Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize()) == expected
}