diff --git a/crates/buzz-backend-kubernetes/src/provider.rs b/crates/buzz-backend-kubernetes/src/provider.rs index 3870981004b..e485312d111 100644 --- a/crates/buzz-backend-kubernetes/src/provider.rs +++ b/crates/buzz-backend-kubernetes/src/provider.rs @@ -12,6 +12,8 @@ use crate::{client, cluster, config, env, identity, naming, reconcile, sandbox}; const RELAY_MESH_PROVIDER: &str = "relay-mesh"; /// Where an operation gets its cluster client and namespace. +// One `Target` exists per request, so boxing `Managed` would save nothing. +#[allow(clippy::large_enum_variant)] pub enum Target { /// The caller's kubeconfig (`provider_config.context`) and /// `provider_config.namespace`, as the desktop runs this provider. @@ -29,6 +31,10 @@ pub enum Target { /// `provider_config` fields the manager sets on every request, such /// as `image` and `pod_options`. They replace the request's own. defaults: serde_json::Map, + /// Launch environment the manager sets for the owner's developer, such + /// as the profile and checkpoint location. It replaces the request's + /// own values for these keys. + env: std::collections::BTreeMap, }, } @@ -70,11 +76,18 @@ impl Target { agent, owner, defaults, + env, .. } = self else { return Ok(()); }; + // The user tier (`launch.env`, or `env_vars` without a launch block), + // so the manager's values replace the request's own. + match request.agent.launch.as_mut() { + Some(launch) => launch.env.extend(env.clone()), + None => request.agent.env_vars.extend(env.clone()), + } let config = request .provider_config .as_object_mut() @@ -395,6 +408,7 @@ mod tests { agent: agent.public_key(), owner: owner.public_key(), defaults: serde_json::Map::new(), + env: Default::default(), } } @@ -435,6 +449,48 @@ mod tests { ); } + /// The manager's launch environment replaces the request's values for its + /// keys, in either user tier, and leaves the developer's other choices, + /// such as the model, effort, and fast mode, as the request sent them. + #[tokio::test] + async fn managed_env_replaces_its_keys_and_keeps_the_rest() { + let (agent, owner) = (nostr::Keys::generate(), nostr::Keys::generate()); + let mut target = managed(&agent, &owner); + if let Target::Managed { env, .. } = &mut target { + env.insert("BUZZ_CHECKPOINT_PREFIX".into(), "dev/".into()); + env.insert("BUZZ_DEVELOPER_ID".into(), "dev".into()); + } + let sandbox = serde_json::json!({"sandbox": {"channel_id": uuid::Uuid::new_v4().to_string(), + "thread_root": "a".repeat(64)}}); + let mut launched: wire::DeployRequest = serde_json::from_value(serde_json::json!({ + "agent": {"relay_url": "wss://r", "private_key_nsec": "nsec1x", + "launch": {"command": "claude-agent-acp", + "env": {"BUZZ_CHECKPOINT_PREFIX": "someone-else/", "KEEP": "1"}, + "policy_env": {"ANTHROPIC_MODEL": "claude-opus-5-5", + "BUZZ_ACP_EFFORT_LEVEL": "xhigh", "BUZZ_ACP_FAST_MODE": "on"}}}, + "provider_config": sandbox, + })) + .unwrap(); + target.confine(&mut launched).unwrap(); + let launch = launched.agent.launch.as_ref().unwrap(); + assert_eq!(launch.env["BUZZ_CHECKPOINT_PREFIX"], "dev/"); + assert_eq!(launch.env["BUZZ_DEVELOPER_ID"], "dev"); + assert_eq!(launch.env["KEEP"], "1"); + assert_eq!(launch.policy_env["ANTHROPIC_MODEL"], "claude-opus-5-5"); + assert_eq!(launch.policy_env["BUZZ_ACP_EFFORT_LEVEL"], "xhigh"); + assert_eq!(launch.policy_env["BUZZ_ACP_FAST_MODE"], "on"); + assert!(launched.agent.env_vars.is_empty()); + // Without a launch block, the legacy user tier carries them. + let mut plain: wire::DeployRequest = serde_json::from_value(serde_json::json!({ + "agent": {"relay_url": "wss://r", "private_key_nsec": "nsec1x", + "env_vars": {"BUZZ_DEVELOPER_ID": "someone-else"}}, + "provider_config": sandbox, + })) + .unwrap(); + target.confine(&mut plain).unwrap(); + assert_eq!(plain.agent.env_vars["BUZZ_DEVELOPER_ID"], "dev"); + } + /// The manager's defaults replace the request's fields and may not set a /// field the manager derives per request. (The forwarding checks in /// `check_sandbox_request` are tested through `buzz-backend-relay`.) diff --git a/crates/buzz-sandbox-reaper/src/manager.rs b/crates/buzz-sandbox-reaper/src/manager.rs index da7a949ad5f..95892f96338 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -44,6 +44,8 @@ const _: () = assert!(REPLAY_WINDOW_SECS > buzz_session_control::EXPIRATION_SECS pub struct ManagerConfig { /// Owner pubkey to its developer. developers: HashMap, + /// The bucket every developer's checkpoints go to, under `/`. + checkpoint_bucket: String, } /// One configured developer. @@ -66,6 +68,8 @@ struct ConfigFile { version: u32, /// Developer ID (the Terraform `developers` key) to its binding. developers: BTreeMap, + /// The checkpoint bucket, shared by every developer under `/`. + checkpoint_bucket: String, } #[derive(Deserialize)] @@ -87,6 +91,8 @@ pub enum ManagerConfigError { UnsupportedVersion(u64), #[error("developers must list at least one developer")] NoDevelopers, + #[error("checkpoint_bucket is invalid")] + InvalidCheckpointBucket, #[error("developers.{developer}.{field} is invalid")] InvalidField { developer: String, @@ -97,6 +103,17 @@ pub enum ManagerConfigError { DuplicateOwner { developer: String }, } +/// An S3 bucket name: 3 to 63 lowercase letters, digits, dots, and hyphens, +/// starting and ending with a letter or digit. +fn is_bucket_name(name: &str) -> bool { + let bytes = name.as_bytes(); + let edge = |b: &u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + (3..=63).contains(&bytes.len()) + && bytes.iter().all(|b| edge(b) || *b == b'.' || *b == b'-') + && bytes.first().is_some_and(edge) + && bytes.last().is_some_and(edge) +} + /// Terraform's developer ID rule: 1-32 of `[a-z0-9-]`, starting and ending /// with a letter or digit. fn is_developer_id(id: &str) -> bool { @@ -142,6 +159,9 @@ impl ManagerConfig { if file.developers.is_empty() { return Err(ManagerConfigError::NoDevelopers); } + if !is_bucket_name(&file.checkpoint_bucket) { + return Err(ManagerConfigError::InvalidCheckpointBucket); + } let mut developers = HashMap::new(); for (id, developer) in file.developers { let invalid = |field| ManagerConfigError::InvalidField { @@ -171,7 +191,10 @@ impl ManagerConfig { return Err(ManagerConfigError::DuplicateOwner { developer: id }); } } - Ok(Self { developers }) + Ok(Self { + developers, + checkpoint_bucket: file.checkpoint_bucket, + }) } /// The developer whose owner key is `owner`. @@ -179,6 +202,28 @@ impl ManagerConfig { self.developers.get(owner) } + /// The launch environment every Sandbox of `developer` gets, whatever the + /// request sends: where its profile and checkpoints are. The image needs + /// the profile to start, and a Sandbox deploy is refused without + /// checkpoint storage. + pub fn launch_env(&self, developer: &Developer) -> BTreeMap { + BTreeMap::from([ + ("BUZZ_DEVELOPER_ID".to_string(), developer.id.clone()), + ( + "BUZZ_DEVELOPER_PROFILE_ARN".to_string(), + developer.profile_secret_arn.clone(), + ), + ( + "BUZZ_CHECKPOINT_BUCKET".to_string(), + self.checkpoint_bucket.clone(), + ), + ( + "BUZZ_CHECKPOINT_PREFIX".to_string(), + format!("{}/", developer.id), + ), + ]) + } + /// How many developers the manager serves. pub fn len(&self) -> usize { self.developers.len() @@ -399,6 +444,7 @@ impl Manager { agent: opened.signer, owner, defaults: self.defaults.clone(), + env: self.config.launch_env(developer), }, }, Err(response) => return answer(response), diff --git a/crates/buzz-sandbox-reaper/src/manager_tests.rs b/crates/buzz-sandbox-reaper/src/manager_tests.rs index 8f28ba12997..04e93c97cc1 100644 --- a/crates/buzz-sandbox-reaper/src/manager_tests.rs +++ b/crates/buzz-sandbox-reaper/src/manager_tests.rs @@ -151,6 +151,7 @@ async fn an_attested_fresh_deploy_starts_in_the_owners_namespace() { namespace, agent: target_agent, owner: target_owner, + env, .. }, } = job.work @@ -158,6 +159,19 @@ async fn an_attested_fresh_deploy_starts_in_the_owners_namespace() { panic!("not a managed deploy"); }; assert_eq!(namespace, NAMESPACE); + // The manager, not the request, says where the profile and checkpoints are. + assert_eq!( + env, + std::collections::BTreeMap::from([ + ("BUZZ_DEVELOPER_ID".to_string(), "dev".to_string()), + ( + "BUZZ_DEVELOPER_PROFILE_ARN".to_string(), + "arn:aws:secretsmanager:us-west-2:000000000000:secret:voltage/staging/agents/dev/profile-AbCdEf".to_string(), + ), + ("BUZZ_CHECKPOINT_BUCKET".to_string(), "fixture-checkpoints".to_string()), + ("BUZZ_CHECKPOINT_PREFIX".to_string(), "dev/".to_string()), + ]) + ); assert_eq!(target_agent, agent().public_key()); assert_eq!(target_owner, owner().public_key()); } @@ -543,7 +557,7 @@ fn config_json(developers: &[(&str, &Keys)]) -> Value { (id.to_string(), entry) }) .collect(); - json!({"version": 1, "developers": developers}) + json!({"version": 1, "checkpoint_bucket": "fixture-checkpoints", "developers": developers}) } #[test] @@ -644,6 +658,16 @@ fn manager_config_refuses_each_invalid_field_and_names_it() { json!({}), Err(ManagerConfigError::NoDevelopers), ), + ( + "/checkpoint_bucket", + json!("Not_A_Bucket"), + Err(ManagerConfigError::InvalidCheckpointBucket), + ), + ( + "/checkpoint_bucket", + json!("ab"), + Err(ManagerConfigError::InvalidCheckpointBucket), + ), ]; for (pointer, bad, expected) in cases { let mut value = config_json(&[("dev", &owner())]); @@ -665,6 +689,12 @@ fn manager_config_refuses_each_invalid_field_and_names_it() { Err(ManagerConfigError::Malformed(_)) )); value = config_json(&[("dev", &owner())]); + value.as_object_mut().unwrap().remove("checkpoint_bucket"); + assert!(matches!( + ManagerConfig::parse(&value.to_string()), + Err(ManagerConfigError::Malformed(_)) + )); + value = config_json(&[("dev", &owner())]); value["namespace_prefix"] = json!("buzz-agent-staging-"); assert!(matches!( ManagerConfig::parse(&value.to_string()), diff --git a/crates/buzz-sandbox-reaper/src/relay_provider_tests.rs b/crates/buzz-sandbox-reaper/src/relay_provider_tests.rs index bcd6cd2d91c..f1808936abf 100644 --- a/crates/buzz-sandbox-reaper/src/relay_provider_tests.rs +++ b/crates/buzz-sandbox-reaper/src/relay_provider_tests.rs @@ -33,7 +33,7 @@ async fn start_manager( tokio::task::JoinHandle<()>, ) { let config = ManagerConfig::parse( - &json!({"version": 1, "developers": {"dev": { + &json!({"version": 1, "checkpoint_bucket": "fixture-checkpoints", "developers": {"dev": { "owner_pubkey": keys("33").public_key().to_hex(), "namespace": "buzz-agent-staging-dev", "profile_secret_arn": "arn:aws:secretsmanager:us-west-2:000000000000:secret:voltage/staging/agents/dev/profile-AbCdEf"}}}) diff --git a/crates/buzz-sandbox-reaper/src/serve_tests.rs b/crates/buzz-sandbox-reaper/src/serve_tests.rs index a0a3192d3b1..539d7bfeee7 100644 --- a/crates/buzz-sandbox-reaper/src/serve_tests.rs +++ b/crates/buzz-sandbox-reaper/src/serve_tests.rs @@ -217,7 +217,7 @@ fn payload(op: &str, agent: &Keys, owner: &Keys, auth: &str) -> Value { async fn deploy_and_stop_run_end_to_end_over_the_relay() { let (manager_keys, agent_keys, owner) = (keys("11"), keys("22"), keys("33")); let config = ManagerConfig::parse( - &json!({"version": 1, "developers": {"dev": { + &json!({"version": 1, "checkpoint_bucket": "fixture-checkpoints", "developers": {"dev": { "owner_pubkey": owner.public_key().to_hex(), "namespace": "buzz-agent-staging-dev", "profile_secret_arn": "arn:aws:secretsmanager:us-west-2:000000000000:secret:voltage/staging/agents/dev/profile-AbCdEf"}}}) @@ -350,7 +350,7 @@ async fn enroll_runs_end_to_end_and_its_events_carry_no_profile_value() { let (manager_keys, owner) = (keys("11"), keys("33")); let arn = "arn:aws:secretsmanager:us-west-2:000000000000:secret:voltage/staging/agents/dev/profile-AbCdEf"; let config = ManagerConfig::parse( - &json!({"version": 1, "developers": {"dev": {"owner_pubkey": owner.public_key().to_hex(), + &json!({"version": 1, "checkpoint_bucket": "fixture-checkpoints", "developers": {"dev": {"owner_pubkey": owner.public_key().to_hex(), "namespace": "buzz-agent-staging-dev", "profile_secret_arn": arn}}}) .to_string(), ) diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index f4ab2e4f2e3..957a7b15647 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -90,7 +90,7 @@ Developer tools and configured MCP servers remain available; the previous text-o Each developer enrolls one profile from the Buzz app (GPUI), through the Sandbox manager over the relay. Every one of their Sandboxes and workers loads it. Terraform creates the empty Secrets Manager secret per developer; the manager writes it, and the developer's `agent-workspace` and `agent-worker` service accounts can only read its exact ARN. -The manager sets `BUZZ_DEVELOPER_ID` and `BUZZ_DEVELOPER_PROFILE_ARN` in each launch from its developer config, and fills in the profile's `developer` from the signing owner's entry. +The manager sets `BUZZ_DEVELOPER_ID`, `BUZZ_DEVELOPER_PROFILE_ARN`, `BUZZ_CHECKPOINT_BUCKET`, and `BUZZ_CHECKPOINT_PREFIX` (`/`) in each launch from its developer config, replacing any value the request sends, and fills in the profile's `developer` from the signing owner's entry. [profile.example.json](profile.example.json) shows the stored shape. The bootstrap checks the profile against Buzz's verified owner, then writes Git identity, GitHub, via, the agent signing key, and the vault's Depot credential to private workspace files. diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index d51f9aa86b4..75310fcec5c 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -47,6 +47,7 @@ flowchart LR - **The Buzz app** sends requests as you. Each request is a Nostr event of kind 24210. Its content is encrypted to the Sandbox manager with NIP-44. - **The Sandbox manager** accepts requests only from owner keys in its developer list. It starts a Sandbox in your namespace for each thread. +- **You choose** the harness, model, effort, and fast mode for each agent. **The manager sets** where your profile and checkpoints are, so a request cannot choose them. - **The Sandbox** loads your profile, starts the harness you chose, and replies in the thread as your agent. - **The reaper** removes Sandboxes after their sessions end.