diff --git a/Cargo.lock b/Cargo.lock index b7e7e325c85..370d54c9177 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2104,7 +2104,9 @@ dependencies = [ "buzz-ws-client", "chrono", "clap", + "curve25519-dalek 4.1.3", "futures-util", + "getrandom 0.4.3", "http 1.4.2", "http-body-util", "k8s-openapi", diff --git a/crates/buzz-backend-kubernetes/src/sandbox_fake.rs b/crates/buzz-backend-kubernetes/src/sandbox_fake.rs index 8481356c2e2..77b825738a3 100644 --- a/crates/buzz-backend-kubernetes/src/sandbox_fake.rs +++ b/crates/buzz-backend-kubernetes/src/sandbox_fake.rs @@ -39,6 +39,8 @@ pub struct State { pub fail_secret_create_responses: usize, /// Fail Secret updates before writing, for durable inbox recovery tests. pub fail_secret_updates: usize, + /// Fail this many Secret reads without changing stored values. + pub fail_secret_reads: usize, /// Fail Pod-owned credential Secret creates, leaving recovery journals intact. pub fail_binding_secret_creates: usize, /// Fail this many Sandbox creates with a 500. @@ -227,10 +229,16 @@ impl State { self.secrets.insert((*name).into(), stored.clone()); ok(&stored) } - ["api", "v1", "namespaces", _, "secrets", name] => match self.secrets.get(*name) { - Some(secret) => ok(secret), - None => status(404, "NotFound"), - }, + ["api", "v1", "namespaces", _, "secrets", name] => { + if self.fail_secret_reads > 0 { + self.fail_secret_reads -= 1; + return status(500, "Unavailable"); + } + match self.secrets.get(*name) { + Some(secret) => ok(secret), + None => status(404, "NotFound"), + } + } ["api", "v1", "namespaces", namespace, "secrets"] if method == Method::GET => { let items: Vec<_> = self .secrets diff --git a/crates/buzz-backend-kubernetes/src/wire.rs b/crates/buzz-backend-kubernetes/src/wire.rs index 3a9a084d9de..5ffd12e2f70 100644 --- a/crates/buzz-backend-kubernetes/src/wire.rs +++ b/crates/buzz-backend-kubernetes/src/wire.rs @@ -244,6 +244,10 @@ pub enum ErrorCode { GithubLoginMismatch, /// The profile could not be written to the developer's secret. ProfileStoreFailed, + /// The developer's Paseo daemon Secret could not be written: for + /// `enroll`, before the profile was stored, so it is unchanged; for + /// `deploy`, after the Sandbox started. Repeating the request retries it. + PaseoStoreFailed, /// No saved launch exists for this owner, community and agent. LaunchNotRegistered, /// A saved launch could not be read or durably updated. diff --git a/crates/buzz-sandbox-reaper/Cargo.toml b/crates/buzz-sandbox-reaper/Cargo.toml index bd375a59031..920697e73e3 100644 --- a/crates/buzz-sandbox-reaper/Cargo.toml +++ b/crates/buzz-sandbox-reaper/Cargo.toml @@ -50,6 +50,10 @@ toml = "1.0" zeroize = { workspace = true } url = { workspace = true } base64 = { workspace = true } +# Paseo daemon pairing (`paseo.rs`): OS randomness, and the X25519 public key +# of a NaCl box keypair. Both crates are already in the workspace lock. +getrandom = "0.4" +curve25519-dalek = "4.1" uuid = { workspace = true } # Same reason as buzz-backend-kubernetes: the process-level CryptoProvider is # installed explicitly because the workspace unifies ring and aws-lc-rs. diff --git a/crates/buzz-sandbox-reaper/src/enroll.rs b/crates/buzz-sandbox-reaper/src/enroll.rs index e439c949016..99c7e0ed07f 100644 --- a/crates/buzz-sandbox-reaper/src/enroll.rs +++ b/crates/buzz-sandbox-reaper/src/enroll.rs @@ -28,6 +28,7 @@ use zeroize::Zeroizing; use crate::codex_login::{Broker, Change}; use crate::manager::{refusal, Developer}; +use crate::paseo::{self, DaemonSecrets}; use crate::profile; pub const DEFAULT_GITHUB_API_URL: &str = "https://api.github.com"; @@ -314,6 +315,9 @@ pub struct Enrollment { github: GitHub, store: Arc, broker: Option>, + paseo: Option, + /// Serialize profile and daemon Secret updates, including without a broker. + lock: tokio::sync::Mutex<()>, } impl Enrollment { @@ -322,6 +326,8 @@ impl Enrollment { github, store, broker: None, + paseo: None, + lock: tokio::sync::Mutex::new(()), } } @@ -330,6 +336,32 @@ impl Enrollment { self } + /// Provision pairing and a gateway key only in the daemon Secret. + pub fn with_paseo(mut self, secrets: DaemonSecrets) -> Self { + self.paseo = Some(secrets); + self + } + + /// A launch registration of `developer` changed: list it in their Paseo + /// daemon's Secret. The registration is already saved, so the refusal + /// asks for a retry of the same deploy, which saves it again. + pub async fn agents_changed(&self, developer: &Developer) -> Result<(), Value> { + let Some(secrets) = &self.paseo else { + return Ok(()); + }; + secrets + .refresh_agents(developer) + .await + .map_err(|paseo::Failed(label)| { + refusal( + ErrorCode::PaseoStoreFailed, + &format!( + "the agent started, but its Paseo daemon secret was not updated ({label}); start it again" + ), + ) + }) + } + /// The developer's stored profile, parsed. An unreadable stored value /// counts as none, so the next enroll must send a complete profile. async fn stored(&self, developer: &Developer) -> Result, Value> { @@ -364,6 +396,7 @@ impl Enrollment { partial: Value, login: Option, ) -> Value { + let _serialized = self.lock.lock().await; let _guard = match &self.broker { Some(broker) => match broker.lock(developer).await { Ok(guard) => Some(guard), @@ -458,6 +491,22 @@ impl Enrollment { if let Err(profile::Invalid(message)) = profile::validate(&profile) { return refusal(ErrorCode::InvalidProfile, message); } + // Before the profile write, so a refusal always means the profile is + // unchanged: a client that sees one keeps what it sent (a new signing + // key) to send again. A Secret written for a profile that then fails + // to store holds only what this request carries, and its retry + // rewrites the Secret. + let pairing = if let Some(secrets) = &self.paseo { + match secrets.write(developer, &profile).await { + Ok(pairing) => Some(pairing), + Err(paseo::Failed(label)) => return refusal( + ErrorCode::PaseoStoreFailed, + &format!("could not write the Paseo daemon secret ({label}); the profile is unchanged, retry"), + ), + } + } else { + None + }; // Revoke before changing the profile: stale Secrets Manager replicas // may still serve its old broker credential, which must no longer work. if !connects_codex && (disconnects_codex || replaces_codex) { @@ -486,7 +535,12 @@ impl Enrollment { } let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let summary = profile::summary(&profile, &version_id, Some(now)); - serde_json::json!({"ok": true, "version_id": version_id, "profile": summary}) + let mut reply = + serde_json::json!({"ok": true, "version_id": version_id, "profile": summary}); + if let Some(pairing) = pairing { + reply["paseo"] = pairing; + } + reply } Err(StoreFailed(label)) => refusal( ErrorCode::ProfileStoreFailed, @@ -496,14 +550,32 @@ impl Enrollment { } /// The `profile` operation: the stored profile's [`profile::summary`], - /// or `null` when none is stored. + /// or `null` when none is stored, and its Paseo pairing details. pub async fn describe(&self, developer: &Developer) -> Value { match self.stored(developer).await { - Ok(Some((profile, stored))) => serde_json::json!({ - "ok": true, - "profile": profile::summary(&profile, &stored.version_id, stored.created), - }), - Ok(None) => serde_json::json!({"ok": true, "profile": null}), + Ok(Some((profile, stored))) => { + let mut reply = serde_json::json!({ + "ok": true, + "profile": profile::summary(&profile, &stored.version_id, stored.created), + "paseo_enabled": self.paseo.is_some(), + }); + if let Some(secrets) = &self.paseo { + match secrets.pairing(developer).await { + Ok(Some(pairing)) => reply["paseo"] = pairing, + Ok(None) => {} + Err(paseo::Failed(label)) => { + return refusal( + ErrorCode::PaseoStoreFailed, + &format!("could not read the Paseo daemon secret ({label}); retry"), + ) + } + } + } + reply + } + Ok(None) => { + serde_json::json!({"ok": true, "profile": null, "paseo_enabled": self.paseo.is_some()}) + } Err(refused) => refused, } } diff --git a/crates/buzz-sandbox-reaper/src/enroll_tests.rs b/crates/buzz-sandbox-reaper/src/enroll_tests.rs index 66d38067e75..98db94ab233 100644 --- a/crates/buzz-sandbox-reaper/src/enroll_tests.rs +++ b/crates/buzz-sandbox-reaper/src/enroll_tests.rs @@ -3,9 +3,12 @@ //! relay round trip in `serve_tests.rs`. use super::*; +use std::collections::BTreeMap; use std::sync::Mutex; use axum::http::{HeaderMap, StatusCode}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use buzz_backend_kubernetes::sandbox_fake; use serde_json::json; /// What a write received: ARN, region and secret string. @@ -308,19 +311,178 @@ async fn a_first_enroll_must_send_the_complete_profile() { assert!(written.lock().unwrap().is_empty()); } +/// The developer's daemon Secret in the fake cluster, by key, decoded. +fn daemon_secret(cluster: &sandbox_fake::Shared) -> Option<(Value, BTreeMap)> { + let cluster = cluster.lock().unwrap(); + let secret = cluster.secrets.get("paseo-daemon-dev")?; + let data = secret["data"] + .as_object() + .unwrap() + .iter() + .map(|(key, value)| { + let bytes = STANDARD.decode(value.as_str().unwrap()).unwrap(); + (key.clone(), String::from_utf8(bytes).unwrap()) + }) + .collect(); + Some((secret["metadata"].clone(), data)) +} + +/// The test relay's daemon Secrets, in `cluster`. +pub(crate) fn daemon_secrets(cluster: &sandbox_fake::Shared) -> DaemonSecrets { + DaemonSecrets::new( + sandbox_fake::client(cluster), + paseo::RELAY_ENDPOINT, + "wss://relay.example.invalid", + ) + .unwrap() +} + +/// With Paseo daemons on, the first enroll generates a pairing and stores +/// only in the narrow Secret. A failed daemon Secret write is reported, never a +/// silent success, and stores no profile. Later enrolls keep the pairing, so paired apps +/// survive, writes the Secret with the daemon's environment only (the +/// developer's registered agents, and of the model credentials only the +/// Claude subscription token), and returns the password and offer; so does +/// `profile`. Replacing the Secret works too. +#[tokio::test] +async fn paseo_pairing_is_kept_only_in_the_daemon_secret_and_returned() { + use crate::mobile_launch::Registration; + use nostr::nips::nip19::ToBech32; + + let cluster = sandbox_fake::Shared::default(); + let (owner, agent) = ( + nostr::Keys::parse(&"33".repeat(32)).unwrap(), + nostr::Keys::parse(&"22".repeat(32)).unwrap(), + ); + let channel = "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10"; + let launch = json!({"relay_url": "wss://relay.example.invalid", + "private_key_nsec": agent.secret_key().to_bech32().unwrap(), + "launch": {"policy_env": {"BUZZ_ACP_DISPLAY_NAME": "Brokkr"}}}); + Registration::new(owner.public_key(), agent.public_key(), launch) + .unwrap() + .in_channel(&json!({"sandbox": {"channel_id": channel, "thread_root": "b".repeat(64)}})) + .save(sandbox_fake::client(&cluster), "buzz-agent-staging-dev") + .await + .unwrap(); + // Another community's registration is not this daemon's. + let elsewhere = nostr::Keys::parse(&"55".repeat(32)).unwrap(); + Registration::new( + owner.public_key(), + elsewhere.public_key(), + json!({"relay_url": "wss://other.example.invalid", + "private_key_nsec": elsewhere.secret_key().to_bech32().unwrap()}), + ) + .unwrap() + .in_channel(&json!({"sandbox": {"channel_id": channel, "thread_root": "b".repeat(64)}})) + .save(sandbox_fake::client(&cluster), "buzz-agent-staging-dev") + .await + .unwrap(); + cluster.lock().unwrap().fail_secret_creates = 1; + let (enrollment, written) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await; + let enrollment = enrollment.with_paseo(daemon_secrets(&cluster)); + assert_eq!( + enrollment.describe(&developer()).await["paseo_enabled"], + true, + "enabled provisioning is advertised before a daemon Secret exists" + ); + + // A refusal leaves the profile unchanged, so a client that keeps what + // it sent (a new signing key) for its retry never strands a stored one. + let response = enrollment.run(&developer(), profile()).await; + assert_eq!(response["code"], "paseo_store_failed", "{response}"); + assert!(response["error"].as_str().unwrap().contains("unchanged")); + assert!(written.lock().unwrap().is_empty()); + assert!(daemon_secret(&cluster).is_none()); + let response = enrollment.run(&developer(), profile()).await; + assert_eq!(response["version_id"], "version-1", "{response}"); + assert!(stored(&written).get("paseo").is_none()); + let initial = daemon_secret(&cluster).unwrap().1; + let pairing = json!({"password": initial["PASEO_PASSWORD"], "server_id": initial["PASEO_SERVER_ID"], + "daemon_keypair": serde_json::from_str::(&initial["PASEO_DAEMON_KEYPAIR"]).unwrap()}); + assert_eq!( + daemon_secret(&cluster).unwrap().1["PASEO_PASSWORD"], + pairing["password"].as_str().unwrap() + ); + + // A failed Secret read must not generate a new identity or write a profile. + cluster.lock().unwrap().fail_secret_reads = 1; + let refused = enrollment.run(&developer(), profile()).await; + assert_eq!(refused["code"], "paseo_store_failed", "{refused}"); + assert_eq!(written.lock().unwrap().len(), 1); + assert_eq!(daemon_secret(&cluster).unwrap().1, initial); + + let partial = json!({"version": 1, "developer": "dev", "owner_pubkey": "a".repeat(64), + "models": {"claude_oauth_token": "sk-ant-oat01-fixture"}}); + for version in ["version-2", "version-3"] { + let response = enrollment.run(&developer(), partial.clone()).await; + assert_eq!(response["version_id"], version, "{response}"); + assert!(stored(&written).get("paseo").is_none()); + let (metadata, env) = daemon_secret(&cluster).unwrap(); + assert_eq!(metadata["namespace"], "buzz-paseo"); + let agents = json!([{"id": "brokkr", "label": "Brokkr", + "agent": agent.public_key().to_hex(), "channel": channel}]); + assert_eq!( + env, + BTreeMap::from([ + ( + "PASEO_PASSWORD".into(), + pairing["password"].as_str().unwrap().into() + ), + ( + "PASEO_SERVER_ID".into(), + pairing["server_id"].as_str().unwrap().into() + ), + ( + "PASEO_DAEMON_KEYPAIR".into(), + pairing["daemon_keypair"].to_string() + ), + ("BUZZ_PASEO_AGENTS".into(), agents.to_string()), + ( + "BUZZ_PRIVATE_KEY".into(), + initial["BUZZ_PRIVATE_KEY"].clone() + ), + ( + "CLAUDE_CODE_OAUTH_TOKEN".into(), + "sk-ant-oat01-fixture".into() + ), + ]) + ); + let offer = crate::paseo::offer_url( + pairing["server_id"].as_str().unwrap(), + pairing["daemon_keypair"]["publicKeyB64"].as_str().unwrap(), + paseo::RELAY_ENDPOINT, + ); + let expected = json!({"password": pairing["password"], + "server_id": pairing["server_id"], "offer_url": offer, + "gateway_pubkey": nostr::Keys::parse(&initial["BUZZ_PRIVATE_KEY"]).unwrap().public_key().to_hex()}); + assert_eq!(response["paseo"], expected); + let described = enrollment.describe(&developer()).await; + assert_eq!(described["paseo"], expected); + assert_eq!(described["paseo_enabled"], true); + // The daemon's secret key stays out of every reply. + let secret = pairing["daemon_keypair"]["secretKeyB64"].as_str().unwrap(); + assert!(!response.to_string().contains(secret)); + assert!(!response.to_string().contains(&initial["BUZZ_PRIVATE_KEY"])); + assert!(!stored(&written).to_string().contains(secret)); + assert!(!stored(&written) + .to_string() + .contains(&initial["BUZZ_PRIVATE_KEY"])); + } +} + /// `profile` describes the stored profile, naming no credential value. #[tokio::test] async fn describe_names_what_is_stored_and_no_credential() { let (enrollment, _) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await; assert_eq!( enrollment.describe(&developer()).await, - json!({"ok": true, "profile": null}) + json!({"ok": true, "profile": null, "paseo_enabled": false}) ); enrollment.run(&developer(), profile()).await; let described = enrollment.describe(&developer()).await; assert_eq!( described, - json!({"ok": true, "profile": { + json!({"ok": true, "paseo_enabled": false, "profile": { "version_id": "version-1", "updated_at": "2026-10-23T12:00:00Z", "git": {"name": "Ada Lovelace", "email": "ada@example.com", "signing_key": true}, diff --git a/crates/buzz-sandbox-reaper/src/main.rs b/crates/buzz-sandbox-reaper/src/main.rs index 2b995a37606..a108ce33f0c 100644 --- a/crates/buzz-sandbox-reaper/src/main.rs +++ b/crates/buzz-sandbox-reaper/src/main.rs @@ -16,6 +16,7 @@ mod evidence; mod manager; mod mentions; mod mobile_launch; +mod paseo; mod plan; mod profile; mod reconcile; diff --git a/crates/buzz-sandbox-reaper/src/manager.rs b/crates/buzz-sandbox-reaper/src/manager.rs index 53efead6fd0..1435361ec57 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -50,6 +50,11 @@ pub struct ManagerConfig { checkpoint_bucket: String, /// Enable only after deploying a matching shared-task agent image. shared_tasks: bool, + /// Provision Paseo daemons at enrollment (`paseo.rs`). Enable after + /// creating the `buzz-paseo` namespace and Secret Role. + paseo_daemons: bool, + /// Where the daemons and the Paseo apps meet, `host:port`, always TLS. + paseo_relay_endpoint: String, } /// One configured developer. @@ -76,6 +81,10 @@ struct ConfigFile { checkpoint_bucket: String, #[serde(default)] shared_tasks: bool, + #[serde(default)] + paseo_daemons: bool, + #[serde(default)] + paseo_relay_endpoint: Option, } #[derive(Deserialize)] @@ -107,6 +116,8 @@ pub enum ManagerConfigError { /// The owner selects the namespace, so it must select only one. #[error("developers.{developer}.owner_pubkey belongs to another developer too")] DuplicateOwner { developer: String }, + #[error("paseo_relay_endpoint must be host:port, without a scheme or path")] + InvalidPaseoRelayEndpoint, } /// An S3 bucket name: 3 to 63 lowercase letters, digits, dots, and hyphens, @@ -197,13 +208,31 @@ impl ManagerConfig { return Err(ManagerConfigError::DuplicateOwner { developer: id }); } } + let paseo_relay_endpoint = file + .paseo_relay_endpoint + .unwrap_or_else(|| crate::paseo::RELAY_ENDPOINT.to_owned()); + if !crate::paseo::is_relay_endpoint(&paseo_relay_endpoint) { + return Err(ManagerConfigError::InvalidPaseoRelayEndpoint); + } Ok(Self { developers, checkpoint_bucket: file.checkpoint_bucket, shared_tasks: file.shared_tasks, + paseo_daemons: file.paseo_daemons, + paseo_relay_endpoint, }) } + /// Whether enrollment provisions each developer's Paseo daemon. + pub fn paseo_daemons(&self) -> bool { + self.paseo_daemons + } + + /// The Paseo relay endpoint the pairing offers name (`host:port`). + pub fn paseo_relay_endpoint(&self) -> &str { + &self.paseo_relay_endpoint + } + /// The developer whose owner key is `owner`. pub fn developer(&self, owner: &PublicKey) -> Option<&Developer> { self.developers.get(owner) @@ -276,6 +305,9 @@ pub enum Work { request: Request, target: Target, registration: Option, + /// The verified owner's developer, whose Paseo daemon lists the + /// registered agents. + developer: Developer, }, /// An owner-authorized start using a registered launch identity. Start { start: Start, target: Target }, @@ -308,6 +340,7 @@ impl Work { request, target, registration, + developer, } => { let response = response_value(&provider::run(request, &target).await); if response["ok"] == true { @@ -321,6 +354,9 @@ impl Work { if let Err(error) = registration.save(client.clone(), namespace).await { return error; } + if let Err(error) = manager.enrollment.agents_changed(&developer).await { + return error; + } } } response @@ -641,7 +677,9 @@ impl Manager { opened.signer, opened.request.payload["agent"].clone(), ) { - Ok(registration) => Some(registration), + Ok(registration) => { + Some(registration.in_channel(&opened.request.payload["provider_config"])) + } Err(response) => return answer(response), } } else { @@ -664,6 +702,7 @@ impl Manager { Work::Provider { request, registration, + developer: developer.clone(), target: Target::Managed { client: self.client.clone(), namespace: developer.namespace.clone(), diff --git a/crates/buzz-sandbox-reaper/src/manager_tests.rs b/crates/buzz-sandbox-reaper/src/manager_tests.rs index 1bb39799ccf..b160ea7e9cf 100644 --- a/crates/buzz-sandbox-reaper/src/manager_tests.rs +++ b/crates/buzz-sandbox-reaper/src/manager_tests.rs @@ -643,6 +643,22 @@ fn manager_config_maps_each_owner_to_its_namespace() { .map(|d| d.namespace.as_str()), None ); + // Paseo provisioning is off unless Terraform turns it on. + assert!(!config.paseo_daemons()); + let mut value = config_json(&[("dev", &owner())]); + value["paseo_daemons"] = json!(true); + let parsed = ManagerConfig::parse(&value.to_string()).unwrap(); + assert!(parsed.paseo_daemons()); + // Offers name the Buzz relay's host unless the config says otherwise. + assert_eq!(parsed.paseo_relay_endpoint(), "buzz.voltage.cloud:443"); + value["paseo_relay_endpoint"] = json!("paseo.example.test:8443"); + let parsed = ManagerConfig::parse(&value.to_string()).unwrap(); + assert_eq!(parsed.paseo_relay_endpoint(), "paseo.example.test:8443"); + value["paseo_relay_endpoint"] = json!("wss://buzz.voltage.cloud:443"); + assert!(matches!( + ManagerConfig::parse(&value.to_string()), + Err(ManagerConfigError::InvalidPaseoRelayEndpoint) + )); } #[test] @@ -755,3 +771,93 @@ fn manager_config_refuses_each_invalid_field_and_names_it() { Err(ManagerConfigError::Malformed(_)) )); } + +/// A registering deploy lists the agent, with its deploy's channel, in the +/// owner's Paseo daemon Secret, keeping the Secret's other keys. A failed +/// update is refused, never a silent success, and the same deploy retried +/// updates it. Before the owner enrolls with Paseo on, there is no Secret +/// and the deploy creates none. +#[tokio::test] +async fn a_registering_deploy_lists_the_agent_for_the_owners_paseo_daemon() { + use base64::{engine::general_purpose::STANDARD, Engine}; + use buzz_backend_kubernetes::sandbox_fake; + + let cluster = sandbox_fake::Shared::default(); + let config = ManagerConfig::parse(&config_json(&[("dev", &owner())]).to_string()).unwrap(); + let developer = config.developer(&owner().public_key()).unwrap().clone(); + let enrollment = crate::enroll::tests::offline(Default::default()) + .with_paseo(crate::enroll::tests::daemon_secrets(&cluster)); + let manager = Manager::new( + manager_keys(), + config, + sandbox_fake::client(&cluster), + enrollment, + ) + .with_sandbox_defaults( + json!({"pod_options": {"active_deadline_seconds": 3600}}) + .as_object() + .unwrap() + .clone(), + ); + let mut sent = Sent::deploy(); + sent.payload["agent"]["launch"]["policy_env"] = json!({"BUZZ_ACP_DISPLAY_NAME": "Brokkr"}); + let deploy = |sent: &Sent| { + let Admission::Start { job, .. } = manager.admit(&sent.event(), now()) else { + panic!("deploy refused"); + }; + job.work.run(&manager) + }; + let response = deploy(&sent).await; + assert_eq!(response["ok"], true, "{response}"); + assert!(!cluster + .lock() + .unwrap() + .secrets + .contains_key("paseo-daemon-dev")); + + let profile = crate::profile::tests::valid("dev", &"a".repeat(64)); + crate::enroll::tests::daemon_secrets(&cluster) + .write(&developer, &profile) + .await + .unwrap(); + let env = || -> std::collections::BTreeMap { + let cluster = cluster.lock().unwrap(); + cluster.secrets["paseo-daemon-dev"]["data"] + .as_object() + .unwrap() + .iter() + .map(|(key, value)| { + let bytes = STANDARD.decode(value.as_str().unwrap()).unwrap(); + (key.clone(), String::from_utf8(bytes).unwrap()) + }) + .collect() + }; + let before = env(); + // Only the daemon Secret refuses replacement (a conflict every time). + let immutable = |value: bool| { + cluster + .lock() + .unwrap() + .secrets + .get_mut("paseo-daemon-dev") + .unwrap()["immutable"] = json!(value); + }; + immutable(true); + sent.request_id = "r-2".into(); + let response = deploy(&sent).await; + assert_eq!(response["code"], "paseo_store_failed", "{response}"); + assert_eq!(env(), before); + + immutable(false); + sent.request_id = "r-3".into(); + let response = deploy(&sent).await; + assert_eq!(response["ok"], true, "{response}"); + let mut expected = before; + expected.insert( + "BUZZ_PASEO_AGENTS".into(), + json!([{"id": "brokkr", "label": "Brokkr", "agent": agent().public_key().to_hex(), + "channel": "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10"}]) + .to_string(), + ); + assert_eq!(env(), expected); +} diff --git a/crates/buzz-sandbox-reaper/src/mobile_launch.rs b/crates/buzz-sandbox-reaper/src/mobile_launch.rs index e67ad87ffb4..06f78ffcc4d 100644 --- a/crates/buzz-sandbox-reaper/src/mobile_launch.rs +++ b/crates/buzz-sandbox-reaper/src/mobile_launch.rs @@ -15,6 +15,10 @@ use crate::manager::refusal; pub(crate) const DATA_KEY: &str = "launch.json"; pub(crate) const LABEL: &str = "buzz.voltage.cloud/mobile-launch"; +/// The channel of the agent's latest channel-thread deploy, for its owner's +/// Paseo daemon (`paseo.rs`). An annotation, not a payload field, so a +/// manager without it still reads the registration. +pub(crate) const CHANNEL_ANNOTATION: &str = "buzz.voltage.cloud/paseo-channel"; fn store_failed() -> Value { refusal( @@ -37,6 +41,11 @@ pub(crate) fn relay_endpoint(text: &str) -> Result { } } +/// A channel ID as the relay writes it: a lowercase, hyphenated UUID. +pub(crate) fn is_channel_id(id: &str) -> bool { + uuid::Uuid::parse_str(id).is_ok_and(|uuid| uuid.hyphenated().to_string() == id) +} + fn invalid() -> Value { refusal( wire::ErrorCode::InvalidStart, @@ -54,6 +63,9 @@ pub(crate) struct Registration { pub(crate) agent: PublicKey, pub(crate) relay: String, pub(crate) payload: Value, + /// Saved as [`CHANNEL_ANNOTATION`]; `None` keeps the saved one. + #[serde(skip)] + pub(crate) channel: Option, } impl Registration { @@ -74,9 +86,20 @@ impl Registration { agent, relay, payload, + channel: None, }) } + /// Remember the deploy's channel when it is bound to a channel thread. + /// A direct-message deploy names no channel another key could post in. + pub fn in_channel(mut self, provider_config: &Value) -> Self { + self.channel = provider_config["sandbox"]["channel_id"] + .as_str() + .filter(|id| is_channel_id(id)) + .map(str::to_owned); + self + } + pub(crate) fn name(owner: PublicKey, agent: PublicKey, relay: &str) -> String { let hash = Sha256::digest(format!("{owner}\n{agent}\n{relay}").as_bytes()); let hex: String = hash[..20].iter().map(|b| format!("{b:02x}")).collect(); @@ -111,6 +134,15 @@ impl Registration { secret.metadata.name = Some(name.clone()); secret.metadata.namespace = Some(namespace.into()); secret.metadata.labels = Some(BTreeMap::from([(LABEL.into(), "v1".into())])); + let channel = self.channel.clone().or_else(|| { + prior + .as_ref() + .and_then(|s| s.metadata.annotations.as_ref()) + .and_then(|a| a.get(CHANNEL_ANNOTATION)) + .cloned() + }); + secret.metadata.annotations = + channel.map(|channel| BTreeMap::from([(CHANNEL_ANNOTATION.into(), channel)])); secret.metadata.resource_version = prior .as_ref() .and_then(|s| s.metadata.resource_version.clone()); diff --git a/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs b/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs index a44b7066416..556da54fcd0 100644 --- a/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs +++ b/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs @@ -341,3 +341,43 @@ fn invalid_desktop_registration_has_a_deploy_specific_refusal_without_url_secret assert_eq!(error["code"], "invalid_launch_registration"); assert!(!error.to_string().contains("private")); } + +/// The Paseo channel annotation follows the latest channel-thread deploy; a +/// direct-message deploy keeps the saved one, and the payload never carries +/// it, so a manager without the annotation still reads the registration. +#[tokio::test] +async fn registrations_keep_the_latest_channel_thread_for_paseo() { + let state: sandbox_fake::Shared = Arc::new(Mutex::new(Default::default())); + let name = Registration::name(owner().public_key(), agent().public_key(), RELAY); + let save = |config: Value| { + let state = state.clone(); + async move { + Registration::new(owner().public_key(), agent().public_key(), launch()) + .unwrap() + .in_channel(&config) + .save(sandbox_fake::client(&state), NAMESPACE) + .await + .unwrap(); + } + }; + let annotation = || { + state.lock().unwrap().secrets[&name]["metadata"]["annotations"][CHANNEL_ANNOTATION].clone() + }; + let other = "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f"; + save(json!({"sandbox": {"channel_id": CHANNEL, "thread_root": "b".repeat(64)}})).await; + assert_eq!(annotation(), CHANNEL); + save(json!({"sandbox": {"dm_channel_id": other}})).await; + assert_eq!(annotation(), CHANNEL); + save(json!({"sandbox": {"channel_id": other.to_uppercase(), "thread_root": "b".repeat(64)}})) + .await; + assert_eq!(annotation(), CHANNEL); + save(json!({"sandbox": {"channel_id": other, "thread_root": "b".repeat(64)}})).await; + assert_eq!(annotation(), other); + let data = state.lock().unwrap().secrets[&name]["data"][DATA_KEY].clone(); + let text = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + data.as_str().unwrap(), + ) + .unwrap(); + assert!(!String::from_utf8(text).unwrap().contains("channel")); +} diff --git a/crates/buzz-sandbox-reaper/src/paseo.rs b/crates/buzz-sandbox-reaper/src/paseo.rs new file mode 100644 index 00000000000..7be29ee13a2 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/paseo.rs @@ -0,0 +1,408 @@ +//! Per-developer Paseo daemons (`voltage/remote-agents.md` §Paseo): the +//! pairing fields kept only in the narrow Kubernetes Secret that the +//! developer's daemon reads its environment from, and +//! the pairing offer the Paseo apps open. +//! +//! The daemon has less access than the developer's Sandboxes: it reads only +//! this Secret, never the profile, and the Secret holds only the pairing, the +//! developer's registered agents, one model token and the reserved gateway +//! key. Nothing here logs a value, and no failure label quotes one. + +use std::collections::{BTreeMap, BTreeSet}; + +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use base64::Engine; +use curve25519_dalek::montgomery::MontgomeryPoint; +use k8s_openapi::api::core::v1::Secret; +use kube::{ + api::{ListParams, PostParams}, + Api, +}; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use crate::manager::Developer; +use crate::mobile_launch::{self, Registration, CHANNEL_ANNOTATION, DATA_KEY, LABEL}; + +/// The namespace every developer's daemon runs in (Terraform `buzz-paseo`). +pub const NAMESPACE: &str = "buzz-paseo"; +/// The default Paseo relay endpoint: served next to the Buzz relay, always +/// over TLS. `paseo_relay_endpoint` in the manager config overrides it. +pub const RELAY_ENDPOINT: &str = "buzz.voltage.cloud:443"; +/// The Paseo web app that opens a pairing offer. +const APP_BASE_URL: &str = "https://app.paseo.sh"; +/// Writes before a concurrent writer's conflict is reported. +const WRITE_ATTEMPTS: usize = 3; +/// Registrations one developer's daemon lists, as the mention listener caps +/// them. +const MAX_AGENTS: usize = 64; +/// The daemon's provider list, `[{"id","label","agent","channel"}]`. +pub const AGENTS_KEY: &str = "BUZZ_PASEO_AGENTS"; +/// The profile's model tokens the daemon's own agent may use, by the +/// variable it reads. No other profile credential reaches the daemon. +const MODEL_TOKENS: &[(&str, &str)] = &[ + ("claude_oauth_token", "CLAUDE_CODE_OAUTH_TOKEN"), + ("codex_access_token", "CODEX_ACCESS_TOKEN"), +]; +/// Provider IDs the daemon image defines itself; an agent never takes one. +const RESERVED_IDS: &[&str] = &[ + "acp", + "antigravity", + "claude", + "codex", + "copilot", + "cursor", + "hoplite", + "muse", + "omp", + "opencode", + "pi", +]; +/// Longest agent ID and label the manager derives. +const MAX_ID: usize = 32; +const MAX_LABEL: usize = 64; + +/// Why generating the pairing or writing the daemon Secret failed: a stable +/// label, safe to log and to show. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Failed(pub &'static str); + +/// A Paseo relay endpoint as the daemon and the apps take it: `host:port`, +/// with no scheme, path or credentials. +pub fn is_relay_endpoint(endpoint: &str) -> bool { + endpoint.rsplit_once(':').is_some_and(|(host, port)| { + !host.is_empty() + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + && port.parse::().is_ok_and(|port| port != 0) + }) +} + +/// New pairing fields: a password of 32 random bytes, a server ID as Paseo's +/// own generator makes it (`srv_` and 9 random bytes, base64url), and a NaCl +/// box keypair. +pub fn generate() -> Result { + let mut password = Zeroizing::new([0u8; 32]); + let mut server_id = [0u8; 9]; + let mut secret = Zeroizing::new([0u8; 32]); + for bytes in [&mut password[..], &mut server_id[..], &mut secret[..]] { + getrandom::fill(bytes).map_err(|_| Failed("no_randomness"))?; + } + Ok(json!({ + "password": URL_SAFE_NO_PAD.encode(*password), + "server_id": format!("srv_{}", URL_SAFE_NO_PAD.encode(server_id)), + "daemon_keypair": keypair(&secret), + })) +} + +/// tweetnacl's `box.keyPair.fromSecretKey`, in the daemon's +/// `daemon-keypair.json` shape: the public key is X25519 of the clamped +/// secret and the base point, and the secret is kept unclamped. +fn keypair(secret: &[u8; 32]) -> Value { + let public = MontgomeryPoint::mul_base_clamped(*secret); + json!({ + "v": 2, + "publicKeyB64": STANDARD.encode(public.as_bytes()), + "secretKeyB64": STANDARD.encode(secret), + }) +} + +/// The URL the daemon's `encodeOfferToFragmentUrl` builds for its +/// `ConnectionOfferV2`: the offer JSON, keys in the schema's order, as +/// unpadded base64url after `#offer=`. The relay is always TLS. +pub fn offer_url(server_id: &str, daemon_public_key: &str, relay_endpoint: &str) -> String { + let offer = format!( + r#"{{"v":2,"serverId":{},"daemonPublicKeyB64":{},"relay":{{"endpoint":{},"useTls":true}}}}"#, + Value::from(server_id), + Value::from(daemon_public_key), + Value::from(relay_endpoint), + ); + format!("{APP_BASE_URL}/#offer={}", URL_SAFE_NO_PAD.encode(offer)) +} + +#[path = "paseo_env.rs"] +mod environment; +use environment::{daemon_env, pairing, read_env}; + +/// The agent's display name from its desktop launch, the last tier winning +/// as in the launch precedence, or a name from its key. +fn label(payload: &Value, agent: &str) -> String { + let named = [ + "/env_vars/BUZZ_ACP_DISPLAY_NAME", + "/launch/policy_env/BUZZ_ACP_DISPLAY_NAME", + "/launch/env/BUZZ_ACP_DISPLAY_NAME", + ] + .iter() + .filter_map(|pointer| payload.pointer(pointer).and_then(Value::as_str)) + .map(str::trim) + .rfind(|name| !name.is_empty() && !name.chars().any(char::is_control)); + match named { + Some(name) => name.chars().take(MAX_LABEL).collect(), + None => format!("Agent {}", &agent[..8]), + } +} + +/// `label` as an ID the daemon accepts, `^[a-z][a-z0-9-]*$`. +fn slug(label: &str) -> String { + let mut slug = String::new(); + for c in label.chars() { + if c.is_ascii_alphanumeric() { + slug.push(c.to_ascii_lowercase()); + } else if !slug.is_empty() && !slug.ends_with('-') { + slug.push('-'); + } + } + slug.truncate(MAX_ID); + let slug = slug.trim_end_matches('-'); + match slug.chars().next() { + Some(c) if c.is_ascii_lowercase() => slug.to_owned(), + Some(_) => format!("agent-{slug}"), + None => "agent".to_owned(), + } +} + +/// The daemon's provider list from `(label, agent, channel)` entries, in a +/// stable order. Each ID is the label's slug; a reserved or repeated slug +/// gets the agent key's first 8 hex digits. +fn agent_list(mut entries: Vec<(String, String, String)>) -> Value { + entries.sort(); + let mut used: BTreeSet = RESERVED_IDS.iter().map(|id| (*id).to_owned()).collect(); + let list = entries + .into_iter() + .map(|(label, agent, channel)| { + let mut id = slug(&label); + if used.contains(&id) { + id = format!("{id}-{}", &agent[..8]); + } + if used.contains(&id) { + id = format!("{}-{agent}", slug(&label)); + } + used.insert(id.clone()); + json!({"id": id, "label": label, "agent": agent, "channel": channel}) + }) + .collect(); + Value::Array(list) +} + +/// A label for a Kubernetes error. The error itself is never logged. +fn error_label(error: &kube::Error) -> &'static str { + match error { + kube::Error::Api(status) if status.code == 403 => "forbidden", + kube::Error::Api(status) if status.code == 404 => "namespace_not_found", + kube::Error::Api(_) => "rejected", + _ => "unavailable", + } +} + +/// Writes each developer's daemon Secret, `paseo-daemon-`. +pub struct DaemonSecrets { + client: kube::Client, + namespace: String, + relay_endpoint: String, + /// The manager's Buzz relay, normalized: the daemon lists only agents + /// registered on it. + buzz_relay: String, + /// Serializes listing the registrations with writing the Secret, so a + /// slower writer never replaces a newer agent list. + lock: tokio::sync::Mutex<()>, +} + +impl DaemonSecrets { + /// Secrets in [`NAMESPACE`], through the manager's Kubernetes client. + /// Offers name `relay_endpoint`; the agent list names only agents + /// registered on `buzz_relay`, the manager's own relay. + pub fn new( + client: kube::Client, + relay_endpoint: &str, + buzz_relay: &str, + ) -> Result { + if !is_relay_endpoint(relay_endpoint) { + return Err(Failed("invalid_relay_endpoint")); + } + let buzz_relay = + mobile_launch::relay_endpoint(buzz_relay).map_err(|_| Failed("invalid_relay_url"))?; + Ok(Self { + client, + namespace: NAMESPACE.to_owned(), + relay_endpoint: relay_endpoint.to_owned(), + buzz_relay, + lock: tokio::sync::Mutex::new(()), + }) + } + + /// The owner's pairing details from the daemon Secret, never the profile. + pub async fn pairing(&self, developer: &Developer) -> Result, Failed> { + let api: Api = Api::namespaced(self.client.clone(), &self.namespace); + api.get_opt(&Self::name(&developer.id)) + .await + .map_err(|e| Failed(error_label(&e)))? + .map(|secret| pairing(&read_env(&secret)?, &self.relay_endpoint)) + .transpose() + } + + /// The developer's daemon Secret, as the Terraform Deployment names it. + pub fn name(developer: &str) -> String { + format!("paseo-daemon-{developer}") + } + + /// The developer's registered agents on the manager's relay that have a + /// channel: each desktop launch registration in their namespace, with + /// the channel of its latest channel-thread deploy. A registration that + /// does not read back as one the manager saved is left out, as a mobile + /// start would refuse it. + async fn agents(&self, developer: &Developer) -> Result { + let api: Api = Api::namespaced(self.client.clone(), &developer.namespace); + let list = api + .list( + &ListParams::default() + .labels(&format!("{LABEL}=v1")) + .limit((MAX_AGENTS + 1) as u32), + ) + .await + .map_err(|_| Failed("registrations_unavailable"))?; + if list.items.len() > MAX_AGENTS + || list + .metadata + .continue_ + .as_ref() + .is_some_and(|c| !c.is_empty()) + { + return Err(Failed("too_many_agents")); + } + let entries = list + .items + .iter() + .filter_map(|secret| self.entry(secret, &developer.namespace)) + .collect(); + Ok(agent_list(entries)) + } + + /// One registration as `(label, agent, channel)`. + fn entry(&self, secret: &Secret, namespace: &str) -> Option<(String, String, String)> { + let channel = secret + .metadata + .annotations + .as_ref()? + .get(CHANNEL_ANNOTATION) + .filter(|channel| mobile_launch::is_channel_id(channel))?; + let raw = secret.data.as_ref()?.get(DATA_KEY)?; + let saved: Registration = serde_json::from_slice(&raw.0).ok()?; + let relay = saved.payload["relay_url"] + .as_str() + .and_then(|url| mobile_launch::relay_endpoint(url).ok())?; + let valid = saved.version == 1 + && saved.relay == self.buzz_relay + && relay == self.buzz_relay + && secret.metadata.namespace.as_deref() == Some(namespace) + && secret.metadata.name.as_deref() + == Some(&Registration::name(saved.owner, saved.agent, &saved.relay)); + let agent = saved.agent.to_hex(); + valid.then(|| (label(&saved.payload, &agent), agent, channel.clone())) + } + + /// Save `secret`, created or replacing `prior` at its resourceVersion. + async fn save( + &self, + api: &Api, + prior: Option<&Secret>, + mut secret: Secret, + developer: &str, + ) -> Result { + let name = Self::name(developer); + secret.type_ = Some("Opaque".into()); + secret.metadata.name = Some(name.clone()); + secret.metadata.namespace = Some(self.namespace.clone()); + secret.metadata.labels = Some(BTreeMap::from([ + ( + "app.kubernetes.io/managed-by".to_owned(), + "buzz-sandbox-manager".to_owned(), + ), + ("voltage.cloud/developer".to_owned(), developer.to_owned()), + ])); + let saved = match prior.and_then(|prior| prior.metadata.resource_version.clone()) { + Some(version) => { + secret.metadata.resource_version = Some(version); + api.replace(&name, &PostParams::default(), &secret).await + } + None => api.create(&PostParams::default(), &secret).await, + }; + match saved { + Ok(_) => Ok(true), + Err(kube::Error::Api(status)) if status.code == 409 => Ok(false), + Err(error) => Err(Failed(error_label(&error))), + } + } + + /// Create the developer's Secret, or replace it at the resourceVersion + /// just read, preserving pairing and gateway identity while updating model + /// tokens and current registrations. A conflict with a concurrent writer is + /// retried a bounded number of times. + pub async fn write(&self, developer: &Developer, profile: &Value) -> Result { + let _serialized = self.lock.lock().await; + let api: Api = Api::namespaced(self.client.clone(), &self.namespace); + let name = Self::name(&developer.id); + for _ in 0..WRITE_ATTEMPTS { + let prior = api + .get_opt(&name) + .await + .map_err(|e| Failed(error_label(&e)))?; + let existing = prior + .as_ref() + .map(read_env) + .transpose()? + .unwrap_or_default(); + let env = daemon_env(profile, &self.agents(developer).await?, existing)?; + let pairing = pairing(&env, &self.relay_endpoint)?; + let secret = Secret { + string_data: Some(env), + ..Default::default() + }; + if self + .save(&api, prior.as_ref(), secret, &developer.id) + .await? + { + return Ok(pairing); + } + } + Err(Failed("conflict")) + } + + /// Replace only [`AGENTS_KEY`] in the developer's existing Secret, after + /// a registration changed. Without a Secret there is nothing to update: + /// the developer has not enrolled since Paseo daemons were enabled, and + /// that enroll writes the whole Secret. + pub async fn refresh_agents(&self, developer: &Developer) -> Result<(), Failed> { + let _serialized = self.lock.lock().await; + let api: Api = Api::namespaced(self.client.clone(), &self.namespace); + let name = Self::name(&developer.id); + for _ in 0..WRITE_ATTEMPTS { + let Some(prior) = api + .get_opt(&name) + .await + .map_err(|e| Failed(error_label(&e)))? + else { + return Ok(()); + }; + let agents = self.agents(developer).await?; + let mut data = prior.data.clone().unwrap_or_default(); + data.remove(AGENTS_KEY); + let secret = Secret { + data: Some(data), + string_data: Some(BTreeMap::from([( + AGENTS_KEY.to_owned(), + agents.to_string(), + )])), + ..Default::default() + }; + if self.save(&api, Some(&prior), secret, &developer.id).await? { + return Ok(()); + } + } + Err(Failed("conflict")) + } +} + +#[cfg(test)] +#[path = "paseo_tests.rs"] +mod tests; diff --git a/crates/buzz-sandbox-reaper/src/paseo_env.rs b/crates/buzz-sandbox-reaper/src/paseo_env.rs new file mode 100644 index 00000000000..204ee22820c --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/paseo_env.rs @@ -0,0 +1,111 @@ +//! The daemon Secret's allowlisted environment and public pairing reply. + +use super::*; + +const PAIRING_FIELDS: &[(&str, &str)] = &[ + ("password", "PASEO_PASSWORD"), + ("server_id", "PASEO_SERVER_ID"), + ("daemon_keypair", "PASEO_DAEMON_KEYPAIR"), +]; +const GATEWAY_KEY: &str = "BUZZ_PRIVATE_KEY"; + +pub(super) fn read_env(secret: &Secret) -> Result, Failed> { + secret.data.as_ref().map_or_else( + || Ok(BTreeMap::new()), + |data| { + data.iter() + .map(|(key, value)| { + String::from_utf8(value.0.clone()) + .map(|value| (key.clone(), value)) + .map_err(|_| Failed("invalid_secret")) + }) + .collect() + }, + ) +} + +/// Fill absent identity fields without replacing existing credentials. Malformed +/// existing fields fail closed; a read error never reaches this function. +pub(super) fn daemon_env( + profile: &Value, + agents: &Value, + mut env: BTreeMap, +) -> Result, Failed> { + if env.keys().any(|key| { + !PAIRING_FIELDS.iter().any(|(_, name)| name == key) + && !MODEL_TOKENS.iter().any(|(_, name)| name == key) + && key != AGENTS_KEY + && key != GATEWAY_KEY + }) { + return Err(Failed("unsupported_secret_key")); + } + if PAIRING_FIELDS + .iter() + .any(|(_, key)| !env.contains_key(*key)) + { + let generated = generate()?; + for (field, key) in PAIRING_FIELDS { + env.entry((*key).to_owned()).or_insert_with(|| { + if *field == "daemon_keypair" { + generated[field].to_string() + } else { + generated[field].as_str().unwrap_or_default().to_owned() + } + }); + } + } + if !env.contains_key(GATEWAY_KEY) { + let keys = nostr::Keys::generate(); + env.insert(GATEWAY_KEY.to_owned(), keys.secret_key().to_secret_hex()); + } + // Validate the saved values, including public/secret key consistency. + pairing(&env, RELAY_ENDPOINT)?; + env.insert(AGENTS_KEY.to_owned(), agents.to_string()); + for (field, variable) in MODEL_TOKENS { + env.remove(*variable); + if let Some(token) = profile["models"][field].as_str() { + env.insert((*variable).to_owned(), token.to_owned()); + } + } + Ok(env) +} + +pub(super) fn pairing(env: &BTreeMap, endpoint: &str) -> Result { + let text = |key: &str| { + env.get(key) + .map(String::as_str) + .ok_or(Failed("invalid_pairing")) + }; + let password = text("PASEO_PASSWORD")?; + let server_id = text("PASEO_SERVER_ID")?; + if !URL_SAFE_NO_PAD + .decode(password) + .is_ok_and(|raw| raw.len() == 32) + || server_id + .strip_prefix("srv_") + .is_none_or(|id| !URL_SAFE_NO_PAD.decode(id).is_ok_and(|raw| raw.len() == 9)) + { + return Err(Failed("invalid_pairing")); + } + let pair: Value = serde_json::from_str(text("PASEO_DAEMON_KEYPAIR")?) + .map_err(|_| Failed("invalid_pairing"))?; + let secret: [u8; 32] = pair["secretKeyB64"] + .as_str() + .and_then(|secret| STANDARD.decode(secret).ok()) + .and_then(|raw| raw.try_into().ok()) + .ok_or(Failed("invalid_pairing"))?; + if pair != keypair(&secret) { + return Err(Failed("invalid_pairing")); + } + let gateway = text(GATEWAY_KEY)?; + if gateway.len() != 64 || !gateway.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(Failed("invalid_gateway_key")); + } + let keys = nostr::Keys::parse(gateway).map_err(|_| Failed("invalid_gateway_key"))?; + Ok(json!({ + "password": password, + "server_id": server_id, + "offer_url": offer_url(server_id, pair["publicKeyB64"].as_str().unwrap_or_default(), endpoint), + "gateway_pubkey": keys.public_key().to_hex(), + })) +} diff --git a/crates/buzz-sandbox-reaper/src/paseo_tests.rs b/crates/buzz-sandbox-reaper/src/paseo_tests.rs new file mode 100644 index 00000000000..965ae9da9a4 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/paseo_tests.rs @@ -0,0 +1,201 @@ +//! Pairing generation and the offer URL, against vectors from the daemon +//! image itself (`ghcr.io/getpaseo/paseo@sha256:57048c43…`). The Secret +//! write and the enroll round trip are in `enroll_tests.rs`. + +use super::*; + +/// `nacl.box.keyPair.fromSecretKey(secret)` in the image's tweetnacl: +/// +/// ```sh +/// docker run --rm --entrypoint node ghcr.io/getpaseo/paseo@sha256:57048c43… -e ' +/// const nacl = require("/usr/local/lib/node_modules/@getpaseo/relay/node_modules/tweetnacl"); +/// const kp = nacl.box.keyPair.fromSecretKey(Buffer.from(process.argv[1], "hex")); +/// console.log(Buffer.from(kp.publicKey).toString("base64"))' +/// ``` +/// +/// The first secret is RFC 7748 §6.1's Alice key. The second needs clamping +/// at both ends, so an unclamped multiplication fails it. +const KEYPAIRS: &[(&str, &str)] = &[ + ( + "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=", + ), + ( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "hHwNLDdSNPNl5mCVUYejc1oPdhPRYJ06ak2MU66qWiI=", + ), +]; + +fn secret(hex: &str) -> [u8; 32] { + let mut secret = [0u8; 32]; + for (i, byte) in secret.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).unwrap(); + } + secret +} + +#[test] +fn the_keypair_matches_tweetnacl() { + for (hex, public) in KEYPAIRS { + let secret = secret(hex); + assert_eq!( + keypair(&secret), + json!({"v": 2, "publicKeyB64": public, "secretKeyB64": STANDARD.encode(secret)}), + "{hex}" + ); + } +} + +/// The daemon's own `createConnectionOfferV2` and `encodeOfferToFragmentUrl` +/// (`dist/server/server/connection-offer.js`), run in the image with +/// `serverId: "srv_AAECAwQFBgcI"`, the first vector's public key, each +/// endpoint, `useTls: true` and `appBaseUrl: "https://app.paseo.sh"`: +/// +/// ```sh +/// docker run --rm --entrypoint node ghcr.io/getpaseo/paseo@sha256:57048c43… --input-type=module -e ' +/// import { createConnectionOfferV2, encodeOfferToFragmentUrl } from +/// "/usr/local/lib/node_modules/@getpaseo/server/dist/server/server/connection-offer.js"; +/// const offer = await createConnectionOfferV2({ serverId: "srv_AAECAwQFBgcI", +/// daemonPublicKeyB64: "hSDw…", relay: { endpoint: "", useTls: true } }); +/// console.log(encodeOfferToFragmentUrl({ offer, appBaseUrl: "https://app.paseo.sh" }))' +/// ``` +#[test] +fn the_offer_url_matches_the_daemons() { + for (endpoint, url) in [ + ( + RELAY_ENDPOINT, + "https://app.paseo.sh/#offer=eyJ2IjoyLCJzZXJ2ZXJJZCI6InNydl9BQUVDQXdRRkJnY0kiLCJkYWVtb25QdWJsaWNLZXlCNjQiOiJoU0R3Q1lrd3AxUjBpMzNjdEQ3M1dnMi9PZzBtT0JyMDY2U3BqcXFiVG1vPSIsInJlbGF5Ijp7ImVuZHBvaW50IjoiYnV6ei52b2x0YWdlLmNsb3VkOjQ0MyIsInVzZVRscyI6dHJ1ZX19", + ), + ( + "paseo.example.test:8443", + "https://app.paseo.sh/#offer=eyJ2IjoyLCJzZXJ2ZXJJZCI6InNydl9BQUVDQXdRRkJnY0kiLCJkYWVtb25QdWJsaWNLZXlCNjQiOiJoU0R3Q1lrd3AxUjBpMzNjdEQ3M1dnMi9PZzBtT0JyMDY2U3BqcXFiVG1vPSIsInJlbGF5Ijp7ImVuZHBvaW50IjoicGFzZW8uZXhhbXBsZS50ZXN0Ojg0NDMiLCJ1c2VUbHMiOnRydWV9fQ", + ), + ] { + assert_eq!(offer_url("srv_AAECAwQFBgcI", KEYPAIRS[0].1, endpoint), url); + } +} + +#[test] +fn relay_endpoints_are_host_and_port_only() { + for good in [RELAY_ENDPOINT, "paseo.example.test:8443", "10.0.0.1:443"] { + assert!(is_relay_endpoint(good), "{good}"); + } + for bad in [ + "buzz.voltage.cloud", + "wss://buzz.voltage.cloud:443", + "buzz.voltage.cloud:443/ws", + "user@buzz.voltage.cloud:443", + ":443", + "buzz.voltage.cloud:0", + "buzz.voltage.cloud:65536", + "", + ] { + assert!(!is_relay_endpoint(bad), "{bad}"); + } +} + +/// The daemon gets only its identity, agents, and selected model tokens. +#[test] +fn daemon_environment_preserves_identity_and_refuses_malformed_saved_fields() { + let mut profile = crate::profile::tests::valid("dev", &"a".repeat(64)); + let agents = json!([]); + let first = daemon_env(&profile, &agents, BTreeMap::new()).unwrap(); + assert_eq!(first.len(), 5); + let keys = nostr::Keys::parse(&first["BUZZ_PRIVATE_KEY"]).unwrap(); + assert_eq!( + pairing(&first, RELAY_ENDPOINT).unwrap()["gateway_pubkey"], + keys.public_key().to_hex() + ); + profile["models"] = json!({"claude_oauth_token": "sk-ant-oat01-fixture", "codex_access_token": "at-fixture", + "openai_api_key": "sk-fixture", "openrouter_api_key": "sk-or-fixture"}); + let next = daemon_env(&profile, &agents, first.clone()).unwrap(); + assert_eq!(next.len(), 7); + for key in [ + "PASEO_PASSWORD", + "PASEO_SERVER_ID", + "PASEO_DAEMON_KEYPAIR", + "BUZZ_PRIVATE_KEY", + ] { + assert_eq!(next[key], first[key]); + let mut bad = first.clone(); + bad.insert(key.into(), "malformed".into()); + assert!(daemon_env(&profile, &agents, bad).is_err(), "{key}"); + let mut missing = first.clone(); + missing.remove(key); + let repaired = daemon_env(&profile, &agents, missing).unwrap(); + for kept in first + .keys() + .filter(|kept| *kept != key && kept.as_str() != AGENTS_KEY) + { + assert_eq!(repaired[kept], first[kept], "{kept}"); + } + } + let mut unexpected = first; + unexpected.insert("GITHUB_TOKEN".into(), "sensitive".into()); + assert_eq!( + daemon_env(&profile, &agents, unexpected), + Err(Failed("unsupported_secret_key")) + ); +} + +/// IDs the daemon accepts, unique and never a provider the image defines; +/// labels from the launch's display name, the last precedence tier winning. +#[test] +fn agent_ids_are_valid_unique_and_never_reserved() { + let key = |c: char| c.to_string().repeat(64); + let channel = "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10".to_owned(); + let entries = vec![ + ("Brokkr".to_owned(), key('2'), channel.clone()), + ("Brokkr".to_owned(), key('1'), channel.clone()), + ("Claude".to_owned(), key('3'), channel.clone()), + ("Cursor".to_owned(), key('7'), channel.clone()), + ("9 Lives!".to_owned(), key('4'), channel.clone()), + ("Ünïcode only".to_owned(), key('5'), channel.clone()), + ("日本".to_owned(), key('6'), channel.clone()), + ]; + let ids: Vec<_> = agent_list(entries) + .as_array() + .unwrap() + .iter() + .map(|agent| agent["id"].as_str().unwrap().to_owned()) + .collect(); + assert_eq!( + ids, + [ + "agent-9-lives", + "brokkr", + "brokkr-22222222", + "claude-33333333", + "cursor-77777777", + "n-code-only", + "agent" + ] + ); + let payload = json!({"env_vars": {"BUZZ_ACP_DISPLAY_NAME": "Old"}, + "launch": {"policy_env": {"BUZZ_ACP_DISPLAY_NAME": " Policy "}, + "env": {"BUZZ_ACP_DISPLAY_NAME": " "}}}); + assert_eq!(label(&payload, &key('a')), "Policy"); + assert_eq!(label(&json!({}), &key('a')), "Agent aaaaaaaa"); +} + +/// Generated pairings pass the profile validation, carry a public key that +/// belongs to their secret, and never repeat. +#[test] +fn generated_pairings_are_valid_consistent_and_fresh() { + let first = generate().unwrap(); + let profile = crate::profile::tests::valid("dev", &"a".repeat(64)); + crate::profile::validate(&profile).unwrap(); + let decoded = STANDARD + .decode(first["daemon_keypair"]["secretKeyB64"].as_str().unwrap()) + .unwrap(); + let secret: [u8; 32] = decoded.try_into().unwrap(); + assert_eq!(first["daemon_keypair"], keypair(&secret)); + let password = URL_SAFE_NO_PAD + .decode(first["password"].as_str().unwrap()) + .unwrap(); + assert_eq!(password.len(), 32); + let second = generate().unwrap(); + for field in ["password", "server_id", "daemon_keypair"] { + assert_ne!(first[field], second[field], "{field}"); + } +} diff --git a/crates/buzz-sandbox-reaper/src/serve.rs b/crates/buzz-sandbox-reaper/src/serve.rs index 6736c928103..406e015f489 100644 --- a/crates/buzz-sandbox-reaper/src/serve.rs +++ b/crates/buzz-sandbox-reaper/src/serve.rs @@ -28,6 +28,7 @@ use tokio_util::sync::CancellationToken; use crate::enroll::{self, Enrollment, SecretsManagerStore}; use crate::manager::{response_value, Admission, Job, Manager, ManagerConfig, Waiter}; +use crate::paseo::DaemonSecrets; use crate::shutdown; /// Bound on connecting, authenticating and subscribing. @@ -108,13 +109,22 @@ pub async fn serve(args: ServeArgs) -> Result<()> { .map_err(|e| anyhow::anyhow!("BUZZ_GITHUB_API_URL: {e}"))?; let store = Arc::new(SecretsManagerStore::new().await); let broker = CodexLoginServer::prepare(args.codex, store.clone(), config.developers()).await?; + let client = kube::Client::try_default() + .await + .context("build in-cluster Kubernetes client")?; let mut enrollment = Enrollment::new(github, store); if let Some(server) = &broker { enrollment = enrollment.with_broker(Arc::clone(&server.broker)); } - let client = kube::Client::try_default() - .await - .context("build in-cluster Kubernetes client")?; + if config.paseo_daemons() { + let secrets = DaemonSecrets::new( + client.clone(), + config.paseo_relay_endpoint(), + &args.relay_url, + ) + .map_err(|crate::paseo::Failed(label)| anyhow::anyhow!("Paseo daemons: {label}"))?; + enrollment = enrollment.with_paseo(secrets); + } let developers = config.len(); let manager = Arc::new(Manager::new(keys, config, client, enrollment).with_sandbox_defaults(defaults)); diff --git a/crates/buzz-session-control/src/tests.rs b/crates/buzz-session-control/src/tests.rs index 592d87046e1..000445179a8 100644 --- a/crates/buzz-session-control/src/tests.rs +++ b/crates/buzz-session-control/src/tests.rs @@ -228,6 +228,28 @@ fn serialized_events_reveal_no_secret_and_no_payload_field() { assert_eq!(tag_names(&reply), ["p", "e", "expiration"]); } +/// An `enroll` or `profile` reply may carry the owner's Paseo pairing: the +/// password and offer URL stay inside the encrypted envelope, and the +/// optional field opens unchanged next to the fields clients already read. +#[test] +fn paseo_pairing_replies_are_opaque_and_open_intact() { + const PASSWORD: &str = "paseo-fixture-password-do-not-leak-0003"; + const OFFER: &str = "https://app.paseo.sh/#offer=eyJ2IjoyfQ"; + let request = request_event(&secret_request()); + let done = Reply::done( + "enroll-1", + json!({"ok": true, "version_id": "v-1", "profile": {"developer": "dev"}, + "paseo": {"password": PASSWORD, "server_id": "srv_AAECAwQFBgcI", "offer_url": OFFER}}), + ); + let event = reply_event(&request.id, &done); + assert_opaque(&event, &[PASSWORD, OFFER, "srv_AAECAwQFBgcI", "paseo"]); + let opened = open_reply(&event, &agent(), &manager().public_key(), &request.id).unwrap(); + assert_eq!(opened, done); + let response = opened.response.unwrap(); + assert_eq!(response["version_id"], "v-1"); + assert_eq!(response["paseo"]["password"], PASSWORD); +} + /// Confidentiality rule 4c: a refusal is as opaque as any other reply. #[test] fn refusal_replies_are_encrypted() { diff --git a/docs/remote-agents.md b/docs/remote-agents.md index bdea3330758..00b08368d9a 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -1968,6 +1968,7 @@ Refusals are `done` replies with one of these codes | `invalid_profile` | `enroll`: the payload is not `{"profile": }`, or the profile fails the image's validation; the message is the image's. | fix the profile | | `github_login_mismatch` | `enroll`: GitHub reports another login for the profile's token, refuses the token, or cannot be reached. | use a token for `github.login`, or retry | | `profile_store_failed` | `enroll`: Secrets Manager refused or failed the write; the message carries a label such as `access_denied`. | retry, or ask an admin | +| `paseo_store_failed` | `enroll`: the developer's Paseo daemon Secret was not written (or the pairing could not be generated), so the profile was not stored either. `deploy`: the Sandbox started and its registration was saved, but the agent list in that Secret was not updated. The message carries a label such as `forbidden`. | repeat the enroll or deploy, or ask an admin | | `manager_unavailable` | Client-side: the manager did not accept the request within 30 s. Ephemeral events reach only connected subscribers, so a manager that is down never sees it. | retry | | `manager_timeout` | Client-side: the manager accepted the request but did not finish it within the provider's `timeout_seconds`. | retry; the retry joins or replays the same run | | `relay_unavailable` | Client-side: the provider could not connect, authenticate or subscribe to the relay, the relay refused the request, or the connection dropped before `done`. The relay's own message is not repeated. | check the relay URL and the agent's owner attestation, then retry | @@ -1991,7 +1992,11 @@ namespace. `profile_secret_arn` is where `enroll` writes the profile. It must be that developer's own secret, `arn:aws:secretsmanager:::secret:voltage/staging/agents//profile-<6 characters>`, so one owner can never reach another developer's secret, and `enroll` -writes to `` (`AWS_REGION` is not used for it). +writes to `` (`AWS_REGION` is not used for it). The optional +`"paseo_daemons": true` turns on Paseo daemon provisioning at enrollment +(see **Paseo daemons** below), and the optional `"paseo_relay_endpoint"` +(`host:port`) changes the Paseo relay that pairing offers name. Terraform +omits both when disabled, because older managers refuse unknown fields. | Flag | Environment | Default | | --- | --- | --- | @@ -2073,14 +2078,91 @@ stored. The summary is: It names the GitHub login and the Git identity, and only whether each credential is present. Replies are encrypted to the owner. Replies, logs and -refusal messages never contain a credential; the serialized profile is -zeroized after the write. Neither the merge nor the write uses the -filesystem. +refusal messages never contain a credential, except the owner's own Paseo +pairing password below; the serialized profile is zeroized after the write. +Neither the merge nor the write uses the filesystem. The manager does not check the 1Password token (`op whoami`); the image still refuses a profile it cannot load. A profile with an empty `developer` gets the signing owner's developer ID before validation, so the client need not know it. +**Paseo daemons.** With `paseo_daemons` in the config, each developer has one +always-on Paseo daemon (Deployment `paseo-daemon-` in namespace +`buzz-paseo`): a low-privilege personal assistant that delegates work to the +developer's remote agents through the Buzz relay. Paseo apps pair with it +through the Paseo relay at `paseo_relay_endpoint` (`host:port`, default +`buzz.voltage.cloud:443`), always over TLS. `enroll` then also: + +1. After validation and the GitHub check, reads the existing Secret + `paseo-daemon-` in `buzz-paseo`. A failed read refuses enrollment; + it never replaces an identity it could not read. +2. Generates absent identity fields and preserves existing values. The + pairing and gateway key exist only in this Secret, never the developer + profile. The Secret holds exactly these keys: + + | Key | Value | + | --- | --- | + | `PASEO_PASSWORD` | 32 random bytes, unpadded base64url | + | `PASEO_SERVER_ID` | `srv_` plus 9 random bytes, unpadded base64url | + | `PASEO_DAEMON_KEYPAIR` | JSON `{"v":2,"publicKeyB64","secretKeyB64"}`, a NaCl box keypair | + | `BUZZ_PRIVATE_KEY` | A generated secp256k1 gateway secret key, 64 hex characters | + | `BUZZ_PASEO_AGENTS` | The developer's registered agents, below | + | `CLAUDE_CODE_OAUTH_TOKEN` | `models.claude_oauth_token`, when set | + | `CODEX_ACCESS_TOKEN` | `models.codex_access_token`, when set | + +3. Writes the Secret before the profile. A failure returns + `paseo_store_failed` and leaves the profile unchanged. If the profile + write fails, retrying enrollment preserves the same pairing and gateway + identity while rewriting the profile-derived fields. + +No GitHub, 1Password, API key, or ChatGPT broker login reaches the daemon. +The gateway needs an owner-signed grant before it can drive remote agents. +To rotate both identities, delete the daemon Secret and enroll again. +Revoke the old gateway grant and pair the Paseo apps again after rotation. + +`BUZZ_PASEO_AGENTS` is a JSON array `[{"id","label","agent","channel"}]`, +derived from the developer's `buzz-mobile-launch-*` registrations on the +manager's own relay (`BUZZ_RELAY_URL`): + +- `agent` is the registered agent's hex public key. +- `channel` is the channel of the agent's latest desktop deploy bound to a + channel thread (`provider_config.sandbox.channel_id`). The manager keeps it + in the registration's `buzz.voltage.cloud/paseo-channel` annotation, not in + its payload, so older managers still read the registration. A + direct-message deploy keeps the saved channel. An agent never deployed into + a channel thread is not listed. +- `label` is the launch's `BUZZ_ACP_DISPLAY_NAME` (`launch.env`, then + `launch.policy_env`, then `env_vars`), or `Agent `. +- `id` is the label's slug, `^[a-z][a-z0-9-]*$`. A slug that repeats one + before it (sorted by label, then key) or names a provider the daemon image + defines (`claude`, `codex`, …) gets `-`. + +Each successful registering deploy replaces `BUZZ_PASEO_AGENTS` in an +existing Secret and keeps its other keys; a failure is `paseo_store_failed` +after the Sandbox started, and the same deploy retried updates it. Without a +Secret (no enroll since Paseo was enabled), the deploy writes none. The +daemon reads its environment when it starts, so a changed Secret applies at +its next restart. + +The `enroll` and `profile` replies then add the pairing the owner opens in a +Paseo app. Neither the daemon secret key nor the gateway secret key is returned: + +```json +"paseo": {"password": "", "server_id": "srv_", + "offer_url": "https://app.paseo.sh/#offer=", + "gateway_pubkey": "<64 hex characters>"} +``` + +The `profile` reply also includes `paseo_enabled`, including when no profile or daemon Secret exists. +Clients can distinguish disabled provisioning from a missing Secret and idempotently re-enroll without replacing stored credentials. +Older clients ignore this additional field. + +The offer is the daemon's own encoding: `{"v":2,"serverId","daemonPublicKeyB64", +"relay":{"endpoint":"","useTls":true}}`, unpadded +base64url. The daemon gets no profile, IAM role, Kubernetes token, or GitHub +credential. The manager's ServiceAccount needs `secrets` get, create, and +update in `buzz-paseo`, and nothing else there. + **The relay provider.** `buzz-backend-relay` (`crates/buzz-backend-relay`) speaks the provider protocol on stdin and stdout like `buzz-backend-kubernetes`, and advertises `info`, `deploy`, `stop` and diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index e6ae73c858c..9176a5e5c9b 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -190,6 +190,10 @@ The bootstrap refuses an inherited `OPENAI_API_KEY`, `CODEX_API_KEY`, `CODEX_ACC Model credentials stay in process environments and are never written to workspace files. Tools that the agent runs inherit them: the agent's tool shell starts from a cleared environment, so the bootstrap lists them in `BUZZ_ACP_MCP_FORWARD_ENV`, which `buzz-acp` forwards into that shell. `buzz-agent-review` uses them to run its reviewer. The checkpoint secret scan also refuses Claude subscription tokens. +When the manager config sets `paseo_daemons`, it generates pairing and gateway credentials only in the narrow Secret `paseo-daemon-`. +The profile carries no Paseo or gateway fields. Sandbox images need no profile-schema change for daemon provisioning. +The manager preserves existing credentials on enrollment and returns only the password, server ID, offer URL, and gateway public key. + ### Commit signing Every commit and tag is signed with an agent-only SSH key, `git.ssh_signing_key` in the profile: one OpenSSH Ed25519 private key. diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 7c4184de05c..1917d259d1a 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -117,6 +117,8 @@ sequenceDiagram | Claude, OpenAI, OpenRouter credentials and Codex access tokens | Enroll form | Profile | The harness in the Sandbox | | ChatGPT refresh token | Dedicated sign-in button | Manager-only Secrets Manager secret | Manager refresh broker | | Depot, Grafana, Linear, Argo CD tokens | Nobody: admins keep them in the vault | 1Password Buzz Agents Dev | The Sandbox, through `via` | +| Paseo pairing: password, server ID, daemon keypair | Nobody: the manager generates it at enroll | Only the Secret `paseo-daemon-` in `buzz-paseo` | Your Paseo daemon | +| Claude subscription token or Codex access token, for the Paseo daemon | Enroll form (the same token as above) | Profile; copied to the Secret `paseo-daemon-` | Your Paseo daemon's own agent | The app removes the dedicated ChatGPT login cache after enrollment or cancellation. Other enrollment credentials are held in memory only. The profile is encrypted in transit and at rest. Checkpoints never include credentials. @@ -136,6 +138,7 @@ Do these steps once for each new developer. You need the staging VPN for step 2. 2. **Apply it locally.** Merge the PR, then run `make plan` and `make apply` in `v2/environments/staging-agent`. CI cannot apply this module, because its Kubernetes identity is read-only. The apply creates the namespace `buzz-agent-staging-`, the profile secret, and the developer's permissions. + With `paseo_daemon_image` set, it also turns on `paseo_daemons` in the manager config and creates the developer's Paseo daemon Deployment in `buzz-paseo`. The daemon waits for its Secret, which the manager writes at the developer's next enroll. 3. **Restart the manager and the reaper.** Both read the developer list only at startup: ```sh @@ -358,6 +361,20 @@ You need a GitHub token only to change it. The signing key stays the same, unles Entering a Codex access token replaces a stored OpenAI API key, and entering a Claude subscription token replaces a stored Anthropic API key. The same applies the other way round, because an agent can use only one of each pair. +#### Pair a Paseo app + +When an admin enables Paseo daemons, each developer has one always-on [Paseo](https://paseo.sh) daemon in staging (Deployment `paseo-daemon-` in namespace `buzz-paseo`). The daemon is a personal assistant with less access than your Sandboxes: no AWS role, no Kubernetes token, and no GitHub or other credentials. It gets only your Claude subscription token or Codex access token from your profile, for its own model, and hands work to your remote agents through the Buzz relay. + +1. Enroll your profile, or enroll again if you enrolled before Paseo daemons were enabled. The first enroll generates the daemon's password, server ID, keypair, and gateway key in its narrow Secret. None enters your profile. +2. Read `paseo.offer_url` and `paseo.password` from the enroll reply, or from the `profile` reply at any later time. You do not need `kubectl`. +3. Open the offer URL (`https://app.paseo.sh/#offer=…`) on the device that runs the Paseo app. The app pairs with your daemon through the Paseo relay, which runs next to the Buzz relay at `buzz.voltage.cloud:443`, always over TLS. + +The daemon lists each remote agent you have started from the desktop in a channel thread, with the channel of its latest such start. Agents started only in direct messages are not listed. The daemon reads the list and your model token when it starts, so changes apply at its next restart. + +The profile reply advertises whether Paseo provisioning is enabled, even before its Secret exists. CLI onboarding can therefore provision a missing or rotated Secret without asking for credentials again. + +Enrolling again keeps the pairing and gateway identity, so paired apps keep working. The reply includes `paseo.gateway_pubkey`; grant this key before using remote agents. To rotate, delete the daemon Secret, enroll again, revoke the old gateway grant, and pair your apps again. If the reply is **Refused (paseo_store_failed)**, the daemon's Secret was not written and your profile was not changed: enroll again. + ### 5. Start a session 1. Mention the agent in a new thread, for example `@my-agent summarize the open PRs in voltagecloud/cd`.