Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions crates/buzz-backend-kubernetes/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String, serde_json::Value>,
/// 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<String, String>,
},
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -395,6 +408,7 @@ mod tests {
agent: agent.public_key(),
owner: owner.public_key(),
defaults: serde_json::Map::new(),
env: Default::default(),
}
}

Expand Down Expand Up @@ -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`.)
Expand Down
48 changes: 47 additions & 1 deletion crates/buzz-sandbox-reaper/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const _: () = assert!(REPLAY_WINDOW_SECS > buzz_session_control::EXPIRATION_SECS
pub struct ManagerConfig {
/// Owner pubkey to its developer.
developers: HashMap<PublicKey, Developer>,
/// The bucket every developer's checkpoints go to, under `<id>/`.
checkpoint_bucket: String,
}

/// One configured developer.
Expand All @@ -66,6 +68,8 @@ struct ConfigFile {
version: u32,
/// Developer ID (the Terraform `developers` key) to its binding.
developers: BTreeMap<String, DeveloperEntry>,
/// The checkpoint bucket, shared by every developer under `<id>/`.
checkpoint_bucket: String,
}

#[derive(Deserialize)]
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -171,14 +191,39 @@ 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`.
pub fn developer(&self, owner: &PublicKey) -> Option<&Developer> {
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<String, String> {
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()
Expand Down Expand Up @@ -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),
Expand Down
32 changes: 31 additions & 1 deletion crates/buzz-sandbox-reaper/src/manager_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,27 @@ async fn an_attested_fresh_deploy_starts_in_the_owners_namespace() {
namespace,
agent: target_agent,
owner: target_owner,
env,
..
},
} = job.work
else {
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());
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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())]);
Expand All @@ -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()),
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-sandbox-reaper/src/relay_provider_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}})
Expand Down
4 changes: 2 additions & 2 deletions crates/buzz-sandbox-reaper/src/serve_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}})
Expand Down Expand Up @@ -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(),
)
Expand Down
2 changes: 1 addition & 1 deletion voltage/images/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<id>/`) 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.
Expand Down
1 change: 1 addition & 0 deletions voltage/remote-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading