From c064ffcdd11cca050afb91a6f9991165b69c8074 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 08:02:51 -0500 Subject: [PATCH 1/5] feat(sandbox-manager): provision per-developer Paseo daemons With `paseo_daemons` in the manager config, enrollment generates a Paseo pairing (password, server ID, tweetnacl box keypair) into the profile's `paseo` section when absent and keeps it on re-enrollment. The manager creates or replaces the narrow Secret `paseo-daemon-` in `buzz-paseo` with the pairing, the developer's registered agents (`BUZZ_PASEO_AGENTS`), only the Claude subscription or Codex access token, and the reserved gateway key. The `enroll` and `profile` replies return the password and the `https://app.paseo.sh/#offer=` pairing URL for the configured relay endpoint (default `buzz.voltage.cloud:443`, always TLS). A registering desktop deploy records its channel-thread channel as a registration annotation and refreshes the agent list in an existing daemon Secret. Write failures are refused as `paseo_store_failed`. Signed-off-by: Tee8z --- Cargo.lock | 2 + crates/buzz-backend-kubernetes/src/wire.rs | 4 + crates/buzz-sandbox-reaper/Cargo.toml | 4 + crates/buzz-sandbox-reaper/src/enroll.rs | 83 +++- .../buzz-sandbox-reaper/src/enroll_tests.rs | 127 +++++ crates/buzz-sandbox-reaper/src/main.rs | 1 + crates/buzz-sandbox-reaper/src/manager.rs | 42 +- .../buzz-sandbox-reaper/src/manager_tests.rs | 107 +++++ .../buzz-sandbox-reaper/src/mobile_launch.rs | 32 ++ .../src/mobile_launch/tests.rs | 40 ++ crates/buzz-sandbox-reaper/src/paseo.rs | 438 ++++++++++++++++++ crates/buzz-sandbox-reaper/src/paseo_tests.rs | 200 ++++++++ crates/buzz-sandbox-reaper/src/profile.rs | 81 +++- .../buzz-sandbox-reaper/src/profile_tests.rs | 69 +++ crates/buzz-sandbox-reaper/src/serve.rs | 16 +- crates/buzz-session-control/src/tests.rs | 22 + docs/remote-agents.md | 83 +++- voltage/images/buzz-agent/README.md | 5 + .../images/buzz-agent/profile.example.json | 11 +- voltage/images/buzz-agent/profile.py | 34 +- voltage/images/buzz-agent/test-profile.py | 4 + voltage/remote-agents.md | 15 + 22 files changed, 1397 insertions(+), 23 deletions(-) create mode 100644 crates/buzz-sandbox-reaper/src/paseo.rs create mode 100644 crates/buzz-sandbox-reaper/src/paseo_tests.rs 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/wire.rs b/crates/buzz-backend-kubernetes/src/wire.rs index 3a9a084d9de..6bd2379cb33 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 profile was stored, or the Sandbox started, but the developer's + /// Paseo daemon Secret could not be written; 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..1ab81b2b845 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,7 @@ pub struct Enrollment { github: GitHub, store: Arc, broker: Option>, + paseo: Option, } impl Enrollment { @@ -322,6 +324,7 @@ impl Enrollment { github, store, broker: None, + paseo: None, } } @@ -330,6 +333,40 @@ impl Enrollment { self } + /// Provision each developer's Paseo daemon: keep a pairing in the + /// profile, mirror it to the daemon's Secret, and return it to the owner. + pub fn with_paseo(mut self, secrets: DaemonSecrets) -> Self { + self.paseo = Some(secrets); + self + } + + /// The owner's pairing details, when Paseo daemons are provisioned. + fn pairing(&self, profile: &Value) -> Option { + self.paseo + .as_ref() + .and_then(|secrets| secrets.pairing(profile)) + } + + /// 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> { @@ -424,6 +461,18 @@ impl Enrollment { models.remove("codex_access_token"); } } + // A stored pairing is kept, so the developer's paired apps survive. + if self.paseo.is_some() && profile.get("paseo").is_none() { + match paseo::generate() { + Ok(pairing) => profile["paseo"] = pairing, + Err(paseo::Failed(label)) => { + return refusal( + ErrorCode::PaseoStoreFailed, + &format!("could not generate the Paseo pairing ({label}); retry"), + ) + } + } + } if let Err(profile::Invalid(message)) = profile::validate(&profile) { return refusal(ErrorCode::InvalidProfile, message); } @@ -484,9 +533,25 @@ impl Enrollment { } } } + // The profile holds the pairing, so the next enroll retries this write. + if let Some(secrets) = &self.paseo { + if let Err(paseo::Failed(label)) = secrets.write(developer, &profile).await { + return refusal( + ErrorCode::PaseoStoreFailed, + &format!( + "the profile was stored, but not the Paseo daemon secret ({label}); enroll again" + ), + ); + } + } 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) = self.pairing(&profile) { + reply["paseo"] = pairing; + } + reply } Err(StoreFailed(label)) => refusal( ErrorCode::ProfileStoreFailed, @@ -496,13 +561,19 @@ 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(Some((profile, stored))) => { + let mut reply = serde_json::json!({ + "ok": true, + "profile": profile::summary(&profile, &stored.version_id, stored.created), + }); + if let Some(pairing) = self.pairing(&profile) { + reply["paseo"] = pairing; + } + reply + } Ok(None) => serde_json::json!({"ok": true, "profile": null}), 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..e20846bb059 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,6 +311,130 @@ 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 +/// it in the profile. A failed daemon Secret write is reported, never a +/// silent success. The next enroll keeps 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_mirrored_to_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)); + + let response = enrollment.run(&developer(), profile()).await; + assert_eq!(response["code"], "paseo_store_failed", "{response}"); + assert!(response["error"].as_str().unwrap().contains("enroll again")); + let pairing = stored(&written)["paseo"].clone(); + assert!(pairing.is_object()); + assert!(daemon_secret(&cluster).is_none()); + + 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_eq!(stored(&written)["paseo"], pairing); + 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()), + ( + "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}); + assert_eq!(response["paseo"], expected); + assert_eq!(enrollment.describe(&developer()).await["paseo"], expected); + // 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)); + } +} + /// `profile` describes the stored profile, naming no credential value. #[tokio::test] async fn describe_names_what_is_stored_and_no_credential() { 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..f27631e73b2 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -50,6 +50,12 @@ 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 only after + /// deploying an agent image whose `profile.py` accepts the `paseo` + /// section, and the `buzz-paseo` namespace and 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 +82,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 +117,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 +209,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 +306,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 +341,7 @@ impl Work { request, target, registration, + developer, } => { let response = response_value(&provider::run(request, &target).await); if response["ok"] == true { @@ -321,6 +355,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 +678,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 +703,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..b7a504e7740 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,94 @@ 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 mut profile = crate::profile::tests::valid("dev", &"a".repeat(64)); + profile["paseo"] = crate::profile::tests::paseo(); + 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..c5a55cab8d4 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/paseo.rs @@ -0,0 +1,438 @@ +//! Per-developer Paseo daemons (`voltage/remote-agents.md` §Paseo): the +//! pairing fields `enroll` keeps in the profile's `paseo` section, the narrow +//! Kubernetes Secret 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", + "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)) +} + +/// What the owner needs to pair a Paseo app with their daemon: the password +/// and the offer URL. `None` when the profile holds no pairing. +pub fn pairing(profile: &Value, relay_endpoint: &str) -> Option { + let text = |pointer: &str| profile.pointer(pointer).and_then(Value::as_str); + let server_id = text("/paseo/server_id")?; + let public_key = text("/paseo/daemon_keypair/publicKeyB64")?; + Some(json!({ + "password": text("/paseo/password")?, + "server_id": server_id, + "offer_url": offer_url(server_id, public_key, relay_endpoint), + })) +} + +/// The daemon's environment from the profile and its agent list: the +/// pairing, [`AGENTS_KEY`], the profile's [`MODEL_TOKENS`] that are set, and +/// `BUZZ_PRIVATE_KEY` once the profile reserves a gateway key. +fn daemon_env(profile: &Value, agents: &Value) -> Result, Failed> { + let paseo = &profile["paseo"]; + let text = |key: &str| { + paseo + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or(Failed("invalid_pairing")) + }; + let keypair = paseo + .get("daemon_keypair") + .filter(|keypair| keypair.is_object()) + .ok_or(Failed("invalid_pairing"))?; + let mut env = BTreeMap::from([ + ("PASEO_PASSWORD".to_owned(), text("password")?), + ("PASEO_SERVER_ID".to_owned(), text("server_id")?), + ("PASEO_DAEMON_KEYPAIR".to_owned(), keypair.to_string()), + (AGENTS_KEY.to_owned(), agents.to_string()), + ]); + for (field, variable) in MODEL_TOKENS { + if let Some(token) = profile["models"][field].as_str() { + env.insert((*variable).to_owned(), token.to_owned()); + } + } + if let Some(key) = paseo.get("gateway_key").and_then(Value::as_str) { + env.insert("BUZZ_PRIVATE_KEY".to_owned(), key.to_owned()); + } + Ok(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 their profile. + pub fn pairing(&self, profile: &Value) -> Option { + pairing(profile, &self.relay_endpoint) + } + + /// 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, with exactly the daemon environment derived from `profile` + /// and the 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<(), 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 env = daemon_env(profile, &self.agents(developer).await?)?; + let prior = api + .get_opt(&name) + .await + .map_err(|e| Failed(error_label(&e)))?; + let secret = Secret { + string_data: Some(env), + ..Default::default() + }; + if self + .save(&api, prior.as_ref(), secret, &developer.id) + .await? + { + return Ok(()); + } + } + 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 agents = self.agents(developer).await?; + let Some(prior) = api + .get_opt(&name) + .await + .map_err(|e| Failed(error_label(&e)))? + else { + return Ok(()); + }; + 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_tests.rs b/crates/buzz-sandbox-reaper/src/paseo_tests.rs new file mode 100644 index 00000000000..50de1598d75 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/paseo_tests.rs @@ -0,0 +1,200 @@ +//! 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's environment: the pairing, the agent list, only the Claude +/// subscription and Codex access tokens among the model credentials, the +/// reserved gateway key, and never a GitHub, 1Password or API key. +#[test] +fn the_daemon_env_holds_only_the_pairing_agents_and_its_model_token() { + let mut profile = crate::profile::tests::valid("dev", &"a".repeat(64)); + profile["paseo"] = crate::profile::tests::paseo(); + let agents = json!([{"id": "brokkr", "label": "Brokkr", "agent": "b".repeat(64), + "channel": "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10"}]); + let base = BTreeMap::from([ + ("PASEO_PASSWORD".to_owned(), "A".repeat(43)), + ("PASEO_SERVER_ID".to_owned(), "srv_AAECAwQFBgcI".to_owned()), + ( + "PASEO_DAEMON_KEYPAIR".to_owned(), + profile["paseo"]["daemon_keypair"].to_string(), + ), + (AGENTS_KEY.to_owned(), agents.to_string()), + ]); + // The fixture holds an Anthropic API key only: it stays out. + assert_eq!(daemon_env(&profile, &agents).unwrap(), base); + 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"}); + profile["paseo"]["gateway_key"] = json!("nsec1fixture"); + let mut expected = base.clone(); + expected.extend([ + ( + "CLAUDE_CODE_OAUTH_TOKEN".to_owned(), + "sk-ant-oat01-fixture".to_owned(), + ), + ("CODEX_ACCESS_TOKEN".to_owned(), "at-fixture".to_owned()), + ("BUZZ_PRIVATE_KEY".to_owned(), "nsec1fixture".to_owned()), + ]); + assert_eq!(daemon_env(&profile, &agents).unwrap(), expected); + profile["paseo"] + .as_object_mut() + .unwrap() + .remove("server_id"); + assert_eq!( + daemon_env(&profile, &agents), + Err(Failed("invalid_pairing")) + ); +} + +/// 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()), + ("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", + "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 mut profile = crate::profile::tests::valid("dev", &"a".repeat(64)); + profile["paseo"] = first.clone(); + 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/profile.rs b/crates/buzz-sandbox-reaper/src/profile.rs index d23490cdfc8..7470f5e3d84 100644 --- a/crates/buzz-sandbox-reaper/src/profile.rs +++ b/crates/buzz-sandbox-reaper/src/profile.rs @@ -4,6 +4,7 @@ //! refuses would break every later session; the two must agree rule for //! rule. Refusals are the image's messages, which never include a value. +use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::{Map, Value}; use url::Url; @@ -26,6 +27,14 @@ const MODEL_FIELDS: &[&str] = &[ "claude_oauth_token", "openrouter_api_key", ]; +/// Top-level sections only the manager writes: [`validate`] accepts them, and +/// [`check_partial`] refuses them from a client (except `codex_login: null`). +const MANAGED: &[&str] = &["codex_login", "paseo"]; +const PASEO_FIELDS: &[&str] = &["password", "server_id", "daemon_keypair"]; +/// Reserved for the daemon's own Buzz key, so a later manager can add it +/// without older images refusing the profile. +const PASEO_GATEWAY_KEY: &str = "gateway_key"; +const INCOMPLETE: &str = "Profile fields are incomplete or unsupported"; /// Why a profile was refused: one of `profile.py`'s messages. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,7 +53,7 @@ fn fields<'a>(value: &'a Value, expected: &[&str]) -> Result<&'a Map Result<(), Invalid> { .as_object() .filter(|map| { FIELDS.iter().all(|field| map.contains_key(*field)) - && map - .keys() - .all(|field| FIELDS.contains(&field.as_str()) || field == "codex_login") + && map.keys().all(|field| { + FIELDS.contains(&field.as_str()) || MANAGED.contains(&field.as_str()) + }) }) - .ok_or(Invalid("Profile fields are incomplete or unsupported"))?; + .ok_or(Invalid(INCOMPLETE))?; let version = &top["version"]; require( (version.is_u64() || version.is_i64()) && version.as_i64() == Some(1), @@ -201,6 +210,9 @@ pub fn validate(profile: &Value) -> Result<(), Invalid> { .is_none_or(|value| value.as_str().is_some_and(|value| value.starts_with("at-"))), "Use a Codex access token (at-...)", )?; + if let Some(paseo) = top.get("paseo") { + validate_paseo(paseo)?; + } require( python_json_len(profile) <= MAX_BYTES, "Profile exceeds Secrets Manager size limit", @@ -252,6 +264,57 @@ fn validate_codex_login(value: &Value, developer: &str) -> Result<(), Invalid> { ) } +/// The manager-generated Paseo pairing (`paseo.rs`): a 32-byte base64url +/// password, Paseo's `srv_` server ID, and a NaCl box keypair as the daemon +/// stores it. +fn validate_paseo(value: &Value) -> Result<(), Invalid> { + const MESSAGE: &str = "Invalid Paseo pairing"; + let base64url = |b: u8| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'; + let paseo = value + .as_object() + .filter(|map| { + PASEO_FIELDS.iter().all(|field| map.contains_key(*field)) + && map + .keys() + .all(|key| PASEO_FIELDS.contains(&key.as_str()) || key == PASEO_GATEWAY_KEY) + }) + .ok_or(Invalid(INCOMPLETE))?; + require( + paseo["password"] + .as_str() + .is_some_and(|password| password.len() == 43 && password.bytes().all(base64url)), + MESSAGE, + )?; + require( + paseo["server_id"] + .as_str() + .and_then(|id| id.strip_prefix("srv_")) + .is_some_and(|id| id.len() == 12 && id.bytes().all(base64url)), + MESSAGE, + )?; + let keypair = fields( + &paseo["daemon_keypair"], + &["v", "publicKeyB64", "secretKeyB64"], + )?; + let version = &keypair["v"]; + require( + (version.is_u64() || version.is_i64()) && version.as_i64() == Some(2), + MESSAGE, + )?; + for key in [&keypair["publicKeyB64"], &keypair["secretKeyB64"]] { + // Canonical, padded standard base64 of 32 bytes, as tweetnacl keys + // export: `STANDARD` refuses nonzero trailing bits and odd padding. + let canonical = key + .as_str() + .is_some_and(|key| STANDARD.decode(key).is_ok_and(|bytes| bytes.len() == 32)); + require(canonical, MESSAGE)?; + } + if let Some(key) = paseo.get(PASEO_GATEWAY_KEY) { + token(key)?; + } + Ok(()) +} + /// The byte length of Python's `json.dumps(value)`: `", "` and `": "` /// separators, and every character outside `' '..='~'` escaped, as `\uXXXX` /// (a surrogate pair above U+FFFF) unless it has a short escape. @@ -304,13 +367,17 @@ const EXCLUSIVE_MODELS: &[(&str, &str)] = &[ /// `profile.py`'s fields, each optional, with `null` removing a model /// credential. [`validate`] checks the merged profile's values. pub fn check_partial(profile: &Value) -> Result<(), Invalid> { - const INCOMPLETE: &str = "Profile fields are incomplete or unsupported"; let top = profile.as_object().ok_or(Invalid(INCOMPLETE))?; require( top.keys() - .all(|k| FIELDS.contains(&k.as_str()) || k == "codex_login"), + .all(|k| FIELDS.contains(&k.as_str()) || MANAGED.contains(&k.as_str())), INCOMPLETE, )?; + // Re-enrollment keeps the pairing, so a client never sends one. + require( + !top.contains_key("paseo"), + "Paseo pairing is managed by the Sandbox manager", + )?; // Clients may disconnect, but only the manager creates broker metadata. require( top.get("codex_login").is_none_or(Value::is_null), diff --git a/crates/buzz-sandbox-reaper/src/profile_tests.rs b/crates/buzz-sandbox-reaper/src/profile_tests.rs index e19e1607d09..814b85e748c 100644 --- a/crates/buzz-sandbox-reaper/src/profile_tests.rs +++ b/crates/buzz-sandbox-reaper/src/profile_tests.rs @@ -259,3 +259,72 @@ fn a_partial_profile_may_omit_fields_but_not_add_them() { assert_eq!(check_partial(&partial), Err(Invalid(message)), "{pointer}"); } } + +/// A pairing as the manager generates it, from the tweetnacl vector in +/// `paseo_tests.rs`. +pub(crate) fn paseo() -> Value { + json!({"password": "A".repeat(43), "server_id": "srv_AAECAwQFBgcI", + "daemon_keypair": {"v": 2, "publicKeyB64": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=", + "secretKeyB64": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo="}}) +} + +/// The manager-written `paseo` section: each rule with a value just outside +/// it, `gateway_key` reserved for the daemon's future Buzz key, and never +/// accepted from a client. +#[test] +fn the_paseo_pairing_is_validated_and_never_sent_by_a_client() { + const PAIRING: &str = "Invalid Paseo pairing"; + let mut value = profile(); + value["paseo"] = paseo(); + validate(&value).unwrap(); + value["paseo"]["gateway_key"] = json!("nsec1fixture"); + validate(&value).unwrap(); + for (pointer, bad, message) in [ + ("/paseo/password", json!("A".repeat(42)), PAIRING), + ( + "/paseo/password", + json!(format!("{}+", "A".repeat(42))), + PAIRING, + ), + ("/paseo/server_id", json!("srv_AAECAwQFBgc"), PAIRING), + ("/paseo/server_id", json!("AAECAwQFBgcIAAAA"), PAIRING), + ("/paseo/daemon_keypair/v", json!(1), PAIRING), + ("/paseo/daemon_keypair/v", json!("2"), PAIRING), + // Nonzero trailing bits: decodes leniently, but is not canonical. + ( + "/paseo/daemon_keypair/publicKeyB64", + json!("hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmp="), + PAIRING, + ), + ( + "/paseo/daemon_keypair/publicKeyB64", + json!("hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr066SpjqqbTmo"), + PAIRING, + ), + ( + "/paseo/daemon_keypair/secretKeyB64", + json!("dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LA=="), + PAIRING, + ), + ("/paseo/daemon_keypair/extra", json!(1), INCOMPLETE), + ("/paseo/extra", json!("x"), INCOMPLETE), + ("/paseo/gateway_key", json!("a b"), "Invalid credential"), + ] { + let mut value = profile(); + value["paseo"] = paseo(); + let (parent, field) = pointer.rsplit_once('/').unwrap(); + value.pointer_mut(parent).unwrap()[field] = bad.clone(); + assert_eq!(validate(&value), Err(Invalid(message)), "{pointer} = {bad}"); + } + let mut missing = profile(); + missing["paseo"] = paseo(); + missing["paseo"].as_object_mut().unwrap().remove("password"); + assert_eq!(validate(&missing), Err(Invalid(INCOMPLETE))); + for sent in [paseo(), json!(null)] { + let partial = json!({"version": 1, "owner_pubkey": "a".repeat(64), "paseo": sent}); + assert_eq!( + check_partial(&partial), + Err(Invalid("Paseo pairing is managed by the Sandbox manager")) + ); + } +} 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..bbd48981574 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 profile was stored, but the developer's Paseo daemon Secret was not written (or the pairing could not be generated). `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,84 @@ 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. Before validation, adds a `paseo` section to a merged profile that has + none: `password` (32 random bytes, base64url), `server_id` (`srv_` and 9 + random bytes, base64url) and `daemon_keypair` (`{"v":2,"publicKeyB64", + "secretKeyB64"}`, a tweetnacl box keypair). A stored section is kept, so + pairings survive re-enrollment. A client never sends `paseo` + (`invalid_profile`). `paseo.gateway_key` is reserved for the daemon's + future delegated Buzz key. +2. After the profile write, creates or replaces the Secret + `paseo-daemon-` in `buzz-paseo` with exactly these keys, which the + Deployment reads with `secretKeyRef`: + + | Key | Value | + | --- | --- | + | `PASEO_PASSWORD`, `PASEO_SERVER_ID` | from `paseo` | + | `PASEO_DAEMON_KEYPAIR` | `paseo.daemon_keypair` as a JSON string | + | `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 | + | `BUZZ_PRIVATE_KEY` | `paseo.gateway_key`, when set (none yet) | + + No other profile credential (GitHub, 1Password, API keys, the ChatGPT + broker login) reaches the daemon. A failure is `paseo_store_failed`; the + profile already holds the pairing, so the next enroll writes the Secret + again. + +`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; the daemon's secret key is never in a reply: + +```json +"paseo": {"password": "", "server_id": "srv_", + "offer_url": "https://app.paseo.sh/#offer="} +``` + +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, update +and patch 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..266ba996f06 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -190,6 +190,11 @@ 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`, the manager adds a `paseo` section at the first enroll and keeps it on every later one, so paired Paseo apps survive re-enrollment: +`password` (32 random bytes, base64url), `server_id` (`srv_` and 9 random bytes, base64url, as Paseo generates it), and `daemon_keypair` (`{"v":2,"publicKeyB64","secretKeyB64"}`, a tweetnacl box keypair in standard base64). +Clients cannot send it. The manager copies it to the Secret `paseo-daemon-` in namespace `buzz-paseo` (keys `PASEO_PASSWORD`, `PASEO_SERVER_ID`, `PASEO_DAEMON_KEYPAIR`, the agent list `BUZZ_PASEO_AGENTS`, and the profile's Claude subscription or Codex access token), which is all the developer's daemon reads. +The bootstrap validates the section and does not use it. An optional `gateway_key` credential is reserved for the daemon's future Buzz 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/images/buzz-agent/profile.example.json b/voltage/images/buzz-agent/profile.example.json index 99c17937449..5d8aaeafc0f 100644 --- a/voltage/images/buzz-agent/profile.example.json +++ b/voltage/images/buzz-agent/profile.example.json @@ -15,5 +15,14 @@ "profile": "REPLACE_WITH_DEVELOPMENT_VIA_TOML", "op_token": "REPLACE_WITH_DEVELOPMENT_VAULT_SERVICE_ACCOUNT_TOKEN" }, - "models": {} + "models": {}, + "paseo": { + "password": "GENERATED_BY_THE_SANDBOX_MANAGER (32 random bytes, base64url)", + "server_id": "GENERATED_BY_THE_SANDBOX_MANAGER (srv_ and 9 random bytes, base64url)", + "daemon_keypair": { + "v": 2, + "publicKeyB64": "GENERATED_BY_THE_SANDBOX_MANAGER (X25519 public key, standard base64)", + "secretKeyB64": "GENERATED_BY_THE_SANDBOX_MANAGER (32 random bytes, standard base64)" + } + } } diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index e73709b948b..b77da5f288f 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -20,6 +20,10 @@ FIELDS = {'version', 'developer', 'owner_pubkey', 'git', 'github', 'via', 'models'} MODEL_FIELDS = {'openai_api_key', 'codex_access_token', 'anthropic_api_key', 'claude_oauth_token', 'openrouter_api_key'} +# Sections only the Sandbox manager writes. +MANAGED_FIELDS = {'codex_login', 'paseo'} +# The developer's Paseo daemon pairing; `gateway_key` is reserved for its future Buzz key. +PASEO_FIELDS = {'password', 'server_id', 'daemon_keypair'} # Inherited values would override or mix with the profile's selected accounts. INHERITED_MODEL_VARIABLES = ('OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'OPENROUTER_API_KEY', @@ -58,7 +62,7 @@ def token(value): def validate(profile): require(isinstance(profile, dict) and FIELDS <= set(profile) - and set(profile) <= FIELDS | {'codex_login'}, 'Profile fields are incomplete or unsupported') + and set(profile) <= FIELDS | MANAGED_FIELDS, 'Profile fields are incomplete or unsupported') require(type(profile['version']) is int and profile['version'] == 1, 'Unsupported profile version') require(isinstance(profile['developer'], str) and re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?', profile['developer']), 'Invalid developer ID') @@ -95,9 +99,37 @@ def validate(profile): token(value) require('codex_access_token' not in profile['models'] or profile['models']['codex_access_token'].startswith('at-'), 'Use a Codex access token (at-...)') + if 'paseo' in profile: + validate_paseo(profile['paseo']) require(len(json.dumps(profile).encode()) <= MAX_BYTES, 'Profile exceeds Secrets Manager size limit') +def nacl_key(value): + """Canonical, padded standard base64 of 32 bytes, as tweetnacl keys export.""" + try: + raw = base64.b64decode(value, validate=True) + except (ValueError, TypeError): + return False + return len(raw) == 32 and base64.b64encode(raw).decode() == value + + +def validate_paseo(paseo): + """The manager-generated pairing; the Sandbox does not use it, but must not refuse it.""" + require(isinstance(paseo, dict) and PASEO_FIELDS <= set(paseo) <= PASEO_FIELDS | {'gateway_key'}, + 'Profile fields are incomplete or unsupported') + require(isinstance(paseo['password'], str) + and re.fullmatch(r'[A-Za-z0-9_-]{43}', paseo['password']), 'Invalid Paseo pairing') + require(isinstance(paseo['server_id'], str) + and re.fullmatch(r'srv_[A-Za-z0-9_-]{12}', paseo['server_id']), 'Invalid Paseo pairing') + keypair = paseo['daemon_keypair'] + fields(keypair, {'v', 'publicKeyB64', 'secretKeyB64'}) + require(type(keypair['v']) is int and keypair['v'] == 2, 'Invalid Paseo pairing') + for key in (keypair['publicKeyB64'], keypair['secretKeyB64']): + require(isinstance(key, str) and nacl_key(key), 'Invalid Paseo pairing') + if 'gateway_key' in paseo: + token(paseo['gateway_key']) + + def validate_codex_login(login, developer): fields(login, {'url', 'ca_certificate', 'refresh_token'}) require(isinstance(login['url'], str), 'Invalid ChatGPT broker URL') diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index 1ecab3423f3..0105c923ec0 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -15,6 +15,10 @@ 'secrets.token="op://Fixture/Depot/credential"\n', 'op_token': 'synthetic-profile-op'}, 'models': {'openai_api_key': 'synthetic-profile-openai', 'anthropic_api_key': 'synthetic-profile-anthropic'}, + # The manager adds the developer's Paseo daemon pairing; the Sandbox must still start. + 'paseo': {'password': 'A' * 43, 'server_id': 'srv_AAECAwQFBgcI', + 'daemon_keypair': {'v': 2, 'publicKeyB64': 'hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=', + 'secretKeyB64': 'dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo='}}, } COMMON = r''' diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 7c4184de05c..f5fa6b8ed47 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 | Profile; copied to 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,18 @@ 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, and keypair, stores them in your profile, and copies them to the daemon's Secret. +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. + +Enrolling again keeps the pairing, so paired apps keep working. If the reply is **Refused (paseo_store_failed)**, the profile was stored but the daemon's Secret was not: 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`. From 35d554a120c8133bbc5df67aedd4937538a27b60 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 08:34:01 -0500 Subject: [PATCH 2/5] fix(sandbox-manager): write the Paseo daemon Secret before the profile A paseo_store_failed refusal came after the profile was already stored. A client that sees a refusal keeps what it sent for the retry, so the GPUI app skipped registering a new agent signing key with GitHub while the manager had stored it; the retry then found the key on file and never registered it, leaving agent commits unverified. Write the daemon Secret after validation and the GitHub check, before the profile write, so a refusal always leaves the profile unchanged. A Secret written for a profile that then fails to store holds only what the request carried, and the retried enroll rewrites it. Signed-off-by: Tee8z --- crates/buzz-backend-kubernetes/src/wire.rs | 6 ++--- crates/buzz-sandbox-reaper/src/enroll.rs | 26 +++++++++++-------- .../buzz-sandbox-reaper/src/enroll_tests.rs | 15 ++++++++--- docs/remote-agents.md | 12 +++++---- voltage/remote-agents.md | 2 +- 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/crates/buzz-backend-kubernetes/src/wire.rs b/crates/buzz-backend-kubernetes/src/wire.rs index 6bd2379cb33..5ffd12e2f70 100644 --- a/crates/buzz-backend-kubernetes/src/wire.rs +++ b/crates/buzz-backend-kubernetes/src/wire.rs @@ -244,9 +244,9 @@ pub enum ErrorCode { GithubLoginMismatch, /// The profile could not be written to the developer's secret. ProfileStoreFailed, - /// The profile was stored, or the Sandbox started, but the developer's - /// Paseo daemon Secret could not be written; repeating the request - /// retries it. + /// 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, diff --git a/crates/buzz-sandbox-reaper/src/enroll.rs b/crates/buzz-sandbox-reaper/src/enroll.rs index 1ab81b2b845..cb2933b2d6a 100644 --- a/crates/buzz-sandbox-reaper/src/enroll.rs +++ b/crates/buzz-sandbox-reaper/src/enroll.rs @@ -507,6 +507,21 @@ 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. + if let Some(secrets) = &self.paseo { + if let Err(paseo::Failed(label)) = secrets.write(developer, &profile).await { + return refusal( + ErrorCode::PaseoStoreFailed, + &format!( + "could not write the Paseo daemon secret ({label}); the profile is unchanged, retry" + ), + ); + } + } // 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) { @@ -533,17 +548,6 @@ impl Enrollment { } } } - // The profile holds the pairing, so the next enroll retries this write. - if let Some(secrets) = &self.paseo { - if let Err(paseo::Failed(label)) = secrets.write(developer, &profile).await { - return refusal( - ErrorCode::PaseoStoreFailed, - &format!( - "the profile was stored, but not the Paseo daemon secret ({label}); enroll again" - ), - ); - } - } let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let summary = profile::summary(&profile, &version_id, Some(now)); let mut reply = diff --git a/crates/buzz-sandbox-reaper/src/enroll_tests.rs b/crates/buzz-sandbox-reaper/src/enroll_tests.rs index e20846bb059..134d5a7021e 100644 --- a/crates/buzz-sandbox-reaper/src/enroll_tests.rs +++ b/crates/buzz-sandbox-reaper/src/enroll_tests.rs @@ -339,7 +339,7 @@ pub(crate) fn daemon_secrets(cluster: &sandbox_fake::Shared) -> DaemonSecrets { /// With Paseo daemons on, the first enroll generates a pairing and stores /// it in the profile. A failed daemon Secret write is reported, never a -/// silent success. The next enroll keeps the pairing, so paired apps +/// 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 @@ -381,12 +381,21 @@ async fn paseo_pairing_is_kept_mirrored_to_the_daemon_secret_and_returned() { let (enrollment, written) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await; let enrollment = enrollment.with_paseo(daemon_secrets(&cluster)); + // 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("enroll again")); + 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}"); let pairing = stored(&written)["paseo"].clone(); assert!(pairing.is_object()); - assert!(daemon_secret(&cluster).is_none()); + assert_eq!( + daemon_secret(&cluster).unwrap().1["PASEO_PASSWORD"], + pairing["password"].as_str().unwrap() + ); let partial = json!({"version": 1, "developer": "dev", "owner_pubkey": "a".repeat(64), "models": {"claude_oauth_token": "sk-ant-oat01-fixture"}}); diff --git a/docs/remote-agents.md b/docs/remote-agents.md index bbd48981574..723b1543411 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -1968,7 +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 profile was stored, but the developer's Paseo daemon Secret was not written (or the pairing could not be generated). `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 | +| `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 | @@ -2100,7 +2100,8 @@ through the Paseo relay at `paseo_relay_endpoint` (`host:port`, default pairings survive re-enrollment. A client never sends `paseo` (`invalid_profile`). `paseo.gateway_key` is reserved for the daemon's future delegated Buzz key. -2. After the profile write, creates or replaces the Secret +2. After validation and the GitHub check, and before the profile write, + creates or replaces the Secret `paseo-daemon-` in `buzz-paseo` with exactly these keys, which the Deployment reads with `secretKeyRef`: @@ -2114,9 +2115,10 @@ through the Paseo relay at `paseo_relay_endpoint` (`host:port`, default | `BUZZ_PRIVATE_KEY` | `paseo.gateway_key`, when set (none yet) | No other profile credential (GitHub, 1Password, API keys, the ChatGPT - broker login) reaches the daemon. A failure is `paseo_store_failed`; the - profile already holds the pairing, so the next enroll writes the Secret - again. + broker login) reaches the daemon. A failure is `paseo_store_failed` and + leaves the profile unchanged, so a refusal never hides a stored change + (such as a new signing key) from the client; the retried enroll writes + both. If the profile write then fails, the retry rewrites the Secret. `BUZZ_PASEO_AGENTS` is a JSON array `[{"id","label","agent","channel"}]`, derived from the developer's `buzz-mobile-launch-*` registrations on the diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index f5fa6b8ed47..91833be9b82 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -371,7 +371,7 @@ When an admin enables Paseo daemons, each developer has one always-on [Paseo](ht 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. -Enrolling again keeps the pairing, so paired apps keep working. If the reply is **Refused (paseo_store_failed)**, the profile was stored but the daemon's Secret was not: enroll again. +Enrolling again keeps the pairing, so paired apps keep working. 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 From 55b197d322af58be6eae8ff67b3c6dcf2c9ba5f7 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 13:05:33 -0500 Subject: [PATCH 3/5] fix(sandbox-manager): isolate daemon identities in the narrow Secret Signed-off-by: Tee8z --- .../src/sandbox_fake.rs | 16 ++- crates/buzz-sandbox-reaper/src/enroll.rs | 58 ++++----- .../buzz-sandbox-reaper/src/enroll_tests.rs | 31 ++++- .../buzz-sandbox-reaper/src/manager_tests.rs | 3 +- crates/buzz-sandbox-reaper/src/paseo.rs | 81 ++++--------- crates/buzz-sandbox-reaper/src/paseo_env.rs | 111 ++++++++++++++++++ crates/buzz-sandbox-reaper/src/paseo_tests.rs | 79 ++++++------- crates/buzz-sandbox-reaper/src/profile.rs | 81 ++----------- .../buzz-sandbox-reaper/src/profile_tests.rs | 69 ----------- docs/remote-agents.md | 49 ++++---- voltage/images/buzz-agent/README.md | 7 +- .../images/buzz-agent/profile.example.json | 11 +- voltage/images/buzz-agent/profile.py | 34 +----- voltage/images/buzz-agent/test-profile.py | 4 - voltage/remote-agents.md | 6 +- 15 files changed, 279 insertions(+), 361 deletions(-) create mode 100644 crates/buzz-sandbox-reaper/src/paseo_env.rs 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-sandbox-reaper/src/enroll.rs b/crates/buzz-sandbox-reaper/src/enroll.rs index cb2933b2d6a..5a2eff8affc 100644 --- a/crates/buzz-sandbox-reaper/src/enroll.rs +++ b/crates/buzz-sandbox-reaper/src/enroll.rs @@ -316,6 +316,8 @@ pub struct Enrollment { store: Arc, broker: Option>, paseo: Option, + /// Serialize profile and daemon Secret updates, including without a broker. + lock: tokio::sync::Mutex<()>, } impl Enrollment { @@ -325,6 +327,7 @@ impl Enrollment { store, broker: None, paseo: None, + lock: tokio::sync::Mutex::new(()), } } @@ -333,20 +336,12 @@ impl Enrollment { self } - /// Provision each developer's Paseo daemon: keep a pairing in the - /// profile, mirror it to the daemon's Secret, and return it to the owner. + /// 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 } - /// The owner's pairing details, when Paseo daemons are provisioned. - fn pairing(&self, profile: &Value) -> Option { - self.paseo - .as_ref() - .and_then(|secrets| secrets.pairing(profile)) - } - /// 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. @@ -401,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), @@ -461,18 +457,6 @@ impl Enrollment { models.remove("codex_access_token"); } } - // A stored pairing is kept, so the developer's paired apps survive. - if self.paseo.is_some() && profile.get("paseo").is_none() { - match paseo::generate() { - Ok(pairing) => profile["paseo"] = pairing, - Err(paseo::Failed(label)) => { - return refusal( - ErrorCode::PaseoStoreFailed, - &format!("could not generate the Paseo pairing ({label}); retry"), - ) - } - } - } if let Err(profile::Invalid(message)) = profile::validate(&profile) { return refusal(ErrorCode::InvalidProfile, message); } @@ -512,16 +496,17 @@ impl Enrollment { // 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. - if let Some(secrets) = &self.paseo { - if let Err(paseo::Failed(label)) = secrets.write(developer, &profile).await { - return refusal( + 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" - ), - ); + &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) { @@ -552,7 +537,7 @@ impl Enrollment { let summary = profile::summary(&profile, &version_id, Some(now)); let mut reply = serde_json::json!({"ok": true, "version_id": version_id, "profile": summary}); - if let Some(pairing) = self.pairing(&profile) { + if let Some(pairing) = pairing { reply["paseo"] = pairing; } reply @@ -573,8 +558,17 @@ impl Enrollment { "ok": true, "profile": profile::summary(&profile, &stored.version_id, stored.created), }); - if let Some(pairing) = self.pairing(&profile) { - reply["paseo"] = pairing; + 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 } diff --git a/crates/buzz-sandbox-reaper/src/enroll_tests.rs b/crates/buzz-sandbox-reaper/src/enroll_tests.rs index 134d5a7021e..ab973dd9beb 100644 --- a/crates/buzz-sandbox-reaper/src/enroll_tests.rs +++ b/crates/buzz-sandbox-reaper/src/enroll_tests.rs @@ -338,14 +338,14 @@ pub(crate) fn daemon_secrets(cluster: &sandbox_fake::Shared) -> DaemonSecrets { } /// With Paseo daemons on, the first enroll generates a pairing and stores -/// it in the profile. A failed daemon Secret write is reported, never a +/// 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_mirrored_to_the_daemon_secret_and_returned() { +async fn paseo_pairing_is_kept_only_in_the_daemon_secret_and_returned() { use crate::mobile_launch::Registration; use nostr::nips::nip19::ToBech32; @@ -390,19 +390,28 @@ async fn paseo_pairing_is_kept_mirrored_to_the_daemon_secret_and_returned() { assert!(daemon_secret(&cluster).is_none()); let response = enrollment.run(&developer(), profile()).await; assert_eq!(response["version_id"], "version-1", "{response}"); - let pairing = stored(&written)["paseo"].clone(); - assert!(pairing.is_object()); + 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_eq!(stored(&written)["paseo"], pairing); + 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", @@ -423,6 +432,10 @@ async fn paseo_pairing_is_kept_mirrored_to_the_daemon_secret_and_returned() { 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() @@ -435,12 +448,18 @@ async fn paseo_pairing_is_kept_mirrored_to_the_daemon_secret_and_returned() { paseo::RELAY_ENDPOINT, ); let expected = json!({"password": pairing["password"], - "server_id": pairing["server_id"], "offer_url": offer}); + "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); assert_eq!(enrollment.describe(&developer()).await["paseo"], expected); // 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"])); } } diff --git a/crates/buzz-sandbox-reaper/src/manager_tests.rs b/crates/buzz-sandbox-reaper/src/manager_tests.rs index b7a504e7740..b160ea7e9cf 100644 --- a/crates/buzz-sandbox-reaper/src/manager_tests.rs +++ b/crates/buzz-sandbox-reaper/src/manager_tests.rs @@ -815,8 +815,7 @@ async fn a_registering_deploy_lists_the_agent_for_the_owners_paseo_daemon() { .secrets .contains_key("paseo-daemon-dev")); - let mut profile = crate::profile::tests::valid("dev", &"a".repeat(64)); - profile["paseo"] = crate::profile::tests::paseo(); + let profile = crate::profile::tests::valid("dev", &"a".repeat(64)); crate::enroll::tests::daemon_secrets(&cluster) .write(&developer, &profile) .await diff --git a/crates/buzz-sandbox-reaper/src/paseo.rs b/crates/buzz-sandbox-reaper/src/paseo.rs index c5a55cab8d4..216d4d12aa1 100644 --- a/crates/buzz-sandbox-reaper/src/paseo.rs +++ b/crates/buzz-sandbox-reaper/src/paseo.rs @@ -1,6 +1,6 @@ //! Per-developer Paseo daemons (`voltage/remote-agents.md` §Paseo): the -//! pairing fields `enroll` keeps in the profile's `paseo` section, the narrow -//! Kubernetes Secret the developer's daemon reads its environment from, and +//! 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 @@ -120,51 +120,9 @@ pub fn offer_url(server_id: &str, daemon_public_key: &str, relay_endpoint: &str) format!("{APP_BASE_URL}/#offer={}", URL_SAFE_NO_PAD.encode(offer)) } -/// What the owner needs to pair a Paseo app with their daemon: the password -/// and the offer URL. `None` when the profile holds no pairing. -pub fn pairing(profile: &Value, relay_endpoint: &str) -> Option { - let text = |pointer: &str| profile.pointer(pointer).and_then(Value::as_str); - let server_id = text("/paseo/server_id")?; - let public_key = text("/paseo/daemon_keypair/publicKeyB64")?; - Some(json!({ - "password": text("/paseo/password")?, - "server_id": server_id, - "offer_url": offer_url(server_id, public_key, relay_endpoint), - })) -} - -/// The daemon's environment from the profile and its agent list: the -/// pairing, [`AGENTS_KEY`], the profile's [`MODEL_TOKENS`] that are set, and -/// `BUZZ_PRIVATE_KEY` once the profile reserves a gateway key. -fn daemon_env(profile: &Value, agents: &Value) -> Result, Failed> { - let paseo = &profile["paseo"]; - let text = |key: &str| { - paseo - .get(key) - .and_then(Value::as_str) - .map(str::to_owned) - .ok_or(Failed("invalid_pairing")) - }; - let keypair = paseo - .get("daemon_keypair") - .filter(|keypair| keypair.is_object()) - .ok_or(Failed("invalid_pairing"))?; - let mut env = BTreeMap::from([ - ("PASEO_PASSWORD".to_owned(), text("password")?), - ("PASEO_SERVER_ID".to_owned(), text("server_id")?), - ("PASEO_DAEMON_KEYPAIR".to_owned(), keypair.to_string()), - (AGENTS_KEY.to_owned(), agents.to_string()), - ]); - for (field, variable) in MODEL_TOKENS { - if let Some(token) = profile["models"][field].as_str() { - env.insert((*variable).to_owned(), token.to_owned()); - } - } - if let Some(key) = paseo.get("gateway_key").and_then(Value::as_str) { - env.insert("BUZZ_PRIVATE_KEY".to_owned(), key.to_owned()); - } - Ok(env) -} +#[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. @@ -272,9 +230,14 @@ impl DaemonSecrets { }) } - /// The owner's pairing details from their profile. - pub fn pairing(&self, profile: &Value) -> Option { - pairing(profile, &self.relay_endpoint) + /// 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. @@ -371,19 +334,25 @@ impl DaemonSecrets { } /// Create the developer's Secret, or replace it at the resourceVersion - /// just read, with exactly the daemon environment derived from `profile` - /// and the current registrations. A conflict with a concurrent writer is + /// 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<(), Failed> { + 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 env = daemon_env(profile, &self.agents(developer).await?)?; 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() @@ -392,7 +361,7 @@ impl DaemonSecrets { .save(&api, prior.as_ref(), secret, &developer.id) .await? { - return Ok(()); + return Ok(pairing); } } Err(Failed("conflict")) @@ -407,7 +376,6 @@ impl DaemonSecrets { let api: Api = Api::namespaced(self.client.clone(), &self.namespace); let name = Self::name(&developer.id); for _ in 0..WRITE_ATTEMPTS { - let agents = self.agents(developer).await?; let Some(prior) = api .get_opt(&name) .await @@ -415,6 +383,7 @@ impl DaemonSecrets { 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 { 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 index 50de1598d75..dce39451fe6 100644 --- a/crates/buzz-sandbox-reaper/src/paseo_tests.rs +++ b/crates/buzz-sandbox-reaper/src/paseo_tests.rs @@ -94,47 +94,47 @@ fn relay_endpoints_are_host_and_port_only() { } } -/// The daemon's environment: the pairing, the agent list, only the Claude -/// subscription and Codex access tokens among the model credentials, the -/// reserved gateway key, and never a GitHub, 1Password or API key. +/// The daemon gets only its identity, agents, and selected model tokens. #[test] -fn the_daemon_env_holds_only_the_pairing_agents_and_its_model_token() { +fn daemon_environment_preserves_identity_and_refuses_malformed_saved_fields() { let mut profile = crate::profile::tests::valid("dev", &"a".repeat(64)); - profile["paseo"] = crate::profile::tests::paseo(); - let agents = json!([{"id": "brokkr", "label": "Brokkr", "agent": "b".repeat(64), - "channel": "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10"}]); - let base = BTreeMap::from([ - ("PASEO_PASSWORD".to_owned(), "A".repeat(43)), - ("PASEO_SERVER_ID".to_owned(), "srv_AAECAwQFBgcI".to_owned()), - ( - "PASEO_DAEMON_KEYPAIR".to_owned(), - profile["paseo"]["daemon_keypair"].to_string(), - ), - (AGENTS_KEY.to_owned(), agents.to_string()), - ]); - // The fixture holds an Anthropic API key only: it stays out. - assert_eq!(daemon_env(&profile, &agents).unwrap(), base); - 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"}); - profile["paseo"]["gateway_key"] = json!("nsec1fixture"); - let mut expected = base.clone(); - expected.extend([ - ( - "CLAUDE_CODE_OAUTH_TOKEN".to_owned(), - "sk-ant-oat01-fixture".to_owned(), - ), - ("CODEX_ACCESS_TOKEN".to_owned(), "at-fixture".to_owned()), - ("BUZZ_PRIVATE_KEY".to_owned(), "nsec1fixture".to_owned()), - ]); - assert_eq!(daemon_env(&profile, &agents).unwrap(), expected); - profile["paseo"] - .as_object_mut() - .unwrap() - .remove("server_id"); + 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!( - daemon_env(&profile, &agents), - Err(Failed("invalid_pairing")) + 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")) ); } @@ -181,8 +181,7 @@ fn agent_ids_are_valid_unique_and_never_reserved() { #[test] fn generated_pairings_are_valid_consistent_and_fresh() { let first = generate().unwrap(); - let mut profile = crate::profile::tests::valid("dev", &"a".repeat(64)); - profile["paseo"] = first.clone(); + 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()) diff --git a/crates/buzz-sandbox-reaper/src/profile.rs b/crates/buzz-sandbox-reaper/src/profile.rs index 7470f5e3d84..d23490cdfc8 100644 --- a/crates/buzz-sandbox-reaper/src/profile.rs +++ b/crates/buzz-sandbox-reaper/src/profile.rs @@ -4,7 +4,6 @@ //! refuses would break every later session; the two must agree rule for //! rule. Refusals are the image's messages, which never include a value. -use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::{Map, Value}; use url::Url; @@ -27,14 +26,6 @@ const MODEL_FIELDS: &[&str] = &[ "claude_oauth_token", "openrouter_api_key", ]; -/// Top-level sections only the manager writes: [`validate`] accepts them, and -/// [`check_partial`] refuses them from a client (except `codex_login: null`). -const MANAGED: &[&str] = &["codex_login", "paseo"]; -const PASEO_FIELDS: &[&str] = &["password", "server_id", "daemon_keypair"]; -/// Reserved for the daemon's own Buzz key, so a later manager can add it -/// without older images refusing the profile. -const PASEO_GATEWAY_KEY: &str = "gateway_key"; -const INCOMPLETE: &str = "Profile fields are incomplete or unsupported"; /// Why a profile was refused: one of `profile.py`'s messages. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -53,7 +44,7 @@ fn fields<'a>(value: &'a Value, expected: &[&str]) -> Result<&'a Map Result<(), Invalid> { .as_object() .filter(|map| { FIELDS.iter().all(|field| map.contains_key(*field)) - && map.keys().all(|field| { - FIELDS.contains(&field.as_str()) || MANAGED.contains(&field.as_str()) - }) + && map + .keys() + .all(|field| FIELDS.contains(&field.as_str()) || field == "codex_login") }) - .ok_or(Invalid(INCOMPLETE))?; + .ok_or(Invalid("Profile fields are incomplete or unsupported"))?; let version = &top["version"]; require( (version.is_u64() || version.is_i64()) && version.as_i64() == Some(1), @@ -210,9 +201,6 @@ pub fn validate(profile: &Value) -> Result<(), Invalid> { .is_none_or(|value| value.as_str().is_some_and(|value| value.starts_with("at-"))), "Use a Codex access token (at-...)", )?; - if let Some(paseo) = top.get("paseo") { - validate_paseo(paseo)?; - } require( python_json_len(profile) <= MAX_BYTES, "Profile exceeds Secrets Manager size limit", @@ -264,57 +252,6 @@ fn validate_codex_login(value: &Value, developer: &str) -> Result<(), Invalid> { ) } -/// The manager-generated Paseo pairing (`paseo.rs`): a 32-byte base64url -/// password, Paseo's `srv_` server ID, and a NaCl box keypair as the daemon -/// stores it. -fn validate_paseo(value: &Value) -> Result<(), Invalid> { - const MESSAGE: &str = "Invalid Paseo pairing"; - let base64url = |b: u8| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'; - let paseo = value - .as_object() - .filter(|map| { - PASEO_FIELDS.iter().all(|field| map.contains_key(*field)) - && map - .keys() - .all(|key| PASEO_FIELDS.contains(&key.as_str()) || key == PASEO_GATEWAY_KEY) - }) - .ok_or(Invalid(INCOMPLETE))?; - require( - paseo["password"] - .as_str() - .is_some_and(|password| password.len() == 43 && password.bytes().all(base64url)), - MESSAGE, - )?; - require( - paseo["server_id"] - .as_str() - .and_then(|id| id.strip_prefix("srv_")) - .is_some_and(|id| id.len() == 12 && id.bytes().all(base64url)), - MESSAGE, - )?; - let keypair = fields( - &paseo["daemon_keypair"], - &["v", "publicKeyB64", "secretKeyB64"], - )?; - let version = &keypair["v"]; - require( - (version.is_u64() || version.is_i64()) && version.as_i64() == Some(2), - MESSAGE, - )?; - for key in [&keypair["publicKeyB64"], &keypair["secretKeyB64"]] { - // Canonical, padded standard base64 of 32 bytes, as tweetnacl keys - // export: `STANDARD` refuses nonzero trailing bits and odd padding. - let canonical = key - .as_str() - .is_some_and(|key| STANDARD.decode(key).is_ok_and(|bytes| bytes.len() == 32)); - require(canonical, MESSAGE)?; - } - if let Some(key) = paseo.get(PASEO_GATEWAY_KEY) { - token(key)?; - } - Ok(()) -} - /// The byte length of Python's `json.dumps(value)`: `", "` and `": "` /// separators, and every character outside `' '..='~'` escaped, as `\uXXXX` /// (a surrogate pair above U+FFFF) unless it has a short escape. @@ -367,17 +304,13 @@ const EXCLUSIVE_MODELS: &[(&str, &str)] = &[ /// `profile.py`'s fields, each optional, with `null` removing a model /// credential. [`validate`] checks the merged profile's values. pub fn check_partial(profile: &Value) -> Result<(), Invalid> { + const INCOMPLETE: &str = "Profile fields are incomplete or unsupported"; let top = profile.as_object().ok_or(Invalid(INCOMPLETE))?; require( top.keys() - .all(|k| FIELDS.contains(&k.as_str()) || MANAGED.contains(&k.as_str())), + .all(|k| FIELDS.contains(&k.as_str()) || k == "codex_login"), INCOMPLETE, )?; - // Re-enrollment keeps the pairing, so a client never sends one. - require( - !top.contains_key("paseo"), - "Paseo pairing is managed by the Sandbox manager", - )?; // Clients may disconnect, but only the manager creates broker metadata. require( top.get("codex_login").is_none_or(Value::is_null), diff --git a/crates/buzz-sandbox-reaper/src/profile_tests.rs b/crates/buzz-sandbox-reaper/src/profile_tests.rs index 814b85e748c..e19e1607d09 100644 --- a/crates/buzz-sandbox-reaper/src/profile_tests.rs +++ b/crates/buzz-sandbox-reaper/src/profile_tests.rs @@ -259,72 +259,3 @@ fn a_partial_profile_may_omit_fields_but_not_add_them() { assert_eq!(check_partial(&partial), Err(Invalid(message)), "{pointer}"); } } - -/// A pairing as the manager generates it, from the tweetnacl vector in -/// `paseo_tests.rs`. -pub(crate) fn paseo() -> Value { - json!({"password": "A".repeat(43), "server_id": "srv_AAECAwQFBgcI", - "daemon_keypair": {"v": 2, "publicKeyB64": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=", - "secretKeyB64": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo="}}) -} - -/// The manager-written `paseo` section: each rule with a value just outside -/// it, `gateway_key` reserved for the daemon's future Buzz key, and never -/// accepted from a client. -#[test] -fn the_paseo_pairing_is_validated_and_never_sent_by_a_client() { - const PAIRING: &str = "Invalid Paseo pairing"; - let mut value = profile(); - value["paseo"] = paseo(); - validate(&value).unwrap(); - value["paseo"]["gateway_key"] = json!("nsec1fixture"); - validate(&value).unwrap(); - for (pointer, bad, message) in [ - ("/paseo/password", json!("A".repeat(42)), PAIRING), - ( - "/paseo/password", - json!(format!("{}+", "A".repeat(42))), - PAIRING, - ), - ("/paseo/server_id", json!("srv_AAECAwQFBgc"), PAIRING), - ("/paseo/server_id", json!("AAECAwQFBgcIAAAA"), PAIRING), - ("/paseo/daemon_keypair/v", json!(1), PAIRING), - ("/paseo/daemon_keypair/v", json!("2"), PAIRING), - // Nonzero trailing bits: decodes leniently, but is not canonical. - ( - "/paseo/daemon_keypair/publicKeyB64", - json!("hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmp="), - PAIRING, - ), - ( - "/paseo/daemon_keypair/publicKeyB64", - json!("hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr066SpjqqbTmo"), - PAIRING, - ), - ( - "/paseo/daemon_keypair/secretKeyB64", - json!("dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LA=="), - PAIRING, - ), - ("/paseo/daemon_keypair/extra", json!(1), INCOMPLETE), - ("/paseo/extra", json!("x"), INCOMPLETE), - ("/paseo/gateway_key", json!("a b"), "Invalid credential"), - ] { - let mut value = profile(); - value["paseo"] = paseo(); - let (parent, field) = pointer.rsplit_once('/').unwrap(); - value.pointer_mut(parent).unwrap()[field] = bad.clone(); - assert_eq!(validate(&value), Err(Invalid(message)), "{pointer} = {bad}"); - } - let mut missing = profile(); - missing["paseo"] = paseo(); - missing["paseo"].as_object_mut().unwrap().remove("password"); - assert_eq!(validate(&missing), Err(Invalid(INCOMPLETE))); - for sent in [paseo(), json!(null)] { - let partial = json!({"version": 1, "owner_pubkey": "a".repeat(64), "paseo": sent}); - assert_eq!( - check_partial(&partial), - Err(Invalid("Paseo pairing is managed by the Sandbox manager")) - ); - } -} diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 723b1543411..ba8b3f3bac5 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -2093,32 +2093,32 @@ 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. Before validation, adds a `paseo` section to a merged profile that has - none: `password` (32 random bytes, base64url), `server_id` (`srv_` and 9 - random bytes, base64url) and `daemon_keypair` (`{"v":2,"publicKeyB64", - "secretKeyB64"}`, a tweetnacl box keypair). A stored section is kept, so - pairings survive re-enrollment. A client never sends `paseo` - (`invalid_profile`). `paseo.gateway_key` is reserved for the daemon's - future delegated Buzz key. -2. After validation and the GitHub check, and before the profile write, - creates or replaces the Secret - `paseo-daemon-` in `buzz-paseo` with exactly these keys, which the - Deployment reads with `secretKeyRef`: +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`, `PASEO_SERVER_ID` | from `paseo` | - | `PASEO_DAEMON_KEYPAIR` | `paseo.daemon_keypair` as a JSON string | - | `BUZZ_PASEO_AGENTS` | the developer's registered agents, below | + | `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 | - | `BUZZ_PRIVATE_KEY` | `paseo.gateway_key`, when set (none yet) | - No other profile credential (GitHub, 1Password, API keys, the ChatGPT - broker login) reaches the daemon. A failure is `paseo_store_failed` and - leaves the profile unchanged, so a refusal never hides a stored change - (such as a new signing key) from the client; the retried enroll writes - both. If the profile write then fails, the retry rewrites the Secret. +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 @@ -2145,18 +2145,19 @@ 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; the daemon's secret key is never in a reply: +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="} + "offer_url": "https://app.paseo.sh/#offer=", + "gateway_pubkey": "<64 hex characters>"} ``` 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, update -and patch in `buzz-paseo`, and nothing else there. +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 diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index 266ba996f06..9176a5e5c9b 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -190,10 +190,9 @@ 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`, the manager adds a `paseo` section at the first enroll and keeps it on every later one, so paired Paseo apps survive re-enrollment: -`password` (32 random bytes, base64url), `server_id` (`srv_` and 9 random bytes, base64url, as Paseo generates it), and `daemon_keypair` (`{"v":2,"publicKeyB64","secretKeyB64"}`, a tweetnacl box keypair in standard base64). -Clients cannot send it. The manager copies it to the Secret `paseo-daemon-` in namespace `buzz-paseo` (keys `PASEO_PASSWORD`, `PASEO_SERVER_ID`, `PASEO_DAEMON_KEYPAIR`, the agent list `BUZZ_PASEO_AGENTS`, and the profile's Claude subscription or Codex access token), which is all the developer's daemon reads. -The bootstrap validates the section and does not use it. An optional `gateway_key` credential is reserved for the daemon's future Buzz key. +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 diff --git a/voltage/images/buzz-agent/profile.example.json b/voltage/images/buzz-agent/profile.example.json index 5d8aaeafc0f..99c17937449 100644 --- a/voltage/images/buzz-agent/profile.example.json +++ b/voltage/images/buzz-agent/profile.example.json @@ -15,14 +15,5 @@ "profile": "REPLACE_WITH_DEVELOPMENT_VIA_TOML", "op_token": "REPLACE_WITH_DEVELOPMENT_VAULT_SERVICE_ACCOUNT_TOKEN" }, - "models": {}, - "paseo": { - "password": "GENERATED_BY_THE_SANDBOX_MANAGER (32 random bytes, base64url)", - "server_id": "GENERATED_BY_THE_SANDBOX_MANAGER (srv_ and 9 random bytes, base64url)", - "daemon_keypair": { - "v": 2, - "publicKeyB64": "GENERATED_BY_THE_SANDBOX_MANAGER (X25519 public key, standard base64)", - "secretKeyB64": "GENERATED_BY_THE_SANDBOX_MANAGER (32 random bytes, standard base64)" - } - } + "models": {} } diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index b77da5f288f..e73709b948b 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -20,10 +20,6 @@ FIELDS = {'version', 'developer', 'owner_pubkey', 'git', 'github', 'via', 'models'} MODEL_FIELDS = {'openai_api_key', 'codex_access_token', 'anthropic_api_key', 'claude_oauth_token', 'openrouter_api_key'} -# Sections only the Sandbox manager writes. -MANAGED_FIELDS = {'codex_login', 'paseo'} -# The developer's Paseo daemon pairing; `gateway_key` is reserved for its future Buzz key. -PASEO_FIELDS = {'password', 'server_id', 'daemon_keypair'} # Inherited values would override or mix with the profile's selected accounts. INHERITED_MODEL_VARIABLES = ('OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'OPENROUTER_API_KEY', @@ -62,7 +58,7 @@ def token(value): def validate(profile): require(isinstance(profile, dict) and FIELDS <= set(profile) - and set(profile) <= FIELDS | MANAGED_FIELDS, 'Profile fields are incomplete or unsupported') + and set(profile) <= FIELDS | {'codex_login'}, 'Profile fields are incomplete or unsupported') require(type(profile['version']) is int and profile['version'] == 1, 'Unsupported profile version') require(isinstance(profile['developer'], str) and re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?', profile['developer']), 'Invalid developer ID') @@ -99,37 +95,9 @@ def validate(profile): token(value) require('codex_access_token' not in profile['models'] or profile['models']['codex_access_token'].startswith('at-'), 'Use a Codex access token (at-...)') - if 'paseo' in profile: - validate_paseo(profile['paseo']) require(len(json.dumps(profile).encode()) <= MAX_BYTES, 'Profile exceeds Secrets Manager size limit') -def nacl_key(value): - """Canonical, padded standard base64 of 32 bytes, as tweetnacl keys export.""" - try: - raw = base64.b64decode(value, validate=True) - except (ValueError, TypeError): - return False - return len(raw) == 32 and base64.b64encode(raw).decode() == value - - -def validate_paseo(paseo): - """The manager-generated pairing; the Sandbox does not use it, but must not refuse it.""" - require(isinstance(paseo, dict) and PASEO_FIELDS <= set(paseo) <= PASEO_FIELDS | {'gateway_key'}, - 'Profile fields are incomplete or unsupported') - require(isinstance(paseo['password'], str) - and re.fullmatch(r'[A-Za-z0-9_-]{43}', paseo['password']), 'Invalid Paseo pairing') - require(isinstance(paseo['server_id'], str) - and re.fullmatch(r'srv_[A-Za-z0-9_-]{12}', paseo['server_id']), 'Invalid Paseo pairing') - keypair = paseo['daemon_keypair'] - fields(keypair, {'v', 'publicKeyB64', 'secretKeyB64'}) - require(type(keypair['v']) is int and keypair['v'] == 2, 'Invalid Paseo pairing') - for key in (keypair['publicKeyB64'], keypair['secretKeyB64']): - require(isinstance(key, str) and nacl_key(key), 'Invalid Paseo pairing') - if 'gateway_key' in paseo: - token(paseo['gateway_key']) - - def validate_codex_login(login, developer): fields(login, {'url', 'ca_certificate', 'refresh_token'}) require(isinstance(login['url'], str), 'Invalid ChatGPT broker URL') diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index 0105c923ec0..1ecab3423f3 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -15,10 +15,6 @@ 'secrets.token="op://Fixture/Depot/credential"\n', 'op_token': 'synthetic-profile-op'}, 'models': {'openai_api_key': 'synthetic-profile-openai', 'anthropic_api_key': 'synthetic-profile-anthropic'}, - # The manager adds the developer's Paseo daemon pairing; the Sandbox must still start. - 'paseo': {'password': 'A' * 43, 'server_id': 'srv_AAECAwQFBgcI', - 'daemon_keypair': {'v': 2, 'publicKeyB64': 'hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=', - 'secretKeyB64': 'dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo='}}, } COMMON = r''' diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 91833be9b82..958414aa0e9 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -117,7 +117,7 @@ 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 | Profile; copied to the Secret `paseo-daemon-` in `buzz-paseo` | Your Paseo daemon | +| 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. @@ -365,13 +365,13 @@ Entering a Codex access token replaces a stored OpenAI API key, and entering a C 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, and keypair, stores them in your profile, and copies them to the daemon's Secret. +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. -Enrolling again keeps the pairing, so paired apps keep working. If the reply is **Refused (paseo_store_failed)**, the daemon's Secret was not written and your profile was not changed: enroll 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 From 22ea650694ffb3c14c7830f90ef1de300c2cb18a Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 13:19:04 -0500 Subject: [PATCH 4/5] fix(sandbox-manager): reserve the Cursor provider id Signed-off-by: Tee8z --- crates/buzz-sandbox-reaper/src/manager.rs | 5 ++--- crates/buzz-sandbox-reaper/src/paseo.rs | 1 + crates/buzz-sandbox-reaper/src/paseo_tests.rs | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/buzz-sandbox-reaper/src/manager.rs b/crates/buzz-sandbox-reaper/src/manager.rs index f27631e73b2..1435361ec57 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -50,9 +50,8 @@ 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 only after - /// deploying an agent image whose `profile.py` accepts the `paseo` - /// section, and the `buzz-paseo` namespace and Role. + /// 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, diff --git a/crates/buzz-sandbox-reaper/src/paseo.rs b/crates/buzz-sandbox-reaper/src/paseo.rs index 216d4d12aa1..7be29ee13a2 100644 --- a/crates/buzz-sandbox-reaper/src/paseo.rs +++ b/crates/buzz-sandbox-reaper/src/paseo.rs @@ -51,6 +51,7 @@ const RESERVED_IDS: &[&str] = &[ "claude", "codex", "copilot", + "cursor", "hoplite", "muse", "omp", diff --git a/crates/buzz-sandbox-reaper/src/paseo_tests.rs b/crates/buzz-sandbox-reaper/src/paseo_tests.rs index dce39451fe6..965ae9da9a4 100644 --- a/crates/buzz-sandbox-reaper/src/paseo_tests.rs +++ b/crates/buzz-sandbox-reaper/src/paseo_tests.rs @@ -148,6 +148,7 @@ fn agent_ids_are_valid_unique_and_never_reserved() { ("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()), @@ -165,6 +166,7 @@ fn agent_ids_are_valid_unique_and_never_reserved() { "brokkr", "brokkr-22222222", "claude-33333333", + "cursor-77777777", "n-code-only", "agent" ] From a35fdf9c9847df48a44156249c5f046e576819ab Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 13:49:23 -0500 Subject: [PATCH 5/5] fix(manager): advertise available Paseo provisioning on profile reads Signed-off-by: Tee8z --- crates/buzz-sandbox-reaper/src/enroll.rs | 5 ++++- crates/buzz-sandbox-reaper/src/enroll_tests.rs | 13 ++++++++++--- docs/remote-agents.md | 4 ++++ voltage/remote-agents.md | 2 ++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/buzz-sandbox-reaper/src/enroll.rs b/crates/buzz-sandbox-reaper/src/enroll.rs index 5a2eff8affc..99c7e0ed07f 100644 --- a/crates/buzz-sandbox-reaper/src/enroll.rs +++ b/crates/buzz-sandbox-reaper/src/enroll.rs @@ -557,6 +557,7 @@ impl Enrollment { 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 { @@ -572,7 +573,9 @@ impl Enrollment { } reply } - Ok(None) => serde_json::json!({"ok": true, "profile": null}), + 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 ab973dd9beb..98db94ab233 100644 --- a/crates/buzz-sandbox-reaper/src/enroll_tests.rs +++ b/crates/buzz-sandbox-reaper/src/enroll_tests.rs @@ -380,6 +380,11 @@ async fn paseo_pairing_is_kept_only_in_the_daemon_secret_and_returned() { 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. @@ -451,7 +456,9 @@ async fn paseo_pairing_is_kept_only_in_the_daemon_secret_and_returned() { "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); - assert_eq!(enrollment.describe(&developer()).await["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)); @@ -469,13 +476,13 @@ 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/docs/remote-agents.md b/docs/remote-agents.md index ba8b3f3bac5..00b08368d9a 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -2153,6 +2153,10 @@ Paseo app. Neither the daemon secret key nor the gateway secret key is returned: "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 diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 958414aa0e9..1917d259d1a 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -371,6 +371,8 @@ When an admin enables Paseo daemons, each developer has one always-on [Paseo](ht 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