diff --git a/baml_language/crates/baml_cli/src/agent_command.rs b/baml_language/crates/baml_cli/src/agent_command.rs index 73cbff9cac8..5da7257f8d0 100644 --- a/baml_language/crates/baml_cli/src/agent_command.rs +++ b/baml_language/crates/baml_cli/src/agent_command.rs @@ -10,7 +10,7 @@ use std::{ use anyhow::{Context, Result, anyhow}; use clap::Args; -use crate::{ExitCode, project_load::find_project_root_from}; +use crate::ExitCode; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); @@ -39,8 +39,10 @@ pub(crate) enum AgentCommand { pub(crate) struct AgentInstallArgs { /// Directory where project-local agent skills should be installed. /// - /// When omitted, BAML installs at the nearest ancestor with baml.toml, - /// then the git root, then the current directory. + /// When omitted, BAML installs at the nearest ancestor with baml.toml or + /// baml_src within the current git repo (else the repo root); outside a + /// git repo, at the nearest such ancestor below your home directory, else + /// the current directory. #[arg(long, value_name = "PATH")] pub dir: Option, @@ -93,11 +95,15 @@ impl AgentInstallArgs { } } +#[derive(Debug)] struct LoadedSkills { skills: Vec, - /// Head commit of the skill repo, present only when installing from the - /// default source (the repo's main branch). Custom `--from` sources have - /// no commit identity to record. + /// Commit of the skill repo the installed content came from, recovered + /// from the tarball's pax global header (`comment=`, written by + /// `git archive` and present in GitHub codeload tarballs). Present only + /// when installing from the default source; custom `--from` sources have + /// no commit identity to record, and archives without the header (or with + /// a non-SHA comment) install fine with no recorded provenance. commit: Option, } @@ -106,7 +112,7 @@ fn load_skills(args: &AgentInstallArgs) -> Result { if is_http_url(source) { let archive = fetch_url_bytes(source)?; return Ok(LoadedSkills { - skills: skills_from_archive(&archive)?, + skills: skills_from_archive(&archive)?.skills, commit: None, }); } @@ -126,20 +132,29 @@ fn load_skills(args: &AgentInstallArgs) -> Result { ) })?; return Ok(LoadedSkills { - skills: skills_from_archive(&archive)?, + skills: skills_from_archive(&archive)?.skills, commit: None, }); } - // Default: install the current head of the skill repo. The commit is - // resolved first and the tarball downloaded at that exact commit, so the - // recorded provenance always matches the installed content. - let commit = baml_release::skills::fetch_latest_skill_commit(HTTP_TIMEOUT) - .context("failed to resolve the latest BAML agent skills commit")?; - let archive = fetch_url_bytes(&baml_release::skills::skill_archive_url(&commit))?; + // Default: download the main-branch tarball from codeload. Deliberately + // NOT the GitHub REST API: the tarball endpoint needs no credentials and + // is not subject to the unauthenticated 60-requests/hour API rate limit + // that used to hard-block installs. The tarball embeds the commit it was + // cut from in its pax global header, which becomes the recorded + // provenance; a mirror that omits the header just skips provenance. + let url = baml_release::skills::skill_archive_url("main"); + let archive = fetch_url_bytes(&url).with_context(|| { + format!( + "could not download the BAML agent skills from {url}; if GitHub is \ + unreachable, install from a local copy with \ + `baml agent install --from `" + ) + })?; + let loaded = skills_from_archive(&archive)?; Ok(LoadedSkills { - skills: skills_from_archive(&archive)?, - commit: Some(commit), + skills: loaded.skills, + commit: loaded.commit, }) } @@ -169,9 +184,10 @@ fn record_installed_commit(commit: &str) { } } -/// Installs from custom `--from` sources have no commit identity. Drop any -/// previously recorded provenance so the wrapper doesn't report the custom -/// content as up to date with (or behind) the official skill repo. +/// Installs with no commit identity (custom `--from` sources, or a default +/// archive whose pax header carried no commit) drop any previously recorded +/// provenance so the wrapper doesn't report the installed content as up to +/// date with (or behind) the official skill repo. fn clear_installed_commit() { if let Err(err) = baml_release::skills::clear_skills_state(&baml_release::skills::state_path()) { @@ -220,35 +236,101 @@ fn detect_install_root() -> Result { let canonical = cwd .canonicalize() .with_context(|| format!("failed to resolve {}", cwd.display()))?; + Ok(detect_install_root_in( + &canonical, + git_toplevel(&canonical).as_deref(), + user_home_dir().as_deref(), + )) +} - if let Some(root) = find_project_root_from(Some(&canonical))? { - return Ok(root); - } +/// Pick the install root for project-local agent skills. +/// +/// Inside a git repo, the nearest baml.toml (else baml_src) owner wins, but +/// the walk never leaves the repo; with no project marker the repo root is +/// used, since that is where agent harnesses discover `.claude/skills/`. +/// Outside a git repo the walk stops before the user's home directory — +/// a stray `~/baml_src` must not pull installs into `$HOME` — or, when no +/// home directory is known, doesn't leave the current directory at all; the +/// fallback is always the current directory. All inputs must be +/// pre-canonicalized. +fn detect_install_root_in(cwd: &Path, git_toplevel: Option<&Path>, home: Option<&Path>) -> PathBuf { + // A toplevel that doesn't contain cwd (an exported GIT_DIR/GIT_WORK_TREE + // pointing at a dotfiles worktree in $HOME) is not the project being + // worked in; neither is one rooted at — or containing — the home + // directory itself, which would reopen the stray-~/baml_src hole this + // function exists to close. Ignore both rather than install there. + let git_toplevel = git_toplevel.filter(|toplevel| { + cwd.starts_with(toplevel) && !home.is_some_and(|home| home.starts_with(toplevel)) + }); + let ancestors: Vec = match (git_toplevel, home) { + (Some(toplevel), _) => cwd + .ancestors() + .take_while(|dir| dir.starts_with(toplevel)) + .map(Path::to_path_buf) + .collect(), + (None, Some(home)) => cwd + .ancestors() + .take_while(|dir| *dir != home) + .map(Path::to_path_buf) + .collect(), + // No git scope and no known home boundary: a walk could climb + // arbitrarily far and adopt a stray marker directory, so don't walk + // at all. Installing at cwd is always safe. + (None, None) => vec![cwd.to_path_buf()], + }; + baml_workspace::find_baml_project_root_from_ancestors( + ancestors, + |dir| dir.join(baml_workspace::BAML_TOML).is_file(), + |dir| dir.join(baml_workspace::BAML_SRC_DIR).is_dir(), + ) + .unwrap_or_else(|| match git_toplevel { + Some(toplevel) => toplevel.to_path_buf(), + None => cwd.to_path_buf(), + }) +} - if let Ok(output) = Command::new("git") +/// Canonicalized toplevel of the git repo containing `dir`, if any. +fn git_toplevel(dir: &Path) -> Option { + let output = Command::new("git") .args(["rev-parse", "--show-toplevel"]) - .current_dir(&canonical) + .current_dir(dir) .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let root = stdout.trim(); - if !root.is_empty() { - return Ok(PathBuf::from(root)); - } - } + .ok()?; + if !output.status.success() { + return None; } + let stdout = String::from_utf8_lossy(&output.stdout); + let root = stdout.trim(); + if root.is_empty() { + return None; + } + PathBuf::from(root).canonicalize().ok() +} - Ok(canonical) +/// The user's canonicalized home directory (`HOME`, or `USERPROFILE` on +/// Windows). Not [`baml_release::baml_home`], which is `~/.baml` state +/// storage and overridable via `BAML_HOME`. +fn user_home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .and_then(|home| home.canonicalize().ok()) } -fn skills_from_archive(archive: &[u8]) -> Result> { +fn skills_from_archive(archive: &[u8]) -> Result { let decoder = flate2::read::GzDecoder::new(Cursor::new(archive)); let mut archive = tar::Archive::new(decoder); let mut raw = Vec::new(); + let mut commit = None; for entry in archive.entries().context("failed to read skill archive")? { let mut entry = entry.context("failed to read skill archive entry")?; + if entry.header().entry_type() == tar::EntryType::XGlobalHeader { + if commit.is_none() { + commit = pax_comment_sha(&mut entry); + } + continue; + } if !entry.header().entry_type().is_file() { continue; } @@ -269,7 +351,48 @@ fn skills_from_archive(archive: &[u8]) -> Result> { raw.push(raw_skill(skill_path, content, path)); } - normalize_skills(raw) + Ok(LoadedSkills { + skills: normalize_skills(raw)?, + commit, + }) +} + +/// Extract the commit SHA a codeload tarball was cut from: `git archive` +/// records it as a `comment=` record in the pax global header entry. +/// Returns `None` (rather than erroring) for archives without the header or +/// with a comment that doesn't look like a git SHA, so custom mirrors and +/// hand-rolled archives still install — they just record no provenance. +fn pax_comment_sha(entry: &mut impl Read) -> Option { + let mut body = Vec::new(); + entry.take(4096).read_to_end(&mut body).ok()?; + let comment = + pax_records(&body).find_map(|(key, value)| (key == "comment").then_some(value))?; + let sha = comment.trim(); + let looks_like_sha = + (7..=64).contains(&sha.len()) && sha.chars().all(|c| c.is_ascii_hexdigit()); + looks_like_sha.then(|| sha.to_string()) +} + +/// Iterate ` =\n` records in a pax extended header body, +/// skipping anything malformed. +fn pax_records(body: &[u8]) -> impl Iterator { + let mut rest = body; + std::iter::from_fn(move || { + loop { + let text = std::str::from_utf8(rest).ok()?; + let (len_text, _) = text.split_once(' ')?; + let record_len: usize = len_text.parse().ok()?; + if record_len <= len_text.len() + 1 { + return None; + } + let record = text.get(..record_len)?; + rest = &rest[record_len..]; + let content = &record[len_text.len() + 1..]; + if let Some((key, value)) = content.trim_end_matches('\n').split_once('=') { + return Some((key, value)); + } + } + }) } fn skills_from_dir(root: &Path) -> Result> { @@ -636,7 +759,7 @@ mod tests { fn direct_archive_layout_is_loaded() { let content = skill("baml-core"); let archive = make_archive(&[("skills/baml-core/SKILL.md", content.as_str())]); - let skills = skills_from_archive(&archive).unwrap(); + let skills = skills_from_archive(&archive).unwrap().skills; assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "baml-core"); @@ -649,7 +772,7 @@ mod tests { direct_skill_entries(&["baml-core", "baml-bridges", "baml-serving", "baml-testing"]); let archive = make_archive(&entry_refs(&entries)); - let skills = skills_from_archive(&archive).unwrap(); + let skills = skills_from_archive(&archive).unwrap().skills; let names = skills .iter() .map(|skill| skill.name.as_str()) @@ -669,7 +792,7 @@ mod tests { ("skills/baml-core/._SKILL.md", &[0xff, 0xfe]), ]); - let skills = skills_from_archive(&archive).unwrap(); + let skills = skills_from_archive(&archive).unwrap().skills; assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "baml-core"); @@ -688,7 +811,7 @@ mod tests { .collect::>(); let archive = make_archive(&entry_refs(&entries)); - let skills = skills_from_archive(&archive).unwrap(); + let skills = skills_from_archive(&archive).unwrap().skills; let core = skills .iter() .find(|skill| skill.name == "baml-core") @@ -716,7 +839,7 @@ mod tests { let content = "---\r\nname: baml-core\r\ndescription: test\r\n---\r\n# Core\r\n"; let archive = make_archive(&[("skills/baml-core/SKILL.md", content)]); - let skills = skills_from_archive(&archive).unwrap(); + let skills = skills_from_archive(&archive).unwrap().skills; assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "baml-core"); @@ -761,6 +884,157 @@ mod tests { ); } + #[test] + fn archive_pax_global_header_comment_becomes_commit() { + let sha = "0123456789abcdef0123456789abcdef01234567"; + let content = skill("baml-core"); + let archive = make_archive_with_pax( + &[("skills/baml-core/SKILL.md", content.as_str())], + Some(sha), + ); + + let loaded = skills_from_archive(&archive).unwrap(); + + assert_eq!(loaded.commit.as_deref(), Some(sha)); + assert_eq!(loaded.skills.len(), 1); + } + + #[test] + fn archive_without_pax_header_has_no_commit() { + let content = skill("baml-core"); + let archive = make_archive(&[("skills/baml-core/SKILL.md", content.as_str())]); + + assert_eq!(skills_from_archive(&archive).unwrap().commit, None); + } + + #[test] + fn archive_with_non_sha_pax_comment_has_no_commit() { + let content = skill("baml-core"); + let archive = make_archive_with_pax( + &[("skills/baml-core/SKILL.md", content.as_str())], + Some("not a commit sha"), + ); + + assert_eq!(skills_from_archive(&archive).unwrap().commit, None); + } + + #[test] + fn install_root_prefers_baml_toml_within_git_repo() { + let tmp = tempfile::tempdir().unwrap(); + let toplevel = tmp.path().canonicalize().unwrap(); + let project = toplevel.join("services/x"); + let cwd = project.join("deep"); + fs::create_dir_all(&cwd).unwrap(); + fs::write(project.join("baml.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let root = detect_install_root_in(&cwd, Some(&toplevel), None); + + assert_eq!(root, project); + } + + #[test] + fn install_root_never_escapes_git_repo() { + let tmp = tempfile::tempdir().unwrap(); + let outer = tmp.path().canonicalize().unwrap(); + // A baml_src above the repo must not pull the install outside it. + fs::create_dir_all(outer.join("baml_src")).unwrap(); + let toplevel = outer.join("repo"); + let cwd = toplevel.join("sub"); + fs::create_dir_all(&cwd).unwrap(); + + let root = detect_install_root_in(&cwd, Some(&toplevel), None); + + assert_eq!(root, toplevel); + } + + #[test] + fn install_root_ignores_git_toplevel_that_is_not_an_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().canonicalize().unwrap(); + // Dotfiles bare-repo pattern: exported GIT_DIR/GIT_WORK_TREE make git + // report a worktree (often $HOME) unrelated to the directory the user + // is actually in. The project markers next to cwd must still win. + let foreign_worktree = base.join("home"); + let project = base.join("opt/project"); + let cwd = project.join("sub"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(&foreign_worktree).unwrap(); + fs::write(project.join("baml.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let root = detect_install_root_in(&cwd, Some(&foreign_worktree), Some(&foreign_worktree)); + + assert_eq!(root, project); + } + + #[test] + fn install_root_ignores_git_toplevel_at_or_above_home() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + // A repo rooted at $HOME (dotfiles-as-git-repo) must not turn the + // stray-marker hole back on: the home boundary still applies. + fs::create_dir_all(home.join("baml_src")).unwrap(); + let cwd = home.join("nomad"); + fs::create_dir_all(&cwd).unwrap(); + + let root = detect_install_root_in(&cwd, Some(&home), Some(&home)); + + assert_eq!(root, cwd); + } + + #[test] + fn install_root_without_known_home_stays_at_cwd() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().canonicalize().unwrap(); + // No git scope and no home boundary: never adopt a marker above cwd. + fs::create_dir_all(base.join("baml_src")).unwrap(); + let cwd = base.join("a/b"); + fs::create_dir_all(&cwd).unwrap(); + + let root = detect_install_root_in(&cwd, None, None); + + assert_eq!(root, cwd); + } + + #[test] + fn install_root_ignores_stray_baml_src_at_home() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + // Regression: a stray ~/baml_src used to make installs from ~/some/dir + // land the skills in $HOME. + fs::create_dir_all(home.join("baml_src")).unwrap(); + let cwd = home.join("nomad"); + fs::create_dir_all(&cwd).unwrap(); + + let root = detect_install_root_in(&cwd, None, Some(&home)); + + assert_eq!(root, cwd); + } + + #[test] + fn install_root_accepts_baml_src_owner_below_home() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let project = home.join("work"); + let cwd = project.join("sub"); + fs::create_dir_all(&cwd).unwrap(); + fs::create_dir_all(project.join("baml_src")).unwrap(); + + let root = detect_install_root_in(&cwd, None, Some(&home)); + + assert_eq!(root, project); + } + + #[test] + fn install_root_in_home_itself_is_home() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + fs::create_dir_all(home.join("baml_src")).unwrap(); + + let root = detect_install_root_in(&home, None, Some(&home)); + + assert_eq!(root, home); + } + #[test] fn root_detection_prefers_nearest_baml_toml() { let _guard = cwd_lock().lock().unwrap(); @@ -869,6 +1143,53 @@ mod tests { make_archive_bytes(&entries) } + /// Like [`make_archive`], but prepends a pax global header carrying + /// `comment=` the way `git archive` (and GitHub codeload) does. + fn make_archive_with_pax(entries: &[(&str, &str)], pax_comment: Option<&str>) -> Vec { + let mut archive_bytes = Vec::new(); + { + let encoder = + flate2::write::GzEncoder::new(&mut archive_bytes, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + if let Some(comment) = pax_comment { + let record_content = format!("comment={comment}\n"); + let mut total = record_content.len(); + // The pax length prefix counts itself: grow until stable. + loop { + let with_prefix = total.to_string().len() + 1 + record_content.len(); + if with_prefix == total { + break; + } + total = with_prefix; + } + let record = format!("{total} {record_content}"); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::XGlobalHeader); + header.set_size(record.len() as u64); + header.set_mode(0o666); + header.set_cksum(); + builder + .append_data(&mut header, "pax_global_header", record.as_bytes()) + .unwrap(); + } + for &(path, content) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data( + &mut header, + format!("baml-skill-main/{path}"), + content.as_bytes(), + ) + .unwrap(); + } + builder.finish().unwrap(); + } + archive_bytes + } + fn make_archive_bytes(entries: &[(&str, &[u8])]) -> Vec { let mut archive_bytes = Vec::new(); { diff --git a/baml_language/crates/baml_cli/tests/agent_install_e2e.rs b/baml_language/crates/baml_cli/tests/agent_install_e2e.rs index c3e9920d1de..fff95a92ae3 100644 --- a/baml_language/crates/baml_cli/tests/agent_install_e2e.rs +++ b/baml_language/crates/baml_cli/tests/agent_install_e2e.rs @@ -1,11 +1,12 @@ -//! End-to-end test for `baml-cli agent install`'s default path: resolve the -//! skill repo head commit, download the tarball at that commit, install the -//! skills, and record provenance in `state.toml`. +//! End-to-end test for `baml-cli agent install`'s default path: download the +//! skill repo's main-branch tarball, install the skills, and record the +//! commit embedded in the tarball's pax global header as provenance in +//! `state.toml`. //! -//! GitHub is stubbed with a local HTTP server via the -//! `BAML_AGENT_SKILLS_COMMITS_URL` / `BAML_AGENT_SKILLS_ARCHIVE_BASE_URL` -//! overrides, so the test exercises the real fetch/parse/install/record flow -//! without network access. +//! The tarball host is stubbed with a local HTTP server via the +//! `BAML_AGENT_SKILLS_ARCHIVE_BASE_URL` override. Deliberately, no GitHub +//! commits API stub exists: the default install must never touch the REST +//! API (whose unauthenticated rate limit used to hard-block installs). use std::{ fs, @@ -16,12 +17,35 @@ use std::{ const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; -fn skill_archive() -> Vec { +/// A codeload-shaped tarball: pax global header carrying the source commit +/// (as `git archive` writes it), then the skill files. +fn skill_archive(pax_comment: Option<&str>) -> Vec { let content = "---\nname: baml-core\ndescription: test skill\n---\n# Core\n"; let mut bytes = Vec::new(); { let encoder = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default()); let mut builder = tar::Builder::new(encoder); + if let Some(comment) = pax_comment { + let record_content = format!("comment={comment}\n"); + let mut total = record_content.len(); + // The length prefix counts itself: grow until it stabilizes. + loop { + let with_prefix = total.to_string().len() + 1 + record_content.len(); + if with_prefix == total { + break; + } + total = with_prefix; + } + let record = format!("{total} {record_content}"); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::XGlobalHeader); + header.set_size(record.len() as u64); + header.set_mode(0o666); + header.set_cksum(); + builder + .append_data(&mut header, "pax_global_header", record.as_bytes()) + .unwrap(); + } let mut header = tar::Header::new_gnu(); header.set_size(content.len() as u64); header.set_mode(0o644); @@ -29,7 +53,7 @@ fn skill_archive() -> Vec { builder .append_data( &mut header, - format!("baml-skill-{COMMIT}/skills/baml-core/SKILL.md"), + "baml-skill-main/skills/baml-core/SKILL.md", content.as_bytes(), ) .unwrap(); @@ -38,9 +62,9 @@ fn skill_archive() -> Vec { bytes } -/// Serve canned GitHub-shaped responses: commit JSON for `/commits/main`, -/// the tarball for `/archive/`. Returns the server's base URL. -fn spawn_stub_github() -> String { +/// Serve the tarball at `/archive/`; everything else (including any +/// attempt to hit a commits API) 404s. Returns the server's base URL. +fn spawn_stub_codeload(archive: Vec) -> String { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); std::thread::spawn(move || { @@ -51,48 +75,47 @@ fn spawn_stub_github() -> String { let request = String::from_utf8_lossy(&buf[..read]).to_string(); let path = request.split_whitespace().nth(1).unwrap_or("").to_string(); - let (content_type, body): (&str, Vec) = if path.starts_with("/commits") { - ( - "application/json", - format!(r#"{{"sha":"{COMMIT}"}}"#).into_bytes(), - ) - } else if path.starts_with("/archive/") { - ("application/gzip", skill_archive()) - } else { + if !path.starts_with("/archive/") { let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n"); continue; - }; + } let _ = stream.write_all( format!( - "HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", - body.len() + "HTTP/1.1 200 OK\r\ncontent-type: application/gzip\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + archive.len() ) .as_bytes(), ); - let _ = stream.write_all(&body); + let _ = stream.write_all(&archive); } }); base } -#[test] -fn default_install_fetches_head_and_records_provenance() { - let server = spawn_stub_github(); - let home = tempfile::tempdir().unwrap(); - let project = tempfile::tempdir().unwrap(); - - let output = Command::new(env!("CARGO_BIN_EXE_baml-cli")) +fn install_command(server: &str, home: &std::path::Path, dir: &std::path::Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_baml-cli")); + command .args(["agent", "install", "--dir"]) - .arg(project.path()) - .env("BAML_HOME", home.path()) - .env( - "BAML_AGENT_SKILLS_COMMITS_URL", - format!("{server}/commits/main"), - ) + .arg(dir) + .env("BAML_HOME", home) .env( "BAML_AGENT_SKILLS_ARCHIVE_BASE_URL", format!("{server}/archive"), ) + // The default install must work for a brand-new user with no GitHub + // credentials of any kind. + .env_remove("GITHUB_TOKEN") + .env_remove("GH_TOKEN"); + command +} + +#[test] +fn default_install_needs_no_github_api_and_records_pax_provenance() { + let server = spawn_stub_codeload(skill_archive(Some(COMMIT))); + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let output = install_command(&server, home.path(), project.path()) .output() .unwrap(); assert!( @@ -108,7 +131,7 @@ fn default_install_fetches_head_and_records_provenance() { assert!(content.contains("name: baml-core"), "{content}"); } - // Provenance recorded in state.toml. + // Provenance recorded in state.toml from the tarball's pax header. let state = fs::read_to_string(home.path().join("state.toml")).unwrap(); assert!(state.contains("[skills]"), "{state}"); assert!( @@ -123,3 +146,43 @@ fn default_install_fetches_head_and_records_provenance() { fs::read_to_string(home.path().join("manifest-cache/skills/latest-commit.json")).unwrap(); assert_eq!(cache, format!(r#"{{"sha":"{COMMIT}"}}"#)); } + +#[test] +fn headerless_archive_installs_without_provenance() { + let server = spawn_stub_codeload(skill_archive(None)); + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let output = install_command(&server, home.path(), project.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "agent install failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let skill = project.path().join(".claude/skills/baml-core/SKILL.md"); + assert!(skill.is_file()); + + // No commit identity → no [skills] provenance recorded. + let state = fs::read_to_string(home.path().join("state.toml")).unwrap_or_default(); + assert!(!state.contains("[skills]"), "{state}"); +} + +#[test] +fn unreachable_archive_fails_with_from_hint() { + let server = spawn_stub_codeload(skill_archive(Some(COMMIT))); + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let mut command = install_command(&server, home.path(), project.path()); + command.env( + "BAML_AGENT_SKILLS_ARCHIVE_BASE_URL", + format!("{server}/missing"), + ); + let output = command.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--from"), "{stderr}"); +}