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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 12 additions & 4 deletions crates/buzz-backend-kubernetes/src/sandbox_fake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-backend-kubernetes/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ pub enum ErrorCode {
GithubLoginMismatch,
/// The profile could not be written to the developer's secret.
ProfileStoreFailed,
/// The developer's Paseo daemon Secret could not be written: for
/// `enroll`, before the profile was stored, so it is unchanged; for
/// `deploy`, after the Sandbox started. Repeating the request retries it.
PaseoStoreFailed,
/// No saved launch exists for this owner, community and agent.
LaunchNotRegistered,
/// A saved launch could not be read or durably updated.
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-sandbox-reaper/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 79 additions & 7 deletions crates/buzz-sandbox-reaper/src/enroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -314,6 +315,9 @@ pub struct Enrollment {
github: GitHub,
store: Arc<dyn ProfileStore>,
broker: Option<Arc<Broker>>,
paseo: Option<DaemonSecrets>,
/// Serialize profile and daemon Secret updates, including without a broker.
lock: tokio::sync::Mutex<()>,
}

impl Enrollment {
Expand All @@ -322,6 +326,8 @@ impl Enrollment {
github,
store,
broker: None,
paseo: None,
lock: tokio::sync::Mutex::new(()),
}
}

Expand All @@ -330,6 +336,32 @@ impl Enrollment {
self
}

/// Provision pairing and a gateway key only in the daemon Secret.
pub fn with_paseo(mut self, secrets: DaemonSecrets) -> Self {
self.paseo = Some(secrets);
self
}

/// A launch registration of `developer` changed: list it in their Paseo
/// daemon's Secret. The registration is already saved, so the refusal
/// asks for a retry of the same deploy, which saves it again.
pub async fn agents_changed(&self, developer: &Developer) -> Result<(), Value> {
let Some(secrets) = &self.paseo else {
return Ok(());
};
secrets
.refresh_agents(developer)
.await
.map_err(|paseo::Failed(label)| {
refusal(
ErrorCode::PaseoStoreFailed,
&format!(
"the agent started, but its Paseo daemon secret was not updated ({label}); start it again"
),
)
})
}

/// The developer's stored profile, parsed. An unreadable stored value
/// counts as none, so the next enroll must send a complete profile.
async fn stored(&self, developer: &Developer) -> Result<Option<(Value, Stored)>, Value> {
Expand Down Expand Up @@ -364,6 +396,7 @@ impl Enrollment {
partial: Value,
login: Option<Change>,
) -> Value {
let _serialized = self.lock.lock().await;
let _guard = match &self.broker {
Some(broker) => match broker.lock(developer).await {
Ok(guard) => Some(guard),
Expand Down Expand Up @@ -458,6 +491,22 @@ impl Enrollment {
if let Err(profile::Invalid(message)) = profile::validate(&profile) {
return refusal(ErrorCode::InvalidProfile, message);
}
// Before the profile write, so a refusal always means the profile is
// unchanged: a client that sees one keeps what it sent (a new signing
// key) to send again. A Secret written for a profile that then fails
// to store holds only what this request carries, and its retry
// rewrites the Secret.
let pairing = if let Some(secrets) = &self.paseo {
match secrets.write(developer, &profile).await {
Ok(pairing) => Some(pairing),
Err(paseo::Failed(label)) => return refusal(
ErrorCode::PaseoStoreFailed,
&format!("could not write the Paseo daemon secret ({label}); the profile is unchanged, retry"),
),
}
} else {
None
};
// Revoke before changing the profile: stale Secrets Manager replicas
// may still serve its old broker credential, which must no longer work.
if !connects_codex && (disconnects_codex || replaces_codex) {
Expand Down Expand Up @@ -486,7 +535,12 @@ impl Enrollment {
}
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let summary = profile::summary(&profile, &version_id, Some(now));
serde_json::json!({"ok": true, "version_id": version_id, "profile": summary})
let mut reply =
serde_json::json!({"ok": true, "version_id": version_id, "profile": summary});
if let Some(pairing) = pairing {
reply["paseo"] = pairing;
}
reply
}
Err(StoreFailed(label)) => refusal(
ErrorCode::ProfileStoreFailed,
Expand All @@ -496,14 +550,32 @@ impl Enrollment {
}

/// The `profile` operation: the stored profile's [`profile::summary`],
/// or `null` when none is stored.
/// or `null` when none is stored, and its Paseo pairing details.
pub async fn describe(&self, developer: &Developer) -> Value {
match self.stored(developer).await {
Ok(Some((profile, stored))) => serde_json::json!({
"ok": true,
"profile": profile::summary(&profile, &stored.version_id, stored.created),
}),
Ok(None) => serde_json::json!({"ok": true, "profile": null}),
Ok(Some((profile, stored))) => {
let mut reply = serde_json::json!({
"ok": true,
"profile": profile::summary(&profile, &stored.version_id, stored.created),
"paseo_enabled": self.paseo.is_some(),
});
if let Some(secrets) = &self.paseo {
match secrets.pairing(developer).await {
Ok(Some(pairing)) => reply["paseo"] = pairing,
Ok(None) => {}
Err(paseo::Failed(label)) => {
return refusal(
ErrorCode::PaseoStoreFailed,
&format!("could not read the Paseo daemon secret ({label}); retry"),
)
}
}
}
reply
}
Ok(None) => {
serde_json::json!({"ok": true, "profile": null, "paseo_enabled": self.paseo.is_some()})
}
Err(refused) => refused,
}
}
Expand Down
166 changes: 164 additions & 2 deletions crates/buzz-sandbox-reaper/src/enroll_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -308,19 +311,178 @@ async fn a_first_enroll_must_send_the_complete_profile() {
assert!(written.lock().unwrap().is_empty());
}

/// The developer's daemon Secret in the fake cluster, by key, decoded.
fn daemon_secret(cluster: &sandbox_fake::Shared) -> Option<(Value, BTreeMap<String, String>)> {
let cluster = cluster.lock().unwrap();
let secret = cluster.secrets.get("paseo-daemon-dev")?;
let data = secret["data"]
.as_object()
.unwrap()
.iter()
.map(|(key, value)| {
let bytes = STANDARD.decode(value.as_str().unwrap()).unwrap();
(key.clone(), String::from_utf8(bytes).unwrap())
})
.collect();
Some((secret["metadata"].clone(), data))
}

/// The test relay's daemon Secrets, in `cluster`.
pub(crate) fn daemon_secrets(cluster: &sandbox_fake::Shared) -> DaemonSecrets {
DaemonSecrets::new(
sandbox_fake::client(cluster),
paseo::RELAY_ENDPOINT,
"wss://relay.example.invalid",
)
.unwrap()
}

/// With Paseo daemons on, the first enroll generates a pairing and stores
/// only in the narrow Secret. A failed daemon Secret write is reported, never a
/// silent success, and stores no profile. Later enrolls keep the pairing, so paired apps
/// survive, writes the Secret with the daemon's environment only (the
/// developer's registered agents, and of the model credentials only the
/// Claude subscription token), and returns the password and offer; so does
/// `profile`. Replacing the Secret works too.
#[tokio::test]
async fn paseo_pairing_is_kept_only_in_the_daemon_secret_and_returned() {
use crate::mobile_launch::Registration;
use nostr::nips::nip19::ToBech32;

let cluster = sandbox_fake::Shared::default();
let (owner, agent) = (
nostr::Keys::parse(&"33".repeat(32)).unwrap(),
nostr::Keys::parse(&"22".repeat(32)).unwrap(),
);
let channel = "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10";
let launch = json!({"relay_url": "wss://relay.example.invalid",
"private_key_nsec": agent.secret_key().to_bech32().unwrap(),
"launch": {"policy_env": {"BUZZ_ACP_DISPLAY_NAME": "Brokkr"}}});
Registration::new(owner.public_key(), agent.public_key(), launch)
.unwrap()
.in_channel(&json!({"sandbox": {"channel_id": channel, "thread_root": "b".repeat(64)}}))
.save(sandbox_fake::client(&cluster), "buzz-agent-staging-dev")
.await
.unwrap();
// Another community's registration is not this daemon's.
let elsewhere = nostr::Keys::parse(&"55".repeat(32)).unwrap();
Registration::new(
owner.public_key(),
elsewhere.public_key(),
json!({"relay_url": "wss://other.example.invalid",
"private_key_nsec": elsewhere.secret_key().to_bech32().unwrap()}),
)
.unwrap()
.in_channel(&json!({"sandbox": {"channel_id": channel, "thread_root": "b".repeat(64)}}))
.save(sandbox_fake::client(&cluster), "buzz-agent-staging-dev")
.await
.unwrap();
cluster.lock().unwrap().fail_secret_creates = 1;
let (enrollment, written) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await;
let enrollment = enrollment.with_paseo(daemon_secrets(&cluster));
assert_eq!(
enrollment.describe(&developer()).await["paseo_enabled"],
true,
"enabled provisioning is advertised before a daemon Secret exists"
);

// A refusal leaves the profile unchanged, so a client that keeps what
// it sent (a new signing key) for its retry never strands a stored one.
let response = enrollment.run(&developer(), profile()).await;
assert_eq!(response["code"], "paseo_store_failed", "{response}");
assert!(response["error"].as_str().unwrap().contains("unchanged"));
assert!(written.lock().unwrap().is_empty());
assert!(daemon_secret(&cluster).is_none());
let response = enrollment.run(&developer(), profile()).await;
assert_eq!(response["version_id"], "version-1", "{response}");
assert!(stored(&written).get("paseo").is_none());
let initial = daemon_secret(&cluster).unwrap().1;
let pairing = json!({"password": initial["PASEO_PASSWORD"], "server_id": initial["PASEO_SERVER_ID"],
"daemon_keypair": serde_json::from_str::<Value>(&initial["PASEO_DAEMON_KEYPAIR"]).unwrap()});
assert_eq!(
daemon_secret(&cluster).unwrap().1["PASEO_PASSWORD"],
pairing["password"].as_str().unwrap()
);

// A failed Secret read must not generate a new identity or write a profile.
cluster.lock().unwrap().fail_secret_reads = 1;
let refused = enrollment.run(&developer(), profile()).await;
assert_eq!(refused["code"], "paseo_store_failed", "{refused}");
assert_eq!(written.lock().unwrap().len(), 1);
assert_eq!(daemon_secret(&cluster).unwrap().1, initial);

let partial = json!({"version": 1, "developer": "dev", "owner_pubkey": "a".repeat(64),
"models": {"claude_oauth_token": "sk-ant-oat01-fixture"}});
for version in ["version-2", "version-3"] {
let response = enrollment.run(&developer(), partial.clone()).await;
assert_eq!(response["version_id"], version, "{response}");
assert!(stored(&written).get("paseo").is_none());
let (metadata, env) = daemon_secret(&cluster).unwrap();
assert_eq!(metadata["namespace"], "buzz-paseo");
let agents = json!([{"id": "brokkr", "label": "Brokkr",
"agent": agent.public_key().to_hex(), "channel": channel}]);
assert_eq!(
env,
BTreeMap::from([
(
"PASEO_PASSWORD".into(),
pairing["password"].as_str().unwrap().into()
),
(
"PASEO_SERVER_ID".into(),
pairing["server_id"].as_str().unwrap().into()
),
(
"PASEO_DAEMON_KEYPAIR".into(),
pairing["daemon_keypair"].to_string()
),
("BUZZ_PASEO_AGENTS".into(), agents.to_string()),
(
"BUZZ_PRIVATE_KEY".into(),
initial["BUZZ_PRIVATE_KEY"].clone()
),
(
"CLAUDE_CODE_OAUTH_TOKEN".into(),
"sk-ant-oat01-fixture".into()
),
])
);
let offer = crate::paseo::offer_url(
pairing["server_id"].as_str().unwrap(),
pairing["daemon_keypair"]["publicKeyB64"].as_str().unwrap(),
paseo::RELAY_ENDPOINT,
);
let expected = json!({"password": pairing["password"],
"server_id": pairing["server_id"], "offer_url": offer,
"gateway_pubkey": nostr::Keys::parse(&initial["BUZZ_PRIVATE_KEY"]).unwrap().public_key().to_hex()});
assert_eq!(response["paseo"], expected);
let described = enrollment.describe(&developer()).await;
assert_eq!(described["paseo"], expected);
assert_eq!(described["paseo_enabled"], true);
// The daemon's secret key stays out of every reply.
let secret = pairing["daemon_keypair"]["secretKeyB64"].as_str().unwrap();
assert!(!response.to_string().contains(secret));
assert!(!response.to_string().contains(&initial["BUZZ_PRIVATE_KEY"]));
assert!(!stored(&written).to_string().contains(secret));
assert!(!stored(&written)
.to_string()
.contains(&initial["BUZZ_PRIVATE_KEY"]));
}
}

/// `profile` describes the stored profile, naming no credential value.
#[tokio::test]
async fn describe_names_what_is_stored_and_no_credential() {
let (enrollment, _) = enrollment(&[("ghp_fixtureGitHubTokenDoNotLeak", "ada-l")]).await;
assert_eq!(
enrollment.describe(&developer()).await,
json!({"ok": true, "profile": null})
json!({"ok": true, "profile": null, "paseo_enabled": false})
);
enrollment.run(&developer(), profile()).await;
let described = enrollment.describe(&developer()).await;
assert_eq!(
described,
json!({"ok": true, "profile": {
json!({"ok": true, "paseo_enabled": false, "profile": {
"version_id": "version-1",
"updated_at": "2026-10-23T12:00:00Z",
"git": {"name": "Ada Lovelace", "email": "ada@example.com", "signing_key": true},
Expand Down
Loading
Loading