From dbcdd39a1a4141c82acd2a23d6d76bab08e367d2 Mon Sep 17 00:00:00 2001 From: Eli Foster Date: Tue, 4 Aug 2026 13:47:31 -0700 Subject: [PATCH] Share toolchain caches across isolated sessions A disposable HOME gives hermit, rustup, npm, and pub a cold cache, so every Claude session re-downloaded its whole toolchain: 13.4s and a 278MB hermit/node fetch for a session that does nothing. The caches now point at a Buzz-owned directory outside the profile, which drops a warm session to 0.6s. The directory is Buzz's own rather than the operator's cache: agents write to it, and a poisoned toolchain must not reach the operator's builds. CARGO_HOME stays unset because it holds credentials.toml and cargo has no cache-only knob, so a shared registry would also share registry tokens. Co-Authored-By: Claude Opus 5 Signed-off-by: Eli Foster --- crates/buzz-acp/Cargo.toml | 2 + crates/buzz-acp/src/acp.rs | 172 ++++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index 72388370bf..ab3dbd20fa 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -61,6 +61,8 @@ tracing-subscriber = { workspace = true } # Error handling thiserror = { workspace = true } anyhow = { workspace = true } + +# Disposable profile directories for isolated Claude adapters tempfile = "3" # CLI diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index c1206f02e2..f2f33bbac2 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -193,6 +193,41 @@ fn operator_global_git_config() -> std::collections::HashMap { .collect() } +/// Toolchain caches an isolated session shares with the ones before it. A +/// disposable `HOME` otherwise gives hermit, rustup, npm, and pub a cold cache, +/// so every session re-downloads its whole toolchain — gigabytes in this repo. +/// +/// `CARGO_HOME` is deliberately absent. It holds `credentials.toml`, and cargo +/// offers no cache-only alternative, so sharing the registry would also share +/// registry tokens between sessions. +fn shared_toolchain_cache_env(root: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] { + [ + ("HERMIT_STATE_DIR", root.join("hermit")), + ("RUSTUP_HOME", root.join("rustup")), + ("npm_config_cache", root.join("npm")), + ("PUB_CACHE", root.join("pub-cache")), + ] +} + +/// Resolve the shared cache root from the operator's environment. +/// +/// Buzz owns this directory rather than reusing the operator's own cache: agents +/// write to it, and a poisoned toolchain must not reach the operator's builds. +fn shared_toolchain_cache_root( + os: &str, + lookup: impl Fn(&str) -> Option, +) -> Option { + let base = match os { + "macos" => lookup("HOME")?.join("Library").join("Caches"), + "windows" => lookup("LOCALAPPDATA")?, + _ => match lookup("XDG_CACHE_HOME") { + Some(cache) => cache, + None => lookup("HOME")?.join(".cache"), + }, + }; + Some(base.join("buzz").join("agent-toolchains")) +} + /// Windows tooling that predates `USERPROFILE` composes the home directory from /// `HOMEDRIVE` + `HOMEPATH`; left inherited they point back at the operator. fn windows_home_split(root: &str) -> Option<(&str, &str)> { @@ -794,7 +829,36 @@ impl AcpClient { } // Apply these last. Persona configuration must not redirect a - // Claude process back to the operator's profile directories. + // Claude process back to the operator's profile directories — nor a + // cache variable at the operator's own toolchain, which the agent + // could then poison. + match shared_toolchain_cache_root(std::env::consts::OS, |key| { + std::env::var_os(key).map(std::path::PathBuf::from) + }) { + Some(cache_root) => { + for (key, directory) in shared_toolchain_cache_env(&cache_root) { + // A cold cache is slow, not broken: leave the variable + // unset rather than failing the spawn. + match std::fs::create_dir_all(&directory) { + Ok(()) => { + cmd.env(key, directory); + } + Err(error) => tracing::warn!( + %error, + key, + ?directory, + "claude adapter: shared toolchain cache unavailable; this session \ + will re-download its toolchain" + ), + } + } + } + None => tracing::warn!( + "claude adapter: no host cache directory to derive a shared toolchain cache \ + from; this session will re-download its toolchain" + ), + } + cmd.env("HOME", root) .env("USERPROFILE", root) .env("XDG_CONFIG_HOME", config) @@ -3378,6 +3442,112 @@ mod tests { assert!(super::claude_profile_git_config(|_| None).is_none()); } + #[test] + fn shared_toolchain_cache_root_follows_each_platform_convention() { + let host = |values: &'static [(&'static str, &'static str)]| { + move |key: &str| { + values + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| std::path::PathBuf::from(value)) + } + }; + let suffix: std::path::PathBuf = ["buzz", "agent-toolchains"].iter().collect(); + + assert_eq!( + super::shared_toolchain_cache_root("macos", host(&[("HOME", "/Users/op")])), + Some( + std::path::PathBuf::from("/Users/op") + .join("Library") + .join("Caches") + .join(&suffix) + ) + ); + assert_eq!( + super::shared_toolchain_cache_root( + "windows", + host(&[("LOCALAPPDATA", r"C:\Users\op\AppData\Local")]) + ), + Some(std::path::PathBuf::from(r"C:\Users\op\AppData\Local").join(&suffix)) + ); + // XDG wins over the `~/.cache` fallback on Linux. + assert_eq!( + super::shared_toolchain_cache_root( + "linux", + host(&[("HOME", "/home/op"), ("XDG_CACHE_HOME", "/var/cache/op")]) + ), + Some(std::path::PathBuf::from("/var/cache/op").join(&suffix)) + ); + assert_eq!( + super::shared_toolchain_cache_root("linux", host(&[("HOME", "/home/op")])), + Some( + std::path::PathBuf::from("/home/op") + .join(".cache") + .join(&suffix) + ) + ); + // Nothing to derive from: callers must treat this as "no shared cache". + for os in ["macos", "windows", "linux"] { + assert_eq!(super::shared_toolchain_cache_root(os, host(&[])), None); + } + } + + /// `CARGO_HOME` carries `credentials.toml`, so it must never be shared + /// between sessions even though that costs a per-session registry download. + #[test] + fn shared_toolchain_cache_env_covers_the_toolchains_but_never_cargo_home() { + let root = std::path::Path::new("/cache/buzz/agent-toolchains"); + let shared = super::shared_toolchain_cache_env(root); + let keys: Vec<&str> = shared.iter().map(|(key, _)| *key).collect(); + + assert_eq!( + keys, + [ + "HERMIT_STATE_DIR", + "RUSTUP_HOME", + "npm_config_cache", + "PUB_CACHE" + ] + ); + assert!(!keys.contains(&"CARGO_HOME")); + for (key, directory) in shared { + assert!( + directory.starts_with(root) && directory != root, + "{key} must get its own subdirectory of the shared root, got {directory:?}" + ); + } + } + + /// The whole point of the shared cache: it must land outside the disposable + /// profile, or the next session starts cold again. + #[cfg(unix)] + #[tokio::test] + async fn claude_toolchain_cache_survives_the_disposable_profile() { + let observed = spawn_named_and_read_child_stdout( + "claude-agent-acp", + r#"printf '%s\t%s\t%s\n' "${HERMIT_STATE_DIR:-}" "$HOME" "${CARGO_HOME:-}""#, + ) + .await; + let mut fields = observed.split('\t'); + let hermit = fields.next().expect("hermit state dir field"); + let home = fields.next().expect("home field"); + let cargo_home = fields.next().expect("cargo home field"); + + assert_ne!(hermit, "", "the child needs a warm hermit state dir"); + assert!( + !hermit.starts_with(home), + "{hermit} is inside the disposable profile {home} and dies with it" + ); + assert!( + std::path::Path::new(hermit).is_dir(), + "{hermit} must exist before the child looks for a toolchain" + ); + assert_eq!( + cargo_home, "", + "CARGO_HOME must stay unset: sharing it would share registry tokens" + ); + } + #[test] fn windows_home_split_only_matches_drive_qualified_paths() { assert_eq!(