diff --git a/crates/buzz-acp/src/gateway.rs b/crates/buzz-acp/src/gateway.rs new file mode 100644 index 00000000000..2948b96c2b3 --- /dev/null +++ b/crates/buzz-acp/src/gateway.rs @@ -0,0 +1,205 @@ +//! Owner-delegated gateway keys, as the harness accepts them. +//! +//! A gateway is a separate key the agent's owner names in a signed, expiring +//! kind 30181 head (see `buzz_sdk::gateway` and `docs/agent-gateway-key.md`). +//! The harness accepts a live gateway in exactly three places, and only when +//! it runs in strict owner mode (`--respond-to owner`, which every remote +//! channel-thread sandbox session uses), with an immutable thread binding: +//! direct-message and unbound sessions never enable gateway access. +//! +//! 1. channel events at the inbound author gate (never DMs), rechecked again +//! when a queued turn reaches model dispatch; +//! 2. `cancel_turn` observer control frames (no other control command); +//! 3. observer telemetry, which is also encrypted to each live gateway. +//! +//! Every lookup fails closed. Verdicts are cached for [`VERDICT_TTL`] so a +//! revocation takes effect on the next lookup after that, and a failure is +//! remembered for [`FAILURE_RETRY`] so a relay outage cannot turn every event +//! into an inline relay round-trip. + +use std::time::Duration; + +use nostr::{Event, PublicKey}; +use serde_json::{json, Value}; +use tokio::time::Instant; + +use crate::relay::RestClient; + +/// Longest a successful lookup is reused, which bounds revocation latency. +pub(crate) const VERDICT_TTL: Duration = Duration::from_secs(30); +/// Delay before a failed lookup is retried. Until then, gateways are refused. +pub(crate) const FAILURE_RETRY: Duration = Duration::from_secs(5); +/// Bound on one complete lookup (delegation heads plus profiles). +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); + +/// A delegation lookup failed; callers refuse the gateway. +#[derive(Debug, Clone, thiserror::Error)] +#[error("gateway delegation lookup failed: {0}")] +pub(crate) struct LookupError(String); + +enum Cached { + Empty, + Fresh { + at: Instant, + gateways: Vec, + }, + Failed { + at: Instant, + error: LookupError, + }, +} + +/// The owner's live gateways for one agent, from the relay, with a bounded +/// cache. +/// +/// The cache mutex is held across the relay lookup. Lookups are therefore +/// single-flight: a slower, older lookup can never overwrite a newer result. +pub(crate) struct GatewayAccess { + owner: PublicKey, + agent: PublicKey, + channel: uuid::Uuid, + rest: RestClient, + cached: tokio::sync::Mutex, +} + +impl GatewayAccess { + /// Gateway access for `agent`, owned by `owner`, read through `rest`. + pub(crate) fn new( + owner: PublicKey, + agent: PublicKey, + rest: RestClient, + channel: uuid::Uuid, + ) -> Self { + Self { + owner, + agent, + channel, + rest, + cached: tokio::sync::Mutex::new(Cached::Empty), + } + } + + /// The live gateways now, newest delegation first, at most + /// [`buzz_sdk::gateway::MAX_LIVE_GATEWAYS`]. + pub(crate) async fn live(&self) -> Result, LookupError> { + let mut cached = self.cached.lock().await; + let reuse = match &*cached { + Cached::Fresh { at, .. } => at.elapsed() < VERDICT_TTL, + Cached::Failed { at, .. } => at.elapsed() < FAILURE_RETRY, + Cached::Empty => false, + }; + if !reuse { + // Age successful results from lookup start so network latency + // cannot extend the authority cache beyond the documented TTL. + let lookup_started = Instant::now(); + *cached = match tokio::time::timeout(LOOKUP_TIMEOUT, self.lookup()).await { + Ok(Ok(gateways)) => Cached::Fresh { + at: lookup_started, + gateways, + }, + Ok(Err(error)) => Cached::Failed { + at: Instant::now(), + error, + }, + Err(_) => Cached::Failed { + at: Instant::now(), + error: LookupError("timed out".into()), + }, + }; + } + let now = unix_now(); + match &*cached { + // A cached delegation never outlives its own expiration. + Cached::Fresh { gateways, .. } => Ok(gateways + .iter() + .filter(|live| live.is_live_at(now)) + .map(|live| live.gateway()) + .collect()), + Cached::Failed { error, .. } => Err(error.clone()), + Cached::Empty => Err(LookupError("no lookup".into())), + } + } + + /// Whether `key` is a live gateway of this agent's owner now. + pub(crate) async fn is_live(&self, key: &PublicKey) -> Result { + if *key == self.owner || *key == self.agent { + return Ok(false); + } + Ok(self.live().await?.contains(key)) + } + + async fn lookup(&self) -> Result, LookupError> { + let now = unix_now(); + let heads = self + .query(json!({ + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [self.owner.to_hex()], + "limit": buzz_sdk::gateway::LOOKUP_LIMIT, + })) + .await?; + let candidates: Vec = buzz_sdk::gateway::live_heads(self.owner, &heads, now) + .iter() + .map(|live| live.gateway().to_hex()) + .collect(); + if candidates.is_empty() { + return Ok(Vec::new()); + } + let profiles = self + .query(json!({ + "kinds": [0], + "authors": candidates, + "limit": candidates.len(), + })) + .await?; + let relay = self + .rest + .relay_self() + .await + .map_err(|error| LookupError(error.to_string()))? + .and_then(|key| PublicKey::from_hex(&key).ok()) + .ok_or_else(|| LookupError("relay identity unavailable".into()))?; + let rosters = self + .query(json!({"kinds":[39002], "authors":[relay], + "#d":[self.channel], "limit":1})) + .await?; + if rosters.len() > 1 { + return Err(LookupError("ambiguous channel membership".into())); + } + let Some(roster) = rosters.first() else { + return Ok(Vec::new()); + }; + let member = |key| buzz_sdk::gateway::channel_member(roster, relay, self.channel, key); + if !member(self.owner) || !member(self.agent) { + return Ok(Vec::new()); + } + Ok(buzz_sdk::gateway::resolve( + self.owner, + &heads, + &profiles, + &[self.owner, self.agent], + now, + ) + .into_iter() + .filter(|live| member(live.gateway())) + .collect()) + } + + /// One `/query`, refusing any response that is not an array of events. + async fn query(&self, filter: Value) -> Result, LookupError> { + let mut filter = filter; + filter["consistency"] = json!("strong"); + let response = self + .rest + .query_raw(&[filter]) + .await + .map_err(|error| LookupError(error.to_string()))?; + serde_json::from_value(response).map_err(|_| LookupError("malformed relay response".into())) + } +} + +fn unix_now() -> u64 { + nostr::Timestamp::now().as_secs() +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/buzz-acp/src/gateway/tests.rs b/crates/buzz-acp/src/gateway/tests.rs new file mode 100644 index 00000000000..a0f88568a05 --- /dev/null +++ b/crates/buzz-acp/src/gateway/tests.rs @@ -0,0 +1,755 @@ +//! A loopback `/query` relay that serves kind 30181 heads and kind:0 profiles, +//! so gateway checks run through the production [`RestClient`] seam. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use nostr::{Event, EventBuilder, Keys, Kind}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::*; + +/// Fake relay state shared with the serving task. +pub(crate) struct FakeRelay { + pub(crate) heads: Arc>>, + pub(crate) profiles: Arc>>, + pub(crate) fail: Arc, + pub(crate) queries: Arc, + pub(crate) rest: RestClient, + pub(crate) members: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl FakeRelay { + fn admit(&self, members: &[PublicKey]) { + *self.members.lock().unwrap() = members.to_vec(); + } +} + +fn channel() -> uuid::Uuid { + "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10".parse().unwrap() +} + +impl Drop for FakeRelay { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// Start a fake relay; `/query` answers 403 while `fail` is set. +pub(crate) async fn fake_relay() -> FakeRelay { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let heads = Arc::new(Mutex::new(Vec::::new())); + let profiles = Arc::new(Mutex::new(Vec::::new())); + let members = Arc::new(Mutex::new(Vec::::new())); + let fail = Arc::new(AtomicBool::new(false)); + let queries = Arc::new(AtomicUsize::new(0)); + let task = { + let (heads, profiles, fail, queries, members) = ( + heads.clone(), + profiles.clone(), + fail.clone(), + queries.clone(), + members.clone(), + ); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + // Read headers plus the Content-Length body. + while let Ok(read) = socket.read(&mut buffer).await { + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + let text = String::from_utf8_lossy(&request).to_string(); + if let Some(split) = text.find("\r\n\r\n") { + let length = text[..split] + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + if request.len() >= split + 4 + length { + break; + } + } + } + queries.fetch_add(1, Ordering::SeqCst); + let text = String::from_utf8_lossy(&request).to_string(); + let body = text.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or(""); + let filter: Value = serde_json::from_str::>(body) + .ok() + .and_then(|filters| filters.into_iter().next()) + .unwrap_or_default(); + let gateway_profile_read = filter["authors"].as_array().is_some_and(|authors| { + heads.lock().unwrap().iter().any(|head| { + buzz_sdk::gateway::Delegation::parse(head) + .is_ok_and(|(grant, _)| authors.contains(&json!(grant.gateway))) + }) + }); + if filter["kinds"] == json!([30181]) + || filter["kinds"] == json!([39002]) + || gateway_profile_read + { + assert_eq!( + filter["consistency"], + json!("strong"), + "authority reads must bypass replicas" + ); + } + let (status, payload) = if fail.load(Ordering::SeqCst) { + ("403 Forbidden", "{}".to_string()) + } else if text.starts_with("GET ") { + let relay = Keys::parse(&"11".repeat(32)).unwrap(); + ("200 OK", json!({"self":relay.public_key()}).to_string()) + } else if filter["kinds"] == json!([39002]) { + let relay = Keys::parse(&"11".repeat(32)).unwrap(); + let mut tags = vec![nostr::Tag::parse(["d", &channel().to_string()]).unwrap()]; + tags.extend( + members.lock().unwrap().iter().map(|key| { + nostr::Tag::parse(["p", &key.to_hex(), "", "member"]).unwrap() + }), + ); + let roster = EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .sign_with_keys(&relay) + .unwrap(); + ("200 OK", serde_json::to_string(&[roster]).unwrap()) + } else if filter["kinds"] == json!([30181]) { + ( + "200 OK", + serde_json::to_string(&*heads.lock().unwrap()).unwrap(), + ) + } else if filter["kinds"] == json!([0]) { + ( + "200 OK", + serde_json::to_string(&*profiles.lock().unwrap()).unwrap(), + ) + } else { + ("200 OK", "[]".to_string()) + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}", + payload.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }) + }; + FakeRelay { + heads, + profiles, + members, + fail, + queries, + rest: RestClient { + http: reqwest::Client::new(), + base_url, + keys: Keys::generate(), + auth_tag_json: None, + }, + task, + } +} + +/// An owner-signed grant for `gateway`, created `age` seconds ago. +pub(crate) fn grant(owner: &Keys, gateway: PublicKey, age: u64) -> Event { + let now = unix_now(); + buzz_sdk::gateway::Delegation::grant(gateway, now + 3_600, now - age) + .unwrap() + .sign_with_keys(owner) + .unwrap() +} + +/// An owner-signed revocation for `gateway`, created `age` seconds ago. +pub(crate) fn revoke(owner: &Keys, gateway: PublicKey, age: u64) -> Event { + buzz_sdk::gateway::Delegation::revoke(gateway, unix_now() - age) + .unwrap() + .sign_with_keys(owner) + .unwrap() +} + +/// The cached verdict is reused within the TTL, a revocation lands after it, +/// failures are refused and throttled, and agent keys never pass. +/// Mutations: drop the TTL reuse → the query count assertion fails; drop the +/// failure cache → the throttled-failure query count fails; drop the +/// `is_live_at` filter → the expired-cache assertion fails; drop the owner or +/// agent exclusion → their assertions fail. +#[tokio::test] +async fn gateway_access_is_cached_bounded_and_fails_closed() { + let relay = fake_relay().await; + let (owner, agent, gateway, sibling) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + let access = GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + ); + let gw = gateway.public_key(); + + assert!(!access.is_live(&gw).await.unwrap(), "no delegation yet"); + relay.heads.lock().unwrap().push(grant(&owner, gw, 10)); + assert!( + !access.is_live(&gw).await.unwrap(), + "the empty verdict is reused within the TTL" + ); + assert_eq!(relay.queries.load(Ordering::SeqCst), 1); + skip(VERDICT_TTL).await; + assert!( + access.is_live(&gw).await.unwrap(), + "grant seen after the TTL" + ); + assert!(!access.is_live(&owner.public_key()).await.unwrap()); + assert!(!access.is_live(&agent.public_key()).await.unwrap()); + + // A newer revocation wins after the TTL. + relay.heads.lock().unwrap().push(revoke(&owner, gw, 5)); + skip(VERDICT_TTL).await; + assert!(!access.is_live(&gw).await.unwrap(), "revoked"); + + // An agent key the owner names is refused by its kind:0 attestation. + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, sibling.public_key(), 1)); + let auth = buzz_sdk::nip_oa::compute_auth_tag(&owner, &sibling.public_key(), "").unwrap(); + relay.profiles.lock().unwrap().push( + EventBuilder::new(Kind::Metadata, "{}") + .tag(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()) + .sign_with_keys(&sibling) + .unwrap(), + ); + skip(VERDICT_TTL).await; + assert!(!access.is_live(&sibling.public_key()).await.unwrap()); + + // Failure: refused, and not retried before FAILURE_RETRY. + relay.fail.store(true, Ordering::SeqCst); + skip(VERDICT_TTL).await; + assert!(access.is_live(&gw).await.is_err()); + let after_failure = relay.queries.load(Ordering::SeqCst); + assert!(access.is_live(&gw).await.is_err()); + assert_eq!(relay.queries.load(Ordering::SeqCst), after_failure); + + // Recovery after the failure backoff. + relay.fail.store(false, Ordering::SeqCst); + relay.heads.lock().unwrap().clear(); + relay.heads.lock().unwrap().push(grant(&owner, gw, 1)); + skip(FAILURE_RETRY).await; + assert!(access.is_live(&gw).await.unwrap()); + + // Even a still-fresh cache cannot extend the signed expiration. + let now = unix_now(); + let expired = buzz_sdk::gateway::Delegation::grant(gw, now, now - 100) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + *access.cached.lock().await = Cached::Fresh { + at: tokio::time::Instant::now(), + gateways: buzz_sdk::gateway::live_heads(owner.public_key(), &[expired], now - 1), + }; + assert!( + !access.is_live(&gw).await.unwrap(), + "a cached grant expired" + ); +} + +/// Advance the tokio clock without letting paused time fire IO timeouts. +pub(crate) async fn skip(duration: Duration) { + tokio::time::pause(); + tokio::time::advance(duration).await; + tokio::time::resume(); +} + +fn channel_message(signer: &Keys, agent: PublicKey) -> Event { + EventBuilder::new(Kind::Custom(9), "please run the tests") + .tags([ + nostr::Tag::parse(["h", "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10"]).unwrap(), + nostr::Tag::public_key(agent), + ]) + .sign_with_keys(signer) + .unwrap() +} + +/// Thread ingress: strict owner mode admits the owner and a live gateway in +/// channels; never in DMs, never after revocation, never on lookup failure, +/// and never in modes that do not enable gateways. +/// Mutations: drop the gateway branch → "gateway in channel" fails; drop the +/// `!is_dm` guard → "gateway in DM" fails; drop the `RespondTo::Owner` guard +/// → the owner-only assertion fails. +#[tokio::test] +async fn author_gate_admits_a_live_gateway_only_in_owner_mode_channels() { + let relay = fake_relay().await; + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + let access = Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + )); + let mut gate = + crate::InboundAuthorGate::connect(&relay.rest, &agent.public_key().to_hex(), "test").await; + gate.enable_gateways(Some(access)); + let cache = crate::OwnerCache::new(Some(owner.public_key().to_hex())); + let allowlist = std::collections::HashSet::new(); + let owner_mode = crate::RespondTo::Owner; + let allowed = |signer: &Keys, mode: crate::RespondTo, is_dm: bool| { + let event = channel_message(signer, agent.public_key()); + let (gate, cache, allowlist, rest) = (&gate, &cache, &allowlist, &relay.rest); + async move { + gate.evaluate_for_test(&event, &mode, allowlist, is_dm, cache, rest) + .await + .allowed + } + }; + + assert!(allowed(&owner, owner_mode.clone(), false).await, "owner"); + assert!( + allowed(&gateway, owner_mode.clone(), false).await, + "gateway in channel" + ); + assert!( + !allowed(&gateway, owner_mode.clone(), true).await, + "gateway in DM" + ); + assert!( + !allowed(&stranger, owner_mode.clone(), false).await, + "stranger" + ); + assert!( + !allowed(&gateway, crate::RespondTo::OwnerOnly, false).await, + "gateways are honoured only in strict owner mode" + ); + + relay + .heads + .lock() + .unwrap() + .push(revoke(&owner, gateway.public_key(), 5)); + skip(VERDICT_TTL).await; + assert!( + !allowed(&gateway, owner_mode.clone(), false).await, + "revoked" + ); + + relay.fail.store(true, Ordering::SeqCst); + skip(VERDICT_TTL).await; + assert!( + !allowed(&gateway, owner_mode.clone(), false).await, + "lookup failure" + ); + assert!(allowed(&owner, owner_mode, false).await, "owner unaffected"); +} + +fn control_frame(sender: &Keys, agent: &Keys, command: &str) -> Event { + let content = crate::encrypt_observer_payload( + sender, + &agent.public_key(), + &serde_json::json!({"type": command, "channelId": channel()}), + ) + .unwrap(); + buzz_sdk::build_agent_observer_frame( + &agent.public_key().to_hex(), + &agent.public_key().to_hex(), + buzz_core::observer::OBSERVER_FRAME_CONTROL, + &content, + ) + .unwrap() + .sign_with_keys(sender) + .unwrap() +} + +/// Observer control: a live gateway may `cancel_turn` and nothing else; an +/// undelegated, revoked, or unverifiable sender is refused. +/// Mutations: drop the cancel-only restriction → "gateway switch_model" +/// fails; accept any non-owner sender → "stranger" fails; drop the gateway +/// lookup in `delegated_control_sender` → "gateway cancel" fails. +#[tokio::test] +async fn observer_control_from_a_gateway_is_cancel_only() { + let relay = fake_relay().await; + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + let access = GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + ); + let owner_hex = owner.public_key().to_hex(); + let accepted = |sender: &Keys, command: &str| { + let frame = control_frame(sender, &agent, command); + let (access, owner_hex, agent) = (&access, &owner_hex, &agent); + async move { + let gateway = crate::delegated_control_sender(Some(access), &frame, owner_hex).await; + crate::authorized_control_payload(agent, &frame, owner_hex, gateway).is_some() + } + }; + + assert!(accepted(&owner, "cancel_turn").await, "owner cancel"); + assert!(accepted(&owner, "switch_model").await, "owner switch_model"); + assert!(accepted(&gateway, "cancel_turn").await, "gateway cancel"); + assert!( + !accepted(&gateway, "switch_model").await, + "gateway switch_model" + ); + assert!( + !accepted(&gateway, "publish_project_owner_announcements").await, + "gateway announcements" + ); + assert!(!accepted(&stranger, "cancel_turn").await, "stranger"); + + relay + .heads + .lock() + .unwrap() + .push(revoke(&owner, gateway.public_key(), 5)); + skip(VERDICT_TTL).await; + assert!(!accepted(&gateway, "cancel_turn").await, "revoked gateway"); +} + +/// Telemetry: while a delegation is live each frame is encrypted to the owner +/// and the gateway, and the extra frame is paid for by an idle tick so the +/// relay budget stays at one frame per tick. +/// Mutations: drop the gateway recipients → tick 1 has one frame; drop the +/// idle tick → tick 2 publishes. +#[tokio::test] +async fn telemetry_reaches_live_gateways_within_the_frame_budget() { + let relay = fake_relay().await; + let (owner, agent, gateway) = (Keys::generate(), Keys::generate(), Keys::generate()); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + let access = Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + )); + // Prime the cache in real time; the paced loop below runs on paused time. + assert_eq!(access.live().await.unwrap(), vec![gateway.public_key()]); + tokio::time::pause(); + + let observer = crate::observer::ObserverHandle::in_process(); + for (channel, marker) in [(uuid::Uuid::new_v4(), "a"), (uuid::Uuid::new_v4(), "b")] { + observer.emit( + "test_event", + None, + &crate::observer::context_for(Some(channel), None, None), + serde_json::json!({"marker": marker}), + ); + } + let (publisher, mut published) = crate::relay::RelayEventPublisher::test_pair(); + let rx = observer.subscribe(); + let task = tokio::spawn(crate::run_relay_observer_publisher( + observer.snapshot(), + rx, + publisher, + agent.clone(), + agent.public_key().to_hex(), + owner.public_key(), + Some(access), + )); + // Let the publisher install its pacer before time moves. + for _ in 0..64 { + tokio::task::yield_now().await; + } + let first = tick(&mut published).await; + assert_eq!(first.len(), 2, "owner and gateway copies"); + let recipients: Vec = first + .iter() + .map(|frame| { + let p = frame + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .unwrap(); + PublicKey::from_hex(&p.as_slice()[1]).unwrap() + }) + .collect(); + assert_eq!(recipients, vec![owner.public_key(), gateway.public_key()]); + let decoded: Value = crate::decrypt_observer_payload(&gateway, &first[1]).unwrap(); + assert!(decoded.is_object(), "the gateway can read its copy"); + assert!( + tick(&mut published).await.is_empty(), + "idle tick pays for the gateway copy" + ); + assert_eq!( + tick(&mut published).await.len(), + 2, + "next frame, again to both" + ); + task.abort(); +} + +async fn tick(published: &mut tokio::sync::mpsc::Receiver) -> Vec { + // The extra millisecond covers the timer wheel rounding a deadline that + // was registered before the clock was paused. + tokio::time::advance(crate::OBSERVER_PUBLISH_TICK + Duration::from_millis(1)).await; + for _ in 0..64 { + tokio::task::yield_now().await; + } + let mut frames = Vec::new(); + while let Ok(frame) = published.try_recv() { + frames.push(frame); + } + frames +} + +/// Model dispatch rechecks a queued gateway turn: a revoked delegation or an +/// unverifiable lookup refuses it, the owner always passes, and a stranger is +/// refused without sharing. +/// Mutations: skip the gateway recheck → "revoked" passes; treat lookup +/// errors as live → "lookup failure" passes. +#[tokio::test] +async fn queued_gateway_turns_are_rechecked_at_dispatch() { + let relay = fake_relay().await; + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + let mut ctx = crate::pool::tests::make_prompt_context_no_owner(); + ctx.agent_owner_pubkey = Some(owner.public_key()); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + ctx.gateways = Some(Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + ))); + let channel = uuid::Uuid::new_v4(); + let batch = |signer: &Keys| crate::queue::FlushBatch { + channel_id: channel, + scope: crate::scope::SessionScope::Thread { + channel_id: channel, + root_event_id: "a".repeat(64), + }, + events: vec![crate::queue::BatchEvent { + event: channel_message(signer, agent.public_key()), + prompt_tag: "@mention".into(), + received_at: std::time::Instant::now(), + edit: None, + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let refused = |signer: &Keys| { + let pending = batch(signer); + let ctx = &ctx; + async move { crate::pool::dispatch_refusal(ctx, &pending).await.is_some() } + }; + + assert!(!refused(&owner).await, "owner"); + assert!(!refused(&gateway).await, "live gateway"); + assert!(refused(&stranger).await, "stranger without sharing"); + relay + .heads + .lock() + .unwrap() + .push(revoke(&owner, gateway.public_key(), 5)); + skip(VERDICT_TTL).await; + assert!(refused(&gateway).await, "revoked"); + relay.heads.lock().unwrap().pop(); + relay.fail.store(true, Ordering::SeqCst); + skip(VERDICT_TTL).await; + assert!(refused(&gateway).await, "lookup failure"); +} + +/// The same factory wires ingress, dispatch, observer control and telemetry. +/// A DM or unbound harness must never create that shared gateway capability. +#[tokio::test] +async fn gateway_factory_admits_only_bound_owner_channel_threads() { + let relay = fake_relay().await; + let owner = Keys::generate().public_key().to_hex(); + let thread = crate::config::BoundSession::Thread { + channel_id: uuid::Uuid::new_v4(), + thread_root: "a".repeat(64), + }; + let dm = crate::config::BoundSession::DirectMessage { + dm_channel_id: uuid::Uuid::new_v4(), + }; + for (binding, mode, owner_hex, expected) in [ + ( + Some(thread.clone()), + crate::RespondTo::Owner, + Some(owner.as_str()), + true, + ), + ( + Some(dm), + crate::RespondTo::Owner, + Some(owner.as_str()), + false, + ), + (None, crate::RespondTo::Owner, Some(owner.as_str()), false), + ( + Some(thread.clone()), + crate::RespondTo::OwnerOnly, + Some(owner.as_str()), + false, + ), + (Some(thread), crate::RespondTo::Owner, None, false), + ] { + let mut config = crate::build_mcp_servers_tests::test_config(); + config.bound_session = binding; + config.respond_to = mode; + assert_eq!( + crate::gateway_access_for(&config, owner_hex, relay.rest.clone()).is_some(), + expected + ); + } +} + +/// A global grant never grants a private channel. Joining enables telemetry, +/// prompts and cancellation; removing membership disables all three on refresh. +#[tokio::test] +async fn channel_membership_gates_gateway_telemetry_prompts_and_cancellation() { + let relay = fake_relay().await; + let (owner, agent, gateway) = (Keys::generate(), Keys::generate(), Keys::generate()); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + relay.admit(&[owner.public_key(), agent.public_key()]); + let access = Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + )); + let mut gate = + crate::InboundAuthorGate::connect(&relay.rest, &agent.public_key().to_hex(), "test").await; + gate.enable_gateways(Some(access.clone())); + let owner_hex = owner.public_key().to_hex(); + let owner_cache = crate::OwnerCache::new(Some(owner_hex.clone())); + let allowlist = std::collections::HashSet::new(); + for allowed in [false, true, false] { + if allowed { + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + } else { + relay.admit(&[owner.public_key(), agent.public_key()]); + } + skip(VERDICT_TTL).await; + assert_eq!( + access.is_live(&gateway.public_key()).await.unwrap(), + allowed + ); + let prompt = channel_message(&gateway, agent.public_key()); + assert_eq!( + gate.evaluate_for_test( + &prompt, + &crate::RespondTo::Owner, + &allowlist, + false, + &owner_cache, + &relay.rest + ) + .await + .allowed, + allowed + ); + let control = control_frame(&gateway, &agent, "cancel_turn"); + let delegated = crate::delegated_control_sender(Some(&access), &control, &owner_hex).await; + assert_eq!( + crate::authorized_control_payload(&agent, &control, &owner_hex, delegated).is_some(), + allowed + ); + let recipients = membership_telemetry(access.clone(), &owner, &agent).await; + let expected = if allowed { + vec![owner.public_key(), gateway.public_key()] + } else { + vec![owner.public_key()] + }; + assert_eq!(recipients, expected); + } +} + +async fn membership_telemetry( + access: Arc, + owner: &Keys, + agent: &Keys, +) -> Vec { + let observer = crate::observer::ObserverHandle::in_process(); + observer.emit( + "test_event", + None, + &crate::observer::context_for(Some(channel()), None, None), + serde_json::json!({"state":"private"}), + ); + let (publisher, mut published) = crate::relay::RelayEventPublisher::test_pair(); + let task = tokio::spawn(crate::run_relay_observer_publisher( + observer.snapshot(), + observer.subscribe(), + publisher, + agent.clone(), + agent.public_key().to_hex(), + owner.public_key(), + Some(access), + )); + let first = tokio::time::timeout(Duration::from_secs(2), published.recv()) + .await + .unwrap() + .unwrap(); + let mut frames = vec![first]; + if let Ok(Some(second)) = + tokio::time::timeout(crate::OBSERVER_PUBLISH_TICK * 2, published.recv()).await + { + frames.push(second); + } + task.abort(); + frames + .into_iter() + .map(|frame| { + frame + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .and_then(|tag| PublicKey::from_hex(&tag.as_slice()[1]).ok()) + .unwrap() + }) + .collect() +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 368e72e7f00..702e14f78e9 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -11,6 +11,7 @@ mod config; mod edit_routing; mod engram_fetch; mod filter; +mod gateway; mod isolated_execution; mod observer; mod pool; @@ -570,6 +571,7 @@ mod inbound_author_gate { // None means no authoritative NIP-11 result yet, including at startup. refreshed_generation: Option, sharing: Option>, + gateways: Option>, } pub(crate) fn refresh_needed(refreshed_generation: Option, event_generation: u64) -> bool { @@ -589,6 +591,7 @@ mod inbound_author_gate { relay_self, refreshed_generation: completed.then_some(0), sharing: None, + gateways: None, } } @@ -660,7 +663,7 @@ mod inbound_author_gate { } else { effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex) }; - let allowed = author_allowed( + let mut allowed = author_allowed( respond_to, allowlist, &effective_author, @@ -669,6 +672,23 @@ mod inbound_author_gate { rest_client, ) .await; + // Strict owner mode also admits a live owner-delegated gateway, in + // channels only. The raw signer is the effective author here, and + // the owner's DMs are never delegated. + if !allowed && !is_dm && matches!(respond_to, RespondTo::Owner) { + if let Some(gateways) = &self.gateways { + allowed = match gateways.is_live(&event.pubkey).await { + Ok(live) => live, + Err(error) => { + tracing::warn!( + author = %event.pubkey, + "gateway check failed; refusing this event: {error}" + ); + false + } + }; + } + } InboundAuthorGateDecision { effective_author, allowed, @@ -676,6 +696,14 @@ mod inbound_author_gate { } } + /// Admit live owner-delegated gateways in strict owner mode. + pub(crate) fn enable_gateways( + &mut self, + gateways: Option>, + ) { + self.gateways = gateways; + } + pub(crate) fn enable_sharing( &mut self, sharing: Option>, @@ -1398,8 +1426,8 @@ fn spawn_relay_observer_publisher( publisher: RelayEventPublisher, keys: nostr::Keys, agent_pubkey_hex: String, - owner_pubkey_hex: String, owner_pubkey: PublicKey, + gateways: Option>, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { // Subscribe BEFORE snapshotting so an event emitted between the two @@ -1414,22 +1442,31 @@ fn spawn_relay_observer_publisher( publisher, keys, agent_pubkey_hex, - owner_pubkey_hex, owner_pubkey, + gateways, ) .await; }) } +/// Publish observer telemetry to the owner and every live owner-delegated +/// gateway. +/// +/// Each frame is encrypted once per recipient. To keep the agent's relay +/// message budget flat, a frame sent to `n` recipients is followed by `n - 1` +/// idle ticks, so telemetry still costs at most one relay frame per tick. A +/// gateway lookup failure sends that frame to the owner only. +#[allow(clippy::too_many_arguments)] async fn run_relay_observer_publisher( snapshot: Vec, mut rx: tokio::sync::broadcast::Receiver, publisher: RelayEventPublisher, keys: nostr::Keys, agent_pubkey_hex: String, - owner_pubkey_hex: String, owner_pubkey: PublicKey, + gateways: Option>, ) { + let mut idle_ticks = 0usize; let mut queue = ObserverPublishQueue::default(); let max_snapshot_seq = snapshot.iter().map(|event| event.seq).max().unwrap_or(0); for event in snapshot { @@ -1472,11 +1509,25 @@ async fn run_relay_observer_publisher( } } _ = publish_tick.tick() => { - if let Some(frame) = queue.next_frame() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, frame, - ).await; + if idle_ticks > 0 { + idle_ticks -= 1; + } else if let Some(frame) = queue.next_frame() { + let mut recipients = vec![owner_pubkey]; + if let Some(gateways) = &gateways { + match gateways.live().await { + Ok(live) => recipients.extend(live), + Err(error) => tracing::warn!( + "observer telemetry to gateways skipped: {error}" + ), + } + } + idle_ticks = recipients.len() - 1; + for recipient in &recipients { + publish_relay_observer_event( + &publisher, &keys, &agent_pubkey_hex, + &recipient.to_hex(), recipient, frame.clone(), + ).await; + } } if closed && queue.is_empty() { break; @@ -1858,29 +1909,78 @@ async fn publish_relay_observer_event( /// Maximum age (seconds) for an observer control frame to be considered fresh. const OBSERVER_CONTROL_FRESHNESS_SECS: i64 = 300; -fn handle_relay_observer_control_event( +/// Build gateway access when this harness may honour owner-delegated gateways: +/// strict owner mode with a resolved owner and a bound channel thread. +/// Direct-message and unbound sessions never delegate telemetry or control. +fn gateway_access_for( + config: &Config, + owner_hex: Option<&str>, + rest: relay::RestClient, +) -> Option> { + if !matches!(config.respond_to, RespondTo::Owner) + || !matches!( + config.bound_session, + Some(config::BoundSession::Thread { .. }) + ) + { + return None; + } + let owner = PublicKey::from_hex(owner_hex?).ok()?; + Some(Arc::new(gateway::GatewayAccess::new( + owner, + config.keys.public_key(), + rest, + config.bound_session.as_ref()?.channel_id(), + ))) +} + +/// The sender of an observer control frame when it is a live gateway of this +/// agent's owner, else `None`. The owner's own frames and every lookup +/// failure yield `None`; the handler then accepts only the owner. +async fn delegated_control_sender( + gateways: Option<&gateway::GatewayAccess>, + event: &nostr::Event, + owner_pubkey_hex: &str, +) -> Option { + let gateways = gateways?; + if event.pubkey.to_hex() == owner_pubkey_hex { + return None; + } + match gateways.is_live(&event.pubkey).await { + Ok(true) => Some(event.pubkey), + Ok(false) => None, + Err(error) => { + tracing::warn!(sender = %event.pubkey, "observer control gateway check failed: {error}"); + None + } + } +} + +/// Verify one observer control frame and return its decrypted payload when +/// the sender may issue it: the owner may send any command; the verified live +/// gateway named by `gateway` may send only `cancel_turn`. +fn authorized_control_payload( keys: &nostr::Keys, - event: nostr::Event, - pool: &mut AgentPool, - observer: Option<&observer::ObserverHandle>, + event: &nostr::Event, owner_pubkey_hex: &str, - event_publisher: RelayEventPublisher, - bound_channel: Option, -) { + gateway: Option, +) -> Option { // Defense-in-depth: verify signature even though the relay already checked. - if let Err(e) = buzz_core::verify_event(&event) { + if let Err(e) = buzz_core::verify_event(event) { tracing::warn!(error = %e, "observer control frame failed signature verification"); - return; + return None; } - // Defense-in-depth: verify the sender is the resolved owner. - if event.pubkey.to_hex() != owner_pubkey_hex { + let from_gateway = gateway == Some(event.pubkey); + // Defense-in-depth: verify the sender is the resolved owner or the + // verified live gateway. + if event.pubkey.to_hex() != owner_pubkey_hex && !from_gateway { tracing::warn!( sender = %event.pubkey, expected = %owner_pubkey_hex, "observer control frame from non-owner — dropping" ); - return; + return None; } // Freshness: reject stale/replayed frames outside ±5 minute window. @@ -1892,17 +1992,47 @@ fn handle_relay_observer_control_event( now, "observer control frame outside freshness window — dropping" ); - return; + return None; } - let payload = match decrypt_observer_payload::(keys, &event) { + let payload = match decrypt_observer_payload::(keys, event) { Ok(payload) => payload, Err(error) => { tracing::warn!("failed to decrypt observer control frame: {error}"); - return; + return None; } }; + let command_type = payload.get("type").and_then(|value| value.as_str()); + if from_gateway && command_type != Some("cancel_turn") { + tracing::warn!( + sender = %event.pubkey, + command = ?command_type, + "gateway observer control is limited to cancel_turn — dropping" + ); + return None; + } + Some(payload) +} + +/// Handle one observer control frame from the owner or, when `gateway` names +/// its verified sender, from a live owner-delegated gateway. A gateway may +/// only `cancel_turn`: model switches change the owner's cost and settings, +/// and project announcements publish owner-authored content. +#[allow(clippy::too_many_arguments)] +fn handle_relay_observer_control_event( + keys: &nostr::Keys, + event: nostr::Event, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, + owner_pubkey_hex: &str, + gateway: Option, + event_publisher: RelayEventPublisher, + bound_channel: Option, +) { + let Some(payload) = authorized_control_payload(keys, &event, owner_pubkey_hex, gateway) else { + return; + }; let command_type = payload.get("type").and_then(|value| value.as_str()); match command_type { Some("cancel_turn") => { @@ -3046,6 +3176,9 @@ async fn run_harness( } } let owner_cache = OwnerCache::new(startup_owner.clone()); + // Owner-delegated gateways are honoured only in strict owner mode, the mode + // every remote sandbox session runs in. Other modes never consult them. + let gateway_access = gateway_access_for(config, startup_owner.as_deref(), relay.rest_client()); let mut relay_observer_control_rx = None; let mut relay_observer_publisher_task = None; @@ -3111,7 +3244,7 @@ async fn run_harness( } } - if let Some((observer, publisher, keys, agent_pubkey, owner_pubkey, owner)) = + if let Some((observer, publisher, keys, agent_pubkey, _owner_pubkey, owner)) = relay_observer_publisher.take() { relay_observer_publisher_task = Some(spawn_relay_observer_publisher( @@ -3119,8 +3252,8 @@ async fn run_harness( publisher, keys, agent_pubkey, - owner_pubkey, owner, + gateway_access.clone(), )); } @@ -3150,12 +3283,15 @@ async fn run_harness( ); } - let ctx = Arc::new(runtime.prompt_context( + let mut prompt_context = runtime.prompt_context( relay.rest_client(), channel_info_map, SessionMode::Conversation, - )?); + )?; + prompt_context.gateways = gateway_access.clone(); + let ctx = Arc::new(prompt_context); author_gate_ctx.enable_sharing(ctx.sharing.clone()); + author_gate_ctx.enable_gateways(ctx.gateways.clone()); let _shared_notice_worker = ctx .sharing .as_ref() @@ -3550,12 +3686,19 @@ async fn run_harness( match control_event { Some(event) => { if let Some(ref owner_hex) = owner_cache.pubkey { + let gateway = delegated_control_sender( + gateway_access.as_deref(), + &event, + owner_hex, + ) + .await; handle_relay_observer_control_event( &config.keys, event, &mut pool, observer.as_ref(), owner_hex, + gateway, relay.event_publisher(), config.bound_session.as_ref().map(|binding| binding.channel_id()), ); @@ -9637,8 +9780,8 @@ mod observer_snapshot_race_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, ) .await; @@ -10361,8 +10504,8 @@ mod observer_publish_cadence_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, )); // t=0: nothing may publish, no matter how full the snapshot was. @@ -10440,8 +10583,8 @@ mod observer_publish_cadence_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, )); settle().await; @@ -10506,8 +10649,8 @@ mod observer_publish_cadence_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, )); settle().await; diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0a86526415b..d51399ae0fc 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -936,6 +936,9 @@ pub struct PromptContext { pub memory_enabled: bool, /// Fresh authorization for bound shared-channel tasks. pub sharing: Option>, + /// Live owner-delegated gateways, set only in strict owner mode. A queued + /// gateway turn is rechecked against it at dispatch. + pub(crate) gateways: Option>, /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, @@ -2514,24 +2517,10 @@ pub async fn run_prompt_task( // Queueing may outlive a grant. Recheck before session creation, memory // fetches, or any ACP prompt; the listener separately guards native steering. - if let (Some(sharing), Some(pending)) = (&ctx.sharing, &batch) { - let mut denied = None; - for item in &pending.events { - if Some(item.event.pubkey) == ctx.agent_owner_pubkey { - continue; - } - match sharing.authorize(&item.event).await { - Ok(crate::sharing::Admission::Allowed(_)) => {} - Ok(crate::sharing::Admission::Refused(reason)) => { - denied = Some(reason); - break; - } - Err(error) => { - denied = Some(error.to_string()); - break; - } - } - } + // A gateway turn is rechecked the same way: a revocation or expiry blocks + // it here even when it was admitted before. + if let (true, Some(pending)) = (ctx.sharing.is_some() || ctx.gateways.is_some(), &batch) { + let denied = dispatch_refusal(&ctx, pending).await; if let Some(reason) = denied { send_prompt_result( &result_tx, @@ -4941,6 +4930,39 @@ fn parse_nostr_dm_response(json: serde_json::Value, limit: u32) -> Option Option { + for item in &pending.events { + if Some(item.event.pubkey) == ctx.agent_owner_pubkey { + continue; + } + let mut gateway_error = None; + if let Some(gateways) = &ctx.gateways { + match gateways.is_live(&item.event.pubkey).await { + Ok(true) => continue, + Ok(false) => {} + Err(error) => gateway_error = Some(error.to_string()), + } + } + let Some(sharing) = &ctx.sharing else { + return Some(gateway_error.unwrap_or_else(|| { + "the requester is not the owner or a live owner-delegated gateway".into() + })); + }; + match sharing.authorize(&item.event).await { + Ok(crate::sharing::Admission::Allowed(_)) => {} + Ok(crate::sharing::Admission::Refused(reason)) => return Some(reason), + Err(error) => return Some(error.to_string()), + } + } + None +} + /// Return the batch for requeue only in Queue mode; drop it in Drop mode. #[inline] fn requeue_batch_if_queue(ctx: &PromptContext, batch: Option) -> Option { @@ -10526,6 +10548,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent_owner_pubkey: owner_pubkey, memory_enabled: false, sharing: None, + gateways: None, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), } diff --git a/crates/buzz-acp/src/runtime.rs b/crates/buzz-acp/src/runtime.rs index 292d7f410ae..32252fbdecc 100644 --- a/crates/buzz-acp/src/runtime.rs +++ b/crates/buzz-acp/src/runtime.rs @@ -126,6 +126,7 @@ fn make_prompt_context( .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, sharing, + gateways: None, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), }) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 29ec19a04c2..f1fb50c414e 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -77,6 +77,12 @@ buzz users set-status --clear # remove your status buzz dms open --pubkey buzz dms list +# Gateway delegation (run as the human owner) +# Admit the gateway into the relay and channel separately before use. +buzz gateway grant --expires 90d +buzz gateway list +buzz gateway revoke + # Workflows buzz workflows list --channel buzz workflows trigger --workflow @@ -110,6 +116,11 @@ buzz channels list | jq '.[].name' constraint omitted from the command is removed. `protect list` reports malformed stored rules in `validation_error` so an owner can remove and repair them. +Gateway grants drive only your remote agents in bound channel threads. They +provide no access to your DMs, profile, or channel administration. See +[Gateway keys](../../docs/agent-gateway-key.md) for membership prerequisites, +revocation timing, and rollout. + ## Commands `buzz --help` prints this whole surface as an indented tree — every group with @@ -157,6 +168,9 @@ The table below mirrors that tree for readers who are not at a terminal. | | `presence` | Get presence status | | | `set-presence` | Set presence status | | | `set-status` | Set or clear your NIP-38 profile status | +| `gateway` | `grant` | Delegate a separate key for at most 90 days | +| `gateway` | `list` | Read the latest 32 delegation heads | +| `gateway` | `revoke` | Publish a durable revocation head | | `workflows` | `list` | List workflows | | | `get` | Get workflow definition | | | `create` | Create a workflow | diff --git a/crates/buzz-cli/src/commands/gateway.rs b/crates/buzz-cli/src/commands/gateway.rs new file mode 100644 index 00000000000..5ba7710d3fa --- /dev/null +++ b/crates/buzz-cli/src/commands/gateway.rs @@ -0,0 +1,141 @@ +//! Owner-managed gateway delegations. Membership remains independently managed. + +use buzz_sdk::gateway::{Delegation, MAX_DELEGATION_SECS}; +use nostr::{Event, PublicKey, Timestamp}; +use serde_json::json; + +use crate::{client::BuzzClient, error::CliError, GatewayCmd}; + +/// Run a gateway command using the human owner's signing identity. +pub async fn dispatch(command: GatewayCmd, client: &BuzzClient) -> Result<(), CliError> { + if client.auth_tag_owner_hex().is_some() { + return Err(CliError::Auth( + "gateway delegations require the owner's key".into(), + )); + } + match command { + GatewayCmd::List => { + let result = client + .query(&json!({ + "consistency": "strong", + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [client.keys().public_key()], + "limit": buzz_sdk::gateway::LOOKUP_LIMIT, + })) + .await?; + println!("{result}"); + Ok(()) + } + GatewayCmd::Grant { pubkey, expires } => { + let gateway = parse_key(&pubkey)?; + if gateway == client.keys().public_key() { + return Err(CliError::Usage("the gateway must be a separate key".into())); + } + let duration = duration(&expires)?; + let now = Timestamp::now().as_secs(); + let created_at = next_timestamp(client, gateway, now).await?; + let expires_at = now + .checked_add(duration) + .ok_or_else(|| CliError::Usage("gateway expiry is out of range".into()))?; + let builder = Delegation::grant(gateway, expires_at, created_at) + .map_err(|error| CliError::Usage(error.to_string()))?; + publish(client, builder).await + } + GatewayCmd::Revoke { pubkey } => { + let gateway = parse_key(&pubkey)?; + let created_at = next_timestamp(client, gateway, Timestamp::now().as_secs()).await?; + let builder = Delegation::revoke(gateway, created_at) + .map_err(|error| CliError::Usage(error.to_string()))?; + publish(client, builder).await + } + } +} + +fn parse_key(value: &str) -> Result { + PublicKey::parse(value).map_err(|_| CliError::Usage("invalid gateway public key".into())) +} + +fn duration(value: &str) -> Result { + let (number, multiplier) = if let Some(number) = value.strip_suffix('w') { + (number, 7 * 86_400) + } else if let Some(number) = value.strip_suffix('d') { + (number, 86_400) + } else if let Some(number) = value.strip_suffix('h') { + (number, 3_600) + } else if let Some(number) = value.strip_suffix('m') { + (number, 60) + } else if let Some(number) = value.strip_suffix('s') { + (number, 1) + } else { + return Err(CliError::Usage("use an expiry such as 24h or 90d".into())); + }; + let seconds = number + .parse::() + .ok() + .and_then(|n| n.checked_mul(multiplier)); + match seconds { + Some(seconds) if seconds > 0 && seconds <= MAX_DELEGATION_SECS => Ok(seconds), + _ => Err(CliError::Usage( + "gateway expiry must be positive and at most 90d".into(), + )), + } +} + +/// A replacement must be newer than the head it replaces, including when +/// grant and revoke run during the same second. Refuse implausible future +/// heads rather than silently publishing a dominated revocation. +async fn next_timestamp( + client: &BuzzClient, + gateway: PublicKey, + now: u64, +) -> Result { + let result = client + .query(&json!({ + "consistency": "strong", + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [client.keys().public_key()], + "#d": [gateway.to_hex()], "limit": 1, + })) + .await?; + let heads: Vec = serde_json::from_str(&result) + .map_err(|_| CliError::Other("invalid delegation head response".into()))?; + if heads.len() > 1 { + return Err(CliError::Other("too many delegation heads returned".into())); + } + let Some(head) = heads.first() else { + return Ok(now); + }; + let coordinate = head.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "d" => Some(value.as_str()), + _ => None, + }); + if head.pubkey != client.keys().public_key() + || head.kind.as_u16() as u32 != buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION + || coordinate != Some(gateway.to_hex().as_str()) + || head.verify().is_err() + { + return Err(CliError::Other( + "relay returned an unrelated delegation head".into(), + )); + } + let next = now.max(head.created_at.as_secs().saturating_add(1)); + if next > now.saturating_add(buzz_sdk::gateway::MAX_FUTURE_SKEW_SECS) { + return Err(CliError::Conflict( + "existing delegation is too far in the future; check the owner's clock".into(), + )); + } + Ok(next) +} + +async fn publish(client: &BuzzClient, builder: nostr::EventBuilder) -> Result<(), CliError> { + let event = client.sign_event(builder)?; + let response = client.submit_event(event).await?; + println!( + "{}", + super::parse_write_response(&response, "delegation changed concurrently; retry")? + ); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-cli/src/commands/gateway/tests.rs b/crates/buzz-cli/src/commands/gateway/tests.rs new file mode 100644 index 00000000000..ab4ef10c556 --- /dev/null +++ b/crates/buzz-cli/src/commands/gateway/tests.rs @@ -0,0 +1,143 @@ +use super::*; +use axum::{extract::State, routing::post, Json, Router}; +use nostr::{EventBuilder, Keys, Kind}; +use std::sync::{Arc, Mutex}; + +#[tokio::test] +async fn grant_revoke_and_regrant_replace_heads_and_refuse_agent_credentials() { + let owner = Keys::generate(); + let gateway = Keys::generate(); + let heads = Arc::new(Mutex::new(Vec::::new())); + let app = Router::new() + .route( + "/query", + post( + |State(heads): State>>>, + Json(filters): Json| async move { + assert_eq!(filters[0]["consistency"], json!("strong")); + assert_eq!( + filters[0]["kinds"], + json!([buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION]) + ); + Json( + heads + .lock() + .unwrap() + .last() + // Match an honest relay's author filter for grant heads; + // metadata is an intentionally corrupt response below. + .filter(|head| { + head.kind == Kind::Metadata + || filters[0]["authors"] == json!([head.pubkey]) + }) + .cloned() + .into_iter() + .collect::>(), + ) + }, + ), + ) + .route( + "/events", + post( + |State(heads): State>>>, Json(event): Json| async move { + event.verify().unwrap(); + let id = event.id; + heads.lock().unwrap().push(event); + Json(json!({"event_id":id,"accepted":true,"message":""})) + }, + ), + ) + .with_state(heads.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = BuzzClient::new(url.clone(), owner.clone(), None, None).unwrap(); + let grant = || GatewayCmd::Grant { + pubkey: gateway.public_key().to_hex(), + expires: "2w".into(), + }; + dispatch(grant(), &client).await.unwrap(); + dispatch( + GatewayCmd::Revoke { + pubkey: gateway.public_key().to_hex(), + }, + &client, + ) + .await + .unwrap(); + dispatch(grant(), &client).await.unwrap(); + { + let heads = heads.lock().unwrap(); + assert_eq!(heads.len(), 3); + for (event, enabled) in heads.iter().zip([true, false, true]) { + assert_eq!(event.pubkey, owner.public_key()); + assert_eq!(Delegation::parse(event).unwrap().0.enabled, enabled); + } + assert!(heads[0].created_at < heads[1].created_at); + assert!(heads[1].created_at < heads[2].created_at); + } + for expires in ["0d", "91d", "18446744073709551615d", "forever"] { + assert!(dispatch( + GatewayCmd::Grant { + pubkey: gateway.public_key().to_hex(), + expires: expires.into() + }, + &client + ) + .await + .is_err()); + } + assert!(dispatch( + GatewayCmd::Grant { + pubkey: owner.public_key().to_hex(), + expires: "1d".into() + }, + &client + ) + .await + .is_err()); + let agent = Keys::generate(); + let auth = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let agent_client = BuzzClient::new( + url, + agent, + Some(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()), + None, + ) + .unwrap(); + assert!(dispatch(grant(), &agent_client).await.is_err()); + // A verified but unrelated head must not influence the replacement time. + heads.lock().unwrap().clear(); + heads.lock().unwrap().push( + EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&owner) + .unwrap(), + ); + assert!(dispatch(grant(), &client).await.is_err()); + // A future head must fail visibly instead of accepting a dominated revoke. + let future = Timestamp::now().as_secs() + 120; + heads.lock().unwrap().clear(); + heads.lock().unwrap().push( + Delegation::grant(gateway.public_key(), future + 3_600, future) + .unwrap() + .sign_with_keys(&owner) + .unwrap(), + ); + assert!(matches!( + dispatch( + GatewayCmd::Revoke { + pubkey: gateway.public_key().to_hex() + }, + &client + ) + .await, + Err(CliError::Conflict(_)) + )); + assert_eq!( + heads.lock().unwrap().len(), + 1, + "no misleading revocation was published" + ); + server.abort(); +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index f7a3ebe9e0d..7c71016abd8 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod dms; pub mod emoji; pub(crate) mod event_output; pub mod feed; +pub mod gateway; pub mod gifs; pub mod issues; pub mod mem; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f4d000ca206..77ddbc168f3 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -263,6 +263,9 @@ enum Cmd { /// Draft owner-reviewed agent creation and updates #[command(subcommand)] Agents(AgentsCmd), + /// Delegate a separate key to drive your remote agents + #[command(subcommand)] + Gateway(GatewayCmd), /// Send, read, search, and manage messages #[command(subcommand)] Messages(MessagesCmd), @@ -349,6 +352,26 @@ impl RespondToArg { } } +/// Manage owner-signed, expiring gateway delegations. +#[derive(Subcommand)] +pub enum GatewayCmd { + /// Delegate a gateway key (membership must be granted separately) + Grant { + /// Gateway public key (hex or npub) + pubkey: String, + /// Lifetime, such as 24h or 90d (maximum 90d) + #[arg(long, default_value = "90d")] + expires: String, + }, + /// Read your delegation heads, including revocations and expired heads + List, + /// Revoke a gateway with a durable replacement head + Revoke { + /// Gateway public key (hex or npub) + pubkey: String, + }, +} + #[derive(Subcommand)] pub enum AgentsCmd { /// Delegate work to another shared agent, retaining the incoming task's provenance @@ -2316,6 +2339,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { match cli.command { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, + Cmd::Gateway(sub) => commands::gateway::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, @@ -2512,6 +2536,7 @@ mod tests { "dms", "emoji", "feed", + "gateway", "gifs", "issues", "media", @@ -2731,6 +2756,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), + ("gateway", 3), ("issues", 6), ("media", 1), ("messages", 8), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 9f66666467f..23bbb207535 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -345,6 +345,17 @@ pub const KIND_MANAGED_AGENT: u32 = 30177; /// absent grants never authorize another member. Credentials are not included. pub const KIND_AGENT_CHANNEL_GRANT: u32 = 30180; +/// Owner-signed delegation of a gateway key (NIP-33, coordinate = gateway hex). +/// +/// A live head lets the named gateway key drive the signer's own remote +/// agents: mention-launch them, prompt them in their threads, cancel a turn, +/// and receive their observer telemetry. It never makes the gateway an owner +/// or an agent anywhere else. Every enabled head carries a NIP-40 +/// `expiration`. Revocation is a newer disabled head; the relay ignores NIP-09 +/// deletions of this kind so an old signed grant cannot be replayed into an +/// empty coordinate. See `docs/agent-gateway-key.md`. +pub const KIND_AGENT_GATEWAY_DELEGATION: u32 = 30181; + /// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored). /// /// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)` @@ -727,6 +738,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM, KIND_MANAGED_AGENT, KIND_AGENT_CHANNEL_GRANT, + KIND_AGENT_GATEWAY_DELEGATION, KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, @@ -943,6 +955,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 300 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_PRIVATE_MANAGED_AGENT)); // 30179 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_AGENT_GATEWAY_DELEGATION)); // 30181 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index dce1a624c54..7710c5aa31f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2172,6 +2172,10 @@ async fn query_member_events( let mut catchall_results = stream::iter(catchall_queries.into_iter().map( |(idx, query, read_route)| { let db = db.clone(); + let state = state.clone(); + let tenant = tenant.clone(); + let reader = pubkey_bytes.clone(); + let filter = filters[idx].clone(); async move { // The route was resolved from client-carried `consistency` // intent in phase 1 (`resolve_read_route`). `Writer` pins the @@ -2179,7 +2183,15 @@ async fn query_member_events( // replica-eligible path. Only these two directions exist — the // inverse "force replica" is deliberately unrepresentable. let result = match read_route { - ReadRoute::Writer => db.query_events(&query).await, + ReadRoute::Writer => match crate::handlers::current_members::read( + &state, &tenant, &reader, &filter, + ) + .await + { + Ok(Some(events)) => Ok(events), + Ok(None) => db.query_events(&query).await, + Err(error) => Err(error), + }, ReadRoute::Routed => db.query_events_routed("bridge_query", &query).await, }; (idx, result) @@ -10225,6 +10237,47 @@ pub(crate) mod postgres_tests { "Probe 3 FAIL — unknown consistency value must be rejected with 400" ); + // The WS extension must reach the same writer boundary. The parser, + // connection options and actual REQ history dispatcher stay real. + let ws = CancelFixture { + admin: admin.clone(), + writer: writer_pool.clone(), + replica: replica_pool.clone(), + search: writer_pool.clone(), + names: [writer_name.clone(), replica_name.clone()], + state: state.clone(), + host: host.clone(), + community: CommunityId::from_uuid(community), + channel: channel_id.to_string(), + root: canvas_ev.id.to_hex(), + reader: author.clone(), + }; + rt.block_on(async { + for (strong, expected) in [(false, 0), (true, 1)] { + let mut filter = serde_json::json!({"kinds":[buzz_core::kind::KIND_CANVAS], "#h":[channel_id], "limit":10}); + if strong { filter["consistency"] = serde_json::json!("strong"); } + let raw = serde_json::json!(["REQ", "strong-ws", filter]).to_string(); + let crate::protocol::ClientMessage::Req { sub_id, filters, before_ids, writer_reads } = + crate::protocol::ClientMessage::parse(&raw).unwrap() else { panic!("expected REQ"); }; + let (conn, mut rx) = ws.ws_conn(); + crate::handlers::req::handle_req_consistent(sub_id, filters, before_ids, writer_reads, conn.clone(), state.clone()).await; + let mut delivered = 0; + let mut eose = false; + while let Ok(axum::extract::ws::Message::Text(frame)) = rx.try_recv() { + let value: serde_json::Value = serde_json::from_str(frame.as_str()).unwrap(); + match value[0].as_str() { + Some("EVENT") => delivered += 1, + Some("EOSE") => eose = true, + _ => panic!("unexpected WS frame: {frame}"), + } + } + assert!(eose, "the WS history read completed"); + assert_eq!(delivered, expected, "WS writer pin strong={strong}"); + crate::handlers::close::handle_close("strong-ws".into(), conn, state.clone()).await; + } + }); + drop(ws); + // --- teardown -------------------------------------------------------- rt.block_on(async { let admin2 = PgPool::connect(&admin_url) diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index c2970fb4435..c29543c7af0 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1418,6 +1418,7 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar sub_id, filters, before_ids, + writer_reads, } => { let conn = Arc::clone(&conn); let state = Arc::clone(&state); @@ -1445,7 +1446,15 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let span = tracing::info_span!("ws.req", conn_id = %conn.conn_id, sub_id = %sub_id); tokio::spawn( async move { - handlers::req::handle_req(sub_id, filters, before_ids, conn, state).await; + handlers::req::handle_req_consistent( + sub_id, + filters, + before_ids, + writer_reads, + conn, + state, + ) + .await; drop(permit); } .instrument(span), diff --git a/crates/buzz-relay/src/handlers/current_members.rs b/crates/buzz-relay/src/handlers/current_members.rs new file mode 100644 index 00000000000..fa1011d9366 --- /dev/null +++ b/crates/buzz-relay/src/handlers/current_members.rs @@ -0,0 +1,304 @@ +//! Current channel authority for scoped strong Nostr roster reads. +//! +//! A stored discovery event can lag canonical membership after projection +//! publication fails. Reconcile that one coordinate behind the existing writer +//! lock before returning authority. Ordinary and historical queries retain +//! their existing behavior. This path never fans out events. + +use std::sync::Arc; + +use buzz_core::{tenant::TenantContext, StoredEvent}; +use buzz_db::{DbError, EventQuery}; +use nostr::{Alphabet, Filter, SingleLetterTag}; + +use crate::state::AppState; + +/// Return current membership for an explicit strong authority filter, or `None` +/// when this is an ordinary event-history filter. One filter repairs at most +/// one channel; existing request filter/concurrency limits bound total work. +pub(crate) async fn read( + state: &Arc, + tenant: &TenantContext, + reader: &[u8], + filter: &Filter, +) -> Result>, DbError> { + if filter.kinds.as_ref().is_none_or(|kinds| { + kinds.len() != 1 + || !kinds + .iter() + .any(|kind| u32::from(kind.as_u16()) == buzz_core::kind::KIND_NIP29_GROUP_MEMBERS) + }) || filter.authors.as_ref().is_none_or(|authors| { + authors.len() != 1 || !authors.contains(&state.relay_keypair.public_key()) + }) || filter.ids.is_some() + || filter.since.is_some() + || filter.until.is_some() + || filter.search.is_some() + || filter.generic_tags.len() != 1 + { + return Ok(None); + } + let d = SingleLetterTag::lowercase(Alphabet::D); + let Some(values) = filter + .generic_tags + .get(&d) + .filter(|values| values.len() == 1) + else { + return Ok(None); + }; + let Some(value) = values.iter().next() else { + return Ok(None); + }; + let Ok(channel) = uuid::Uuid::parse_str(value) else { + return Ok(Some(Vec::new())); + }; + if channel.to_string() != *value || filter.limit == Some(0) { + return Ok(Some(Vec::new())); + } + + // Read before taking the lock: never nest a pool checkout while holding + // a membership transaction. The locked canonical tags below decide whether + // this head remains authoritative, regardless of intervening publication. + let existing = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_NIP29_GROUP_MEMBERS as i32]), + authors: Some(vec![state.relay_keypair.public_key().to_bytes().to_vec()]), + channel_id: Some(channel), + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await? + .into_iter() + .next(); + let relay = state.relay_keypair.public_key().to_bytes(); + let mut snapshot = state + .db + .lock_member_snapshot(tenant.community(), channel, &relay) + .await?; + // A cached access list must not let a removed reader repair or read a + // private channel's current membership. No writes precede this check. + if snapshot.identity.is_none() + || !snapshot + .members + .iter() + .any(|member| member.pubkey == reader) + { + snapshot.release().await?; + return Ok(Some(Vec::new())); + } + let expected = super::side_effects::group_members_tags( + &channel.to_string(), + snapshot.identity.as_ref(), + &snapshot.members, + ) + .map_err(|_| DbError::InvalidData("could not build channel roster".into()))?; + let tags = |tags: &nostr::Tags| { + let mut values = tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::>(); + values.sort(); + values + }; + let mut expected = expected + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::>(); + expected.sort(); + if let Some(existing) = existing + .filter(|stored| stored.event.verify().is_ok() && tags(&stored.event.tags) == expected) + { + snapshot.release().await?; + return Ok(Some(vec![existing])); + } + let repaired = super::side_effects::store_group_members_event(state, &mut snapshot) + .await + .map_err(|_| DbError::InvalidData("could not repair channel roster".into()))?; + snapshot.release().await?; + Ok(Some(repaired.into_iter().collect())) +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use nostr::Keys; + use serde_json::json; + + /// Simulate committed membership removal with failed discovery publication. + /// Strong roster reads repair it from canonical rows; a removed reader + /// cannot repair or observe current private state. Repeated reads do not + /// create replacement churn, and database failure propagates. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn strong_roster_recovers_failed_projection_without_leaking_or_churning() { + let state = crate::state::tests::test_state().await; + let host = format!("gateway-roster-{}.test", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let tenant = TenantContext::resolved(community, host); + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let channel = uuid::Uuid::new_v4(); + state + .db + .create_channel_with_id( + community, + channel, + "private-gateway-roster", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Private, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .unwrap(); + for key in [&agent, &gateway] { + state + .db + .add_member( + community, + channel, + key.public_key().as_bytes(), + buzz_core::channel::MemberRole::Member, + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(); + } + let relay = state.relay_keypair.public_key(); + let filter: Filter = serde_json::from_value( + json!({"kinds":[39002], "authors":[relay], "#d":[channel], "limit":1}), + ) + .unwrap(); + let stored_query = EventQuery { + kinds: Some(vec![39002]), + authors: Some(vec![relay.to_bytes().to_vec()]), + channel_id: Some(channel), + limit: Some(1), + ..EventQuery::for_community(community) + }; + // Extra generic constraints retain ordinary filter semantics and must + // never be answered as an unconstrained current authority query. + let constrained: Filter = serde_json::from_value(json!({ + "kinds":[39002], "authors":[relay], "#d":[channel], + "#p":[stranger.public_key()], "limit":1 + })) + .unwrap(); + assert!( + read(&state, &tenant, agent.public_key().as_bytes(), &constrained) + .await + .unwrap() + .is_none() + ); + assert!( + state + .db + .query_events(&stored_query) + .await + .unwrap() + .is_empty(), + "an ordinary constrained query must not repair the projection" + ); + let original = read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .pop() + .unwrap(); + assert!(buzz_sdk::gateway::channel_member( + &original.event, + relay, + channel, + gateway.public_key() + )); + // Canonical membership changes without emitting discovery metadata. + state + .db + .remove_member( + community, + channel, + gateway.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let stale = state + .db + .query_events(&stored_query) + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!(stale.event.id, original.event.id); + assert!(buzz_sdk::gateway::channel_member( + &stale.event, + relay, + channel, + gateway.public_key() + )); + for reader in [&gateway, &stranger] { + assert!( + read(&state, &tenant, reader.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .is_empty() + ); + } + assert_eq!( + state.db.query_events(&stored_query).await.unwrap()[0] + .event + .id, + original.event.id, + "unauthorized reads did not repair or publish" + ); + let current = read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .pop() + .unwrap(); + assert!(!buzz_sdk::gateway::channel_member( + ¤t.event, + relay, + channel, + gateway.public_key() + )); + assert_ne!(current.event.id, original.event.id); + let unchanged = read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .pop() + .unwrap(); + assert_eq!( + unchanged.event.id, current.event.id, + "current state does not churn" + ); + let foreign: Filter = serde_json::from_value( + json!({"kinds":[39002], "authors":[stranger.public_key()], "#d":[channel]}), + ) + .unwrap(); + assert!( + read(&state, &tenant, stranger.public_key().as_bytes(), &foreign) + .await + .unwrap() + .is_none() + ); + state.db.pool().close().await; + assert!( + read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .is_err() + ); + } +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index c21901e7ac3..d8452f57303 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1417,7 +1417,30 @@ async fn handle_agent_observer_event( } } }; - if !is_owner { + // Not the owner: the counterpart may still be a live gateway the agent's + // owner delegated (control from it, telemetry to it). A lookup failure + // refuses the frame. + let is_authorized = is_owner + || match gateway_observer_authorized( + &state, + conn.tenant.community(), + route.agent, + route.owner, + ) + .await + { + Ok(authorized) => authorized, + Err(e) => { + warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer gateway check failed: {e}"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; + } + }; + if !is_authorized { reject("auth"); conn.send(RelayMessage::ok( event_id_hex, @@ -1467,6 +1490,85 @@ async fn handle_agent_observer_event( conn.send(RelayMessage::ok(event_id_hex, true, "")); } +/// Longest a cached observer gateway verdict is trusted, which bounds how long +/// a revoked delegation keeps routing frames on another replica. +pub(crate) const GATEWAY_VERDICT_TTL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Whether `gateway` may exchange observer frames with `agent`: the agent's +/// recorded owner has a live kind 30181 delegation naming `gateway`, and +/// `gateway` is not itself an agent the relay has recorded. An owned agent +/// therefore never passes as a gateway, and an agent without a recorded owner +/// has none. Any lookup failure propagates and the caller refuses the frame. +async fn gateway_observer_authorized( + state: &AppState, + community: CommunityId, + agent: PublicKey, + gateway: PublicKey, +) -> Result { + let now = chrono::Utc::now().timestamp().max(0) as u64; + let agent_bytes = agent.to_bytes().to_vec(); + let gateway_bytes = gateway.to_bytes().to_vec(); + let key = (community, agent_bytes.clone(), gateway_bytes.clone()); + // Read the generation before any lookup: a head stored meanwhile bumps it, + // so the verdict computed below is ignored on the next frame. + let generation = state + .gateway_generation + .load(std::sync::atomic::Ordering::Acquire); + if let Some(cached) = state.observer_gateway_cache.get(&key) { + if cached.generation == generation && cached.valid_until > now { + return Ok(cached.authorized); + } + } + let users = state + .db + .get_agent_channel_policies(community, &[agent_bytes.clone(), gateway_bytes.clone()]) + .await?; + let owner = users + .get(&agent_bytes) + .and_then(|(_, owner)| owner.as_deref()) + .and_then(|owner| PublicKey::from_slice(owner).ok()); + let gateway_is_agent = users + .get(&gateway_bytes) + .is_some_and(|(_, owner)| owner.is_some()); + let live = match owner { + Some(owner) if !gateway_is_agent => { + let heads = state + .db + .query_events(&buzz_db::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION as i32]), + authors: Some(vec![owner.to_bytes().to_vec()]), + limit: Some(buzz_sdk::gateway::LOOKUP_LIMIT as i64), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await?; + let heads: Vec = heads.into_iter().map(|stored| stored.event).collect(); + buzz_sdk::gateway::resolve(owner, &heads, &[], &[owner, agent], now) + .into_iter() + .find(|live| live.gateway() == gateway) + } + _ => None, + }; + let cached = GatewayVerdict { + authorized: live.is_some(), + generation, + // A positive verdict never outlives the delegation's own expiry. + valid_until: live.map_or(u64::MAX, |live| live.expires_at()), + }; + state.observer_gateway_cache.insert(key, cached); + Ok(cached.authorized) +} + +/// A cached observer gateway verdict; see [`gateway_observer_authorized`]. +#[derive(Debug, Clone, Copy)] +pub(crate) struct GatewayVerdict { + authorized: bool, + /// [`AppState::gateway_generation`] read before the lookup. + generation: u64, + /// Unix seconds after which the verdict is recomputed. + valid_until: u64, +} + fn agent_observer_route(event: &Event) -> Result, String> { if !content_looks_like_nip44(&event.content) { return Err("invalid: observer content must be NIP-44 encrypted".into()); @@ -1782,6 +1884,18 @@ mod tests { (community_b, agent_bytes.clone(), owner_bytes.clone()), false, ); + // A failed owner check now tries gateway authority too. Keep this + // no-database test on the production cached-denial path in B. + state.observer_gateway_cache.insert( + (community_b, agent_bytes.clone(), owner_bytes.clone()), + super::GatewayVerdict { + authorized: false, + generation: state + .gateway_generation + .load(std::sync::atomic::Ordering::Acquire), + valid_until: u64::MAX, + }, + ); let encrypted = encrypt_observer_payload( &agent, @@ -3969,6 +4083,200 @@ mod tests { ); } + /// Every observer path a delegated gateway gets, and the boundaries + /// around it: control from the gateway and telemetry to it route only + /// while the agent's recorded owner has a live kind 30181 head naming + /// it; an owned agent never passes as a gateway; another owner's agent + /// is unreachable; a newer disabled head revokes at once; NIP-09 + /// deletion cannot empty the coordinate for a replayed old grant. + /// Mutations: return `is_owner` alone (no gateway branch) → RED at + /// "live gateway"; drop `!gateway_is_agent` → RED at "sibling"; + /// drop the generation bump → RED at "revoked"; drop the deletion + /// guard → RED at "replay". + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn gateway_observer_frames_follow_the_live_delegation() { + use buzz_core::kind::{KIND_AGENT_GATEWAY_DELEGATION, KIND_AGENT_OBSERVER_FRAME}; + use buzz_sdk::gateway::Delegation; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + let state = crate::state::tests::test_state().await; + let host = format!("ws-gateway-{}.test", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = buzz_core::tenant::TenantContext::resolved(community, host); + let (owner, agent, sibling, gateway, stranger, foreign_agent) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + for keys in [ + &owner, + &agent, + &sibling, + &gateway, + &stranger, + &foreign_agent, + ] { + state + .db + .ensure_user(community, keys.public_key().as_bytes()) + .await + .expect("ensure user"); + } + for (owned, by) in [(&agent, &owner), (&foreign_agent, &stranger)] { + state + .db + .set_agent_owner( + community, + owned.public_key().as_bytes(), + by.public_key().as_bytes(), + ) + .await + .expect("set agent owner"); + } + let frame = |from: &Keys, to: &Keys, agent: &Keys, direction: &str| { + let content = super::encrypt_observer_payload( + from, + &to.public_key(), + &serde_json::json!({"type": "cancel_turn"}), + ) + .expect("encrypt"); + EventBuilder::new(Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), content) + .tags([ + Tag::parse(["p", &to.public_key().to_hex()]).unwrap(), + Tag::parse([super::OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]) + .unwrap(), + Tag::parse([super::OBSERVER_FRAME_TAG, direction]).unwrap(), + ]) + .sign_with_keys(from) + .expect("sign frame") + }; + let control = + |from: &Keys, to: &Keys| frame(from, to, to, super::OBSERVER_FRAME_CONTROL); + let telemetry = + |from: &Keys, to: &Keys| frame(from, to, from, super::OBSERVER_FRAME_TELEMETRY); + let accepted = |text: &str| text.contains(",true,"); + async fn ok_frame_scoped_owner( + state: &std::sync::Arc, + tenant: &buzz_core::tenant::TenantContext, + keys: &Keys, + event: nostr::Event, + ) -> String { + ok_frame_scoped( + state, + tenant, + keys, + event, + buzz_auth::Scope::all_non_admin(), + ) + .await + } + let now = Timestamp::now().as_secs(); + let grant = |to: &Keys, created_at: u64| { + Delegation::grant(to.public_key(), created_at + 3_600, created_at) + .unwrap() + .sign_with_keys(&owner) + .unwrap() + }; + + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!(!accepted(&text), "undelegated gateway control: {text}"); + + let old_grant = grant(&gateway, now - 10); + let text = ok_frame_scoped_owner(&state, &tenant, &owner, old_grant).await; + assert!(accepted(&text), "grant stored: {text}"); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!(accepted(&text), "live gateway control: {text}"); + let text = ok_frame(&state, &tenant, &agent, telemetry(&agent, &gateway)).await; + assert!(accepted(&text), "live gateway telemetry: {text}"); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &foreign_agent)).await; + assert!(!accepted(&text), "another owner's agent: {text}"); + + let text = + ok_frame_scoped_owner(&state, &tenant, &owner, grant(&sibling, now - 9)).await; + assert!(accepted(&text), "sibling grant stored: {text}"); + // Becoming an agent after the grant still prevents gateway use. + state + .db + .set_agent_owner( + community, + sibling.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let text = + ok_frame_scoped_owner(&state, &tenant, &owner, grant(&sibling, now - 8)).await; + assert!( + !accepted(&text), + "known agent cannot receive a new delegation: {text}" + ); + let text = ok_frame(&state, &tenant, &sibling, control(&sibling, &agent)).await; + assert!( + !accepted(&text), + "sibling must not pass as a gateway: {text}" + ); + + let revocation = Delegation::revoke(gateway.public_key(), now - 5) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + let revocation_id = revocation.id; + let text = ok_frame_scoped_owner(&state, &tenant, &owner, revocation).await; + assert!(accepted(&text), "revocation stored: {text}"); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!(!accepted(&text), "revoked gateway control: {text}"); + + // NIP-09 e-tag deletion cannot erase the durable revocation. + // Replay a correctly signed older head never submitted before: + // reusing an existing event id could be rejected by generic dedup. + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tag(Tag::event(revocation_id)) + .sign_with_keys(&owner) + .unwrap(); + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, deletion).await; + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, grant(&gateway, now - 7)).await; + state.observer_gateway_cache.invalidate_all(); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!( + !accepted(&text), + "revocation survived e-tag deletion and a never-seen old head: {text}" + ); + + let coordinate = format!( + "{KIND_AGENT_GATEWAY_DELEGATION}:{}:{}", + owner.public_key().to_hex(), + gateway.public_key().to_hex() + ); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tag(Tag::parse(["a", &coordinate]).unwrap()) + .sign_with_keys(&owner) + .unwrap(); + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, deletion).await; + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, grant(&gateway, now - 6)).await; + state.observer_gateway_cache.invalidate_all(); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!( + !accepted(&text), + "replayed old grant after deletion: {text}" + ); + + let malformed = + EventBuilder::new(Kind::Custom(KIND_AGENT_GATEWAY_DELEGATION as u16), "{}") + .tag(Tag::parse(["d", &gateway.public_key().to_hex()]).unwrap()) + .sign_with_keys(&owner) + .unwrap(); + let text = ok_frame_scoped_owner(&state, &tenant, &owner, malformed).await; + assert!(!accepted(&text), "malformed head refused at ingest: {text}"); + } + /// Fresh community with one unrestricted user. async fn unrestricted_fixture() -> ( std::sync::Arc, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index acff4b7c3eb..1608fd3d0f8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -552,6 +552,8 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { buzz_core::kind::KIND_AGENT_CHANNEL_GRANT => Ok(Scope::MessagesWrite), + // Owner-managed global authority, like a profile or managed-agent record. + buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION => Ok(Scope::UsersWrite), KIND_PROFILE => Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM | buzz_core::kind::KIND_ARTIFACT => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM @@ -786,6 +788,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG + // Gateway delegations (30181) are owner-wide, never channel-scoped. + | buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT @@ -2823,6 +2827,34 @@ async fn ingest_event_inner( )); } + // Consumers verify every head themselves; ingest only refuses heads that + // could never be live or revoke, so a coordinate always holds a parseable + // head. + if kind_u32 == buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION { + let (delegation, _) = buzz_sdk::gateway::Delegation::parse(&event) + .map_err(|_| IngestError::Rejected("invalid: malformed gateway delegation".into()))?; + if delegation.enabled { + let policies = state + .db + .get_agent_channel_policies( + tenant.community(), + &[ + event.pubkey.to_bytes().to_vec(), + delegation.gateway.to_bytes().to_vec(), + ], + ) + .await + .map_err(|error| IngestError::Internal(error.to_string()))?; + if policies.values().any(|(_, owner)| owner.is_some()) + || buzz_sdk::gateway::profile_claims_agent(&event) + { + return Err(IngestError::Rejected( + "restricted: agent keys cannot grant or receive gateway authority".into(), + )); + } + } + } + // A verified owner must be able to revoke after removal or archive. // Enabling still requires explicit membership in the grant validator. let disabled_agent_grant = if kind_u32 == buzz_core::kind::KIND_AGENT_CHANNEL_GRANT { @@ -3745,6 +3777,13 @@ async fn ingest_event_inner( }); } + // Fence observer gateway verdicts computed before this committed head. + if kind_u32 == buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION { + state + .gateway_generation + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + } + // Workflow deletion, kind 5 / 9005 deletions of `e` targets, kind:9000 and // kind:9002 applied their changes in the insert's own transaction above. let persisted_atomically = workflow_deletion diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 7e05d94251c..6e5c45cbf91 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -22,6 +22,8 @@ pub mod community_provisioning; pub mod count; #[cfg(test)] mod create_with_members_postgres_tests; +/// Canonical membership authority behind strong Nostr reads. +pub(crate) mod current_members; #[cfg(test)] mod deletion_postgres_tests; #[cfg(test)] diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 6a4857715e9..0a983985e1c 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -59,6 +59,18 @@ pub async fn handle_req( before_ids: Vec>>, conn: Arc, state: Arc, +) { + handle_req_consistent(sub_id, filters, before_ids, Vec::new(), conn, state).await; +} + +/// Handle a subscription with per-filter writer-pinned authority reads. +pub(crate) async fn handle_req_consistent( + sub_id: String, + filters: Vec, + before_ids: Vec>>, + writer_reads: Vec, + conn: Arc, + state: Arc, ) { let mut access = crate::access::ReadLog::req(&conn, &sub_id, &filters); let (conn_id, pubkey_bytes, token_channel_ids) = { @@ -505,8 +517,22 @@ pub async fn handle_req( let mut results = stream::iter(filter_queries.into_iter().map( |(idx, per_filter_channel, params)| { let db = db.clone(); + let writer_read = writer_reads.get(idx).copied().unwrap_or(false); + let state = state.clone(); + let tenant = conn.tenant.clone(); + let reader = pubkey_bytes.clone(); + let filter = filters[idx].clone(); async move { - let filter_events = db.query_events_routed("req_historical", ¶ms).await; + let filter_events = if writer_read { + match super::current_members::read(&state, &tenant, &reader, &filter).await + { + Ok(Some(events)) => Ok(events), + Ok(None) => db.query_events(¶ms).await, + Err(error) => Err(error), + } + } else { + db.query_events_routed("req_historical", ¶ms).await + }; (idx, per_filter_channel, filter_events) } }, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 38b6dbc38ab..36417de40b0 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1579,7 +1579,7 @@ fn group_state_identity_tags( )?) } -fn group_members_tags( +pub(crate) fn group_members_tags( group_id: &str, identity: Option<&buzz_db::channel::ChannelIdentity>, members: &[MemberRecord], @@ -1598,7 +1598,7 @@ fn group_members_tags( Ok(tags) } -async fn store_group_members_event( +pub(crate) async fn store_group_members_event( state: &Arc, member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot, ) -> anyhow::Result> { @@ -3351,6 +3351,11 @@ async fn handle_a_tag_deletion( super::push_lease::KIND_PUSH_LEASE => { tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease"); } + // Revocation is exclusively a newer disabled head. Deleting a head would + // empty the coordinate and let anyone replay an older signed grant. + buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION => { + tracing::debug!(d_tag, "NIP-09 deletion ignored for gateway delegation"); + } buzz_core::kind::KIND_WORKFLOW_DEF => { // Workflow deletion belongs to the atomic persistence path in ingest. return Err(anyhow::anyhow!( @@ -3548,6 +3553,13 @@ async fn plan_standard_deletion_targets( ); continue; } + if u32::from(target.event.kind.as_u16()) == buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION { + tracing::debug!( + target_id = %hex::encode(&target_id), + "NIP-09 deletion ignored for gateway delegation" + ); + continue; + } planned.push(PlannedDeletionTarget::load(tenant, state, target_id, target).await?); } Ok(planned) diff --git a/crates/buzz-relay/src/protocol.rs b/crates/buzz-relay/src/protocol.rs index 58c1a188a91..c9383327870 100644 --- a/crates/buzz-relay/src/protocol.rs +++ b/crates/buzz-relay/src/protocol.rs @@ -24,6 +24,8 @@ pub enum ClientMessage { filters: Vec, /// Optional per-filter composite cursor tiebreaks from raw extension fields. before_ids: Vec>>, + /// Per-filter authority reads pinned to the writer by `consistency:strong`. + writer_reads: Vec, }, /// A REQ whose single filter is a NIP-AR sync (`"artifact":"current"`). /// An invalid sync filter is answered with `CLOSED`, so the parse error @@ -173,10 +175,21 @@ impl ClientMessage { Ok(Some(bytes)) }) .collect::>>()?; + let writer_reads = filter_values + .iter() + .map(|filter| match filter.get("consistency") { + None => Ok(false), + Some(Value::String(value)) if value == "strong" => Ok(true), + Some(_) => Err(RelayError::InvalidMessage( + "consistency must be \"strong\" when present".into(), + )), + }) + .collect::>>()?; Ok(ClientMessage::Req { sub_id, filters, before_ids, + writer_reads, }) } "COUNT" => { @@ -437,6 +450,27 @@ mod tests { } } + #[test] + fn req_consistency_preserves_filter_alignment_and_rejects_unknown_modes() { + let value = serde_json::json!(["REQ", "authority", {"kinds":[9]}, + {"kinds":[30181], "consistency":"strong"}, {"kinds":[0]}]); + let ClientMessage::Req { writer_reads, .. } = + ClientMessage::parse(&value.to_string()).unwrap() + else { + panic!("expected REQ"); + }; + assert_eq!(writer_reads, vec![false, true, false]); + for bad in [ + serde_json::json!("eventual"), + serde_json::json!(true), + serde_json::json!(null), + ] { + let value = + serde_json::json!(["REQ", "authority", {"kinds":[30181], "consistency":bad}]); + assert!(ClientMessage::parse(&value.to_string()).is_err()); + } + } + #[test] fn parse_req_composite_cursor_preserves_filter_alignment() { let raw = serde_json::json!([ diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 11abba15963..691ba63becf 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1547,6 +1547,17 @@ pub struct AppState { /// for every concurrently active agent so per-request HTTP agents hit. #[allow(clippy::type_complexity)] pub observer_owner_cache: Arc, Vec), bool>>, + /// Observer-frame gateway verdicts (kind 24200 between an agent and a key + /// its owner delegated with kind 30181). Key: (community, agent, gateway). + /// A short TTL bounds revocation latency across replicas; the generation + /// fences a lookup that raced a delegation write on this replica. + #[allow(clippy::type_complexity)] + pub(crate) observer_gateway_cache: Arc< + moka::sync::Cache<(CommunityId, Vec, Vec), crate::handlers::event::GatewayVerdict>, + >, + /// Bumped after every stored gateway delegation head, so a cached verdict + /// computed before the write is ignored. + pub(crate) gateway_generation: Arc, /// Cache for the `author_type` metric label on the ingest path. /// Key: (community_id, author pubkey bytes). Value: is_agent /// (`users.agent_owner_pubkey IS NOT NULL`). The mapping is @@ -1854,6 +1865,13 @@ impl AppState { .time_to_live(std::time::Duration::from_secs(300)) .build(), ), + observer_gateway_cache: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(crate::handlers::event::GATEWAY_VERDICT_TTL) + .build(), + ), + gateway_generation: Arc::new(AtomicU64::new(0)), author_type_cache: Arc::new( moka::sync::Cache::builder() .max_capacity(10_000) diff --git a/crates/buzz-sandbox-reaper/src/manager.rs b/crates/buzz-sandbox-reaper/src/manager.rs index 53efead6fd0..2f5b334bb90 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -463,22 +463,28 @@ impl Manager { } /// Reuse mobile start validation without manufacturing an owner control event. + /// Start the agent for one mention. The trigger's signer is the owner, a + /// live owner-delegated gateway (`delegation`, freshly verified by the + /// caller for this trigger), or a shared-task requester. pub(crate) async fn start_mention( &self, owner: PublicKey, agent: PublicKey, relay: &str, event: &Event, + delegation: Option<&buzz_sdk::gateway::Live>, ) -> serde_json::Value { let Some(developer) = self.config.developer(&owner) else { return refusal(ErrorCode::UnauthorizedOwner, "owner is not configured"); }; + let payload = + serde_json::json!({"agent_pubkey": agent, "relay_url": relay, "trigger": event}); + let delegation = + delegation.filter(|live| live.owner() == owner && live.gateway() == event.pubkey); let admitted = if event.pubkey == owner { - Start::admit( - serde_json::json!({"agent_pubkey": agent, "relay_url": relay, "trigger": event}), - owner, - Timestamp::now().as_secs(), - ) + Start::admit(payload, owner, Timestamp::now().as_secs()) + } else if let Some(delegation) = delegation { + Start::delegated(payload, delegation, Timestamp::now().as_secs()) } else { let request = crate::shared_tasks::Request { relay_url: relay.into(), diff --git a/crates/buzz-sandbox-reaper/src/mentions.rs b/crates/buzz-sandbox-reaper/src/mentions.rs index 1bc76ee4d91..44019b450a4 100644 --- a/crates/buzz-sandbox-reaper/src/mentions.rs +++ b/crates/buzz-sandbox-reaper/src/mentions.rs @@ -1,8 +1,10 @@ -//! Launch registered remote workspaces from ordinary, owner-signed mentions. +//! Launch registered remote workspaces from ordinary mentions signed by the +//! owner or by a gateway key the owner has live-delegated (kind 30181). //! Clients need no control protocol. Agent-authenticated subscriptions retain //! the relay's channel access checks; credentials never leave the manager. mod channels; +mod gateways; mod journal; mod registry; pub(crate) use registry::Identity; diff --git a/crates/buzz-sandbox-reaper/src/mentions/gateways.rs b/crates/buzz-sandbox-reaper/src/mentions/gateways.rs new file mode 100644 index 00000000000..8835d6ebd6a --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/mentions/gateways.rs @@ -0,0 +1,125 @@ +//! Read the owner's live delegated gateway keys (kind 30181) with the agent's +//! own relay identity. Every failure is an error, so callers fail closed: a +//! listener retries its session with backoff, and a launch is not started. + +use super::{registry::Identity, Error, IO_TIMEOUT}; +use buzz_sdk::gateway::Live; +use buzz_ws_client::{NostrWsConnection, RelayMessage}; +use nostr::{Event, Timestamp}; +use serde_json::{json, Value}; + +/// The owner's live gateways now, newest first, excluding the owner, this +/// agent, and any key whose profile claims agent identity. +/// +/// The subscriptions are finite reads on a short-lived connection; they are +/// left to end with it. +pub(super) async fn read( + connection: &mut NostrWsConnection, + identity: &Identity, +) -> Result, Error> { + tokio::time::timeout(IO_TIMEOUT, async { + let now = Timestamp::now().as_secs(); + let heads = fetch( + connection, + "mention-gateways", + json!({ + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [identity.owner], + "limit": buzz_sdk::gateway::LOOKUP_LIMIT, + }), + buzz_sdk::gateway::LOOKUP_LIMIT, + ) + .await?; + let candidates: Vec = buzz_sdk::gateway::live_heads(identity.owner, &heads, now) + .iter() + .map(|live| live.gateway().to_hex()) + .collect(); + if candidates.is_empty() { + return Ok(Vec::new()); + } + let count = candidates.len(); + let profiles = fetch( + connection, + "mention-gateway-profiles", + json!({"kinds": [0], "authors": candidates, "limit": count}), + count, + ) + .await?; + Ok(buzz_sdk::gateway::resolve( + identity.owner, + &heads, + &profiles, + &[identity.owner, identity.keys.public_key()], + now, + )) + }) + .await + .map_err(|_| Error::Deadline)? +} + +/// Fresh channel authority for a launch. A global delegation never substitutes +/// for explicit current posting membership of owner, agent and gateway. +pub(super) async fn member( + connection: &mut NostrWsConnection, + identity: &Identity, + channel: uuid::Uuid, + gateway: nostr::PublicKey, +) -> Result { + let relay = crate::shared_tasks::relay_key(&identity.relay) + .await + .map_err(|_| Error::Relay)?; + let rosters = fetch( + connection, + "mention-gateway-channel", + json!({ + "kinds":[39002], "authors":[relay], "#d":[channel], "limit":1, + }), + 1, + ) + .await?; + let Some(roster) = rosters.first() else { + return Ok(false); + }; + Ok([identity.owner, identity.keys.public_key(), gateway] + .into_iter() + .all(|key| buzz_sdk::gateway::channel_member(roster, relay, channel, key))) +} + +/// One finite REQ, read to EOSE. More than `limit` results is a relay fault. +async fn fetch( + connection: &mut NostrWsConnection, + id: &str, + filter: Value, + limit: usize, +) -> Result, Error> { + let mut filter = filter; + filter["consistency"] = json!("strong"); + connection + .send_raw(&json!(["REQ", id, filter])) + .await + .map_err(|_| Error::Relay)?; + let mut events = Vec::new(); + loop { + match connection + .next_event(IO_TIMEOUT) + .await + .map_err(|_| Error::Relay)? + { + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == id => { + events.push(*event); + if events.len() > limit { + return Err(Error::Relay); + } + } + RelayMessage::Eose { subscription_id } if subscription_id == id => return Ok(events), + RelayMessage::Closed { + subscription_id, .. + } if subscription_id == id => return Err(Error::Relay), + RelayMessage::Auth { .. } => return Err(Error::Relay), + _ => {} + } + } +} diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests.rs b/crates/buzz-sandbox-reaper/src/mentions/tests.rs index e2a2e2f64e7..945edd1b3fa 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests.rs @@ -1,6 +1,7 @@ //! A black-box client sends only ordinary signed chat events. A real WebSocket //! drives the production supervisor/worker and provider against a fake cluster. +mod gateway; mod live_relay; mod relay; mod revoked; @@ -433,7 +434,8 @@ async fn ended_session_posts_an_ordinary_reply_without_recovering_the_sandbox() identity.owner, identity.keys.public_key(), &identity.relay, - &event + &event, + None, ) .await["ok"], true diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs new file mode 100644 index 00000000000..5e27302d5e0 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs @@ -0,0 +1,157 @@ +use super::*; + +fn signed_mention(signer: &Keys, content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([ + Tag::parse(["h", CHANNEL]).unwrap(), + Tag::public_key(keys("22").public_key()), + ]) + .sign_with_keys(signer) + .unwrap() +} + +fn delegation(enabled: bool, age: u64) -> Event { + let now = Timestamp::now().as_secs(); + let gateway = keys("44").public_key(); + let builder = if enabled { + buzz_sdk::gateway::Delegation::grant(gateway, now + 3_600, now - age) + } else { + buzz_sdk::gateway::Delegation::revoke(gateway, now - age) + }; + builder.unwrap().sign_with_keys(&keys("33")).unwrap() +} + +fn roster(gateway: bool) -> Event { + let mut tags = vec![Tag::parse(["d", CHANNEL]).unwrap()]; + for key in ["22", "33"].into_iter().chain(gateway.then_some("44")) { + tags.push(Tag::parse(["p", &keys(key).public_key().to_hex(), "", "member"]).unwrap()); + } + EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .sign_with_keys(&keys("11")) + .unwrap() +} + +/// Mention launch from a delegated gateway runs exactly like the owner's own +/// mention; an undelegated key never launches; a revocation stored after the +/// listener's snapshot is still enforced at launch time with a visible reply. +/// Mutations: drop the gateway authors from the listener filter or the +/// gateway branch in `eligible` → the first launch never happens; drop the +/// launch-time delegation check → the revoked mention launches a second +/// sandbox; let `Start::delegated` skip the signer check → stranger launches. +#[tokio::test] +async fn delegated_gateway_mentions_launch_until_revoked() { + let relay = relay::Relay::start().await; + let cluster = sandbox_fake::Shared::default(); + register(&cluster, &relay.url).await; + relay.events.lock().unwrap().push(delegation(true, 10)); + let stop = CancellationToken::new(); + let server = { + let manager = manager(&cluster); + let stop = stop.clone(); + let url = relay.url.clone(); + tokio::spawn(async move { + crate::serve::run_services(manager, &url, None, true, stop.cancelled()).await + }) + }; + relay.wait_subscription("mentions-history").await; + + let mut gateway = NostrWsConnection::connect_authenticated(&relay.url, &keys("44"), None) + .await + .unwrap(); + let mut stranger = NostrWsConnection::connect_authenticated(&relay.url, &keys("55"), None) + .await + .unwrap(); + let undelegated = signed_mention(&keys("55"), "@agent run as a stranger"); + assert!( + stranger + .send_event(undelegated.clone()) + .await + .unwrap() + .accepted + ); + let missing_member = signed_mention( + &keys("44"), + "@agent I have a grant but no channel membership", + ); + assert!( + gateway + .send_event(missing_member.clone()) + .await + .unwrap() + .accepted + ); + wait_for(|| { + relay.events.lock().unwrap().iter().any(|event| { + event.content.contains("not a current posting member") + && event + .tags + .iter() + .any(|tag| tag.as_slice().get(1) == Some(&missing_member.id.to_hex())) + }) + }) + .await; + assert!(cluster.lock().unwrap().sandboxes.is_empty()); + relay + .events + .lock() + .unwrap() + .retain(|event| event.kind.as_u16() != 39002); + relay.events.lock().unwrap().push(roster(true)); + let first = signed_mention(&keys("44"), "@agent run from the gateway"); + assert!(gateway.send_event(first.clone()).await.unwrap().accepted); + wait_for(|| completed(&cluster, &first)).await; + assert_eq!(cluster.lock().unwrap().sandboxes.len(), 1); + assert!(!completed(&cluster, &undelegated), "stranger was journaled"); + + // Removing channel membership revokes launch independently of the grant. + relay + .events + .lock() + .unwrap() + .retain(|event| event.kind.as_u16() != 39002); + relay.events.lock().unwrap().push(roster(false)); + let removed = signed_mention(&keys("44"), "@agent run after membership removal"); + assert!(gateway.send_event(removed.clone()).await.unwrap().accepted); + wait_for(|| { + relay.events.lock().unwrap().iter().any(|event| { + event.content.contains("not a current posting member") + && event + .tags + .iter() + .any(|tag| tag.as_slice().get(1) == Some(&removed.id.to_hex())) + }) + }) + .await; + assert_eq!(cluster.lock().unwrap().sandboxes.len(), 1); + relay + .events + .lock() + .unwrap() + .retain(|event| event.kind.as_u16() != 39002); + relay.events.lock().unwrap().push(roster(true)); + + // Revoke after the listener captured its gateway snapshot. + relay.events.lock().unwrap().push(delegation(false, 5)); + let revoked = signed_mention(&keys("44"), "@agent run after revocation"); + assert!(gateway.send_event(revoked.clone()).await.unwrap().accepted); + wait_for(|| { + relay.events.lock().unwrap().iter().any(|event| { + event.pubkey == keys("22").public_key() + && event.content.contains("not currently delegated") + && event + .tags + .iter() + .any(|tag| tag.as_slice().get(1) == Some(&revoked.id.to_hex())) + }) + }) + .await; + assert_eq!( + cluster.lock().unwrap().sandboxes.len(), + 1, + "a revoked gateway must not launch" + ); + stop.cancel(); + tokio::time::timeout(BOUND, server).await.unwrap().unwrap(); + relay.stop().await; +} diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs index 5c6b24959e6..e758d161404 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs @@ -143,12 +143,30 @@ fn matches(filter: &Value, event: &Event) -> bool { } async fn connection( - socket: tokio::net::TcpStream, + mut socket: tokio::net::TcpStream, stored: Arc>>, hub: broadcast::Sender, subscriptions: watch::Sender>, lose_next_ack: Arc, ) -> Result<(), Box> { + // The authority lookup reads NIP-11 from the same authenticated community + // origin. Peek without consuming the WebSocket handshake. + let mut prefix = [0_u8; 16_384]; + for _ in 0..64 { + let n = tokio::time::timeout(std::time::Duration::from_secs(1), socket.peek(&mut prefix)) + .await??; + let text = String::from_utf8_lossy(&prefix[..n]); + if text.contains("\r\n\r\n") { + if !text.to_ascii_lowercase().contains("sec-websocket-key:") { + let body = json!({"self":keys("11").public_key()}).to_string(); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_bytes()).await?; + return Ok(()); + } + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } let mut ws = tokio_tungstenite::accept_async(socket).await?; let frame = |value: Value| Message::Text(value.to_string().into()); ws.send(frame(json!(["AUTH", "challenge"]))).await?; @@ -175,7 +193,10 @@ async fn connection( assert!(author.is_some()); let id = input[1].as_str().unwrap().to_string(); let filter = input[2].clone(); - let allowed = |event: &Event| matches!(event.kind.as_u16(), 24210 | 30177) || event.tags.iter().any(|tag| tag.as_slice() == ["h", CHANNEL] || tag.as_slice() == ["d", CHANNEL]); + if matches!(id.as_str(), "mention-gateways" | "mention-gateway-profiles" | "mention-gateway-channel") { + assert_eq!(filter["consistency"], json!("strong")); + } + let allowed = |event: &Event| matches!(event.kind.as_u16(), 0 | 24210 | 30177 | 30181) || event.tags.iter().any(|tag| tag.as_slice() == ["h", CHANNEL] || tag.as_slice() == ["d", CHANNEL]); let mut history: Vec<_> = stored.lock().unwrap().iter().filter(|e| matches(&filter,e) && allowed(e)).cloned().collect(); history.sort_by_key(|e| std::cmp::Reverse((e.created_at, e.id))); history.truncate(filter["limit"].as_u64().unwrap_or(1000) as usize); diff --git a/crates/buzz-sandbox-reaper/src/mentions/worker.rs b/crates/buzz-sandbox-reaper/src/mentions/worker.rs index 31a10591a10..0b6a262eef7 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/worker.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/worker.rs @@ -20,9 +20,12 @@ const MAX_ATTEMPTS: u8 = 3; const MAX_EVENT_BYTES: usize = 60_000; enum LaunchPermission { - Allowed, + /// Deliverable; carries the trigger signer's fresh delegation when the + /// signer is a live owner-delegated gateway. + Allowed(Option), UnsupportedConversation, NoLongerRemote, + GatewayNotMember, } // A worker must renew its fenced journal before its longest external effect @@ -106,12 +109,22 @@ pub(super) async fn session( tokio::time::sleep(RECYCLE_AFTER).await; return Ok(()); } + // A delegation granted later is seen on the next bounded recycle; a + // revocation is enforced again at launch, so this snapshot only widens + // what is journaled, never what starts. + let gateways: Vec = super::gateways::read(&mut connection, identity) + .await? + .iter() + .map(buzz_sdk::gateway::Live::gateway) + .collect(); let mut connected_at = Timestamp::now().as_secs(); let started = tokio::time::Instant::now(); let mut filter = json!({"kinds": [9, 40002], "#h":channels, "#p": [identity.keys.public_key()]}); if !manager.sharing_enabled() { - filter["authors"] = json!([identity.owner]); + let mut authors = vec![identity.owner]; + authors.extend(gateways.iter().copied()); + filter["authors"] = json!(authors); } let mut live = filter.clone(); live["limit"] = json!(0); @@ -156,7 +169,13 @@ pub(super) async fn session( } oldest = oldest.min(event.created_at.as_secs()); } - let actionable = eligible(identity, journal, &event, manager.sharing_enabled()); + let actionable = eligible( + identity, + journal, + &event, + manager.sharing_enabled(), + Some(&gateways), + ); tracing::debug!(event_id=%event.id, agent=%identity.keys.public_key(), actionable,"received mention"); if actionable { let persisted = tokio::time::timeout(IO_TIMEOUT, journal.enqueue(*event)) @@ -234,38 +253,72 @@ async fn history( send(connection, json!(["REQ", "mentions-history", filter])).await } -fn eligible(identity: &Identity, journal: &Journal, event: &Event, shared: bool) -> bool { +/// Whether `event` may be journaled for launch. +/// +/// `gateways` is the listener's current snapshot of live delegated gateways. +/// `None` defers that judgement for an already journaled event: delivery +/// re-verifies the delegation before any launch. +fn eligible( + identity: &Identity, + journal: &Journal, + event: &Event, + shared: bool, + gateways: Option<&[nostr::PublicKey]>, +) -> bool { if event.created_at.as_secs() < journal.state.activated || serde_json::to_vec(event).map_or(true, |v| v.len() > MAX_EVENT_BYTES) { return false; } - if event.pubkey != identity.owner { - return shared - && buzz_sdk::sharing::task::scope( - event, - identity.keys.public_key(), - Timestamp::now().as_secs(), - ) - .is_ok(); + if event.pubkey == identity.owner { + return conversation(identity, event).is_ok(); + } + let delegated = gateways.is_none_or(|gateways| gateways.contains(&event.pubkey)); + if delegated && gateway_thread(identity, event).is_ok() { + return true; } - conversation(identity, event).is_ok() + shared + && buzz_sdk::sharing::task::scope( + event, + identity.keys.public_key(), + Timestamp::now().as_secs(), + ) + .is_ok() +} + +fn start_payload(identity: &Identity, event: &Event) -> serde_json::Value { + json!({"agent_pubkey":identity.keys.public_key(),"relay_url":identity.relay,"trigger":event}) +} + +/// The thread a non-owner signer's mention binds, as an owner mention would. +fn gateway_thread(identity: &Identity, event: &Event) -> Result<(String, String), Error> { + Start::thread_of( + start_payload(identity, event), + identity.owner, + event.pubkey, + Timestamp::now().as_secs(), + ) + .map_err(|_| Error::Registration) } fn conversation(identity: &Identity, event: &Event) -> Result<(String, String), Error> { if event.pubkey == identity.owner { - let start = Start::admit(json!({"agent_pubkey":identity.keys.public_key(),"relay_url":identity.relay,"trigger":event}), - identity.owner,Timestamp::now().as_secs()).map_err(|_|Error::Registration)?; - let (channel, root) = start.conversation(); - Ok((channel.into(), root.into())) - } else { - let scope = buzz_sdk::sharing::task::scope( - event, - identity.keys.public_key(), + let start = Start::admit( + start_payload(identity, event), + identity.owner, Timestamp::now().as_secs(), ) .map_err(|_| Error::Registration)?; + let (channel, root) = start.conversation(); + Ok((channel.into(), root.into())) + } else if let Ok(scope) = buzz_sdk::sharing::task::scope( + event, + identity.keys.public_key(), + Timestamp::now().as_secs(), + ) { Ok((scope.channel.to_string(), scope.root.to_hex())) + } else { + gateway_thread(identity, event) } } @@ -279,7 +332,13 @@ async fn drain( ) -> Result<(), Error> { while journal.state.pending.is_some() { let pending = journal.state.pending.as_ref().ok_or(Error::Storage)?; - if !eligible(identity, journal, &pending.event, manager.sharing_enabled()) { + if !eligible( + identity, + journal, + &pending.event, + manager.sharing_enabled(), + None, + ) { return Err(Error::Registration); } if pending.notice.is_some() { @@ -314,13 +373,24 @@ async fn drain( .await .map_err(|_| Error::Deadline)??; let result = match channel_launch_allowed(identity, &event).await { - Ok(LaunchPermission::Allowed) => tokio::time::timeout( + // A non-owner trigger needs a fresh live delegation unless the + // manager may still admit it as a shared task. + Ok(LaunchPermission::GatewayNotMember) => { + json!({"ok": false, "code": "gateway_not_member"}) + } + Ok(LaunchPermission::Allowed(None)) + if event.pubkey != identity.owner && !manager.sharing_enabled() => + { + json!({"ok": false, "code": "gateway_not_delegated"}) + } + Ok(LaunchPermission::Allowed(delegation)) => tokio::time::timeout( TURN_TIMEOUT, manager.start_mention( identity.owner, identity.keys.public_key(), &identity.relay, &event, + delegation.as_ref(), ), ) .await @@ -352,6 +422,8 @@ async fn drain( | "invalid_start" | "unsupported_conversation" | "shared_task_denied" + | "gateway_not_delegated" + | "gateway_not_member" ) || pending.attempts >= MAX_ATTEMPTS { pending.notice = Some(failure_notice(identity, &event, code)?); @@ -430,6 +502,8 @@ fn failure_notice(identity: &Identity, event: &Event, code: &str) -> Result "Start this agent once from desktop to register its launch settings, then mention me again.", "unsupported_conversation" => "Automatic remote startup requires a channel thread. Mention me in a channel, or manage this conversation from desktop.", "shared_task_denied" => "Shared task refused: current sharing permission, channel membership, or handoff budget does not permit this request. Ask the agent owner to check sharing.", + "gateway_not_member" => "This gateway is not a current posting member of this channel, so I did not start. Ask the channel owner to restore membership.", + "gateway_not_delegated" => "This gateway key is not currently delegated by my owner, so I did not start. Ask my owner to check `buzz gateway list`.", "shared_task_retry" => "Current sharing authorization is unavailable. Mention me again to retry; this request has not been allowed to run.", _ => "Agent startup could not be confirmed. Mention me again to retry.", }; @@ -458,6 +532,19 @@ async fn channel_launch_allowed( tokio::time::timeout(IO_TIMEOUT, async { let (channel, _) = conversation(identity,trigger)?; let mut connection = connect(identity).await?; + // Fresh, per launch: a revoked or expired delegation never starts. + let delegation = if trigger.pubkey == identity.owner { + None + } else { + super::gateways::read(&mut connection, identity) + .await? + .into_iter() + .find(|live| live.gateway() == trigger.pubkey) + }; + if delegation.is_some() && !super::gateways::member(&mut connection, identity, + channel.parse().map_err(|_| Error::Registration)?, trigger.pubkey).await? { + return Ok(LaunchPermission::GatewayNotMember); + } send(&mut connection, json!(["REQ", "mention-trigger", {"kinds":[9,40002],"ids":[trigger.id],"#h":[channel],"limit":1}])).await?; send(&mut connection, json!(["REQ", "mention-channel", {"kinds":[39000],"#d":[channel],"limit":1}])).await?; // Query the current coordinate without a runtime filter: an old remote @@ -498,7 +585,7 @@ async fn channel_launch_allowed( if trigger_done && channel_done && listing_done { if !remote { return Ok(LaunchPermission::NoLongerRemote); } return match (message_seen, metadata) { - (true, Some(true)) => Ok(LaunchPermission::Allowed), + (true, Some(true)) => Ok(LaunchPermission::Allowed(delegation)), (true, Some(false)) => Ok(LaunchPermission::UnsupportedConversation), _ => Err(Error::Relay), }; diff --git a/crates/buzz-sandbox-reaper/src/mobile_launch.rs b/crates/buzz-sandbox-reaper/src/mobile_launch.rs index e67ad87ffb4..90b033d0a74 100644 --- a/crates/buzz-sandbox-reaper/src/mobile_launch.rs +++ b/crates/buzz-sandbox-reaper/src/mobile_launch.rs @@ -170,6 +170,48 @@ impl Start { /// Verify the owner's signed mention and derive its immutable thread scope. pub fn admit(payload: Value, owner: PublicKey, request_time: u64) -> Result { + Self::admit_signed(payload, owner, owner, request_time) + } + + /// Verify a live owner-delegated gateway's signed mention and derive its + /// immutable thread scope. The launch runs exactly as the owner's own + /// mention would: same namespace, registration, and identity policy. The + /// gateway can never address itself or the owner as the agent. + pub(crate) fn delegated( + payload: Value, + delegation: &buzz_sdk::gateway::Live, + request_time: u64, + ) -> Result { + if !delegation.is_live_at(request_time) { + return Err(invalid()); + } + Self::admit_signed( + payload, + delegation.owner(), + delegation.gateway(), + request_time, + ) + } + + /// The channel and thread a `signer`'s mention would bind, for replies and + /// durable journaling only. This confers no launch authority. + pub(crate) fn thread_of( + payload: Value, + owner: PublicKey, + signer: PublicKey, + request_time: u64, + ) -> Result<(String, String), Value> { + let start = Self::admit_signed(payload, owner, signer, request_time)?; + let (channel, root) = start.conversation(); + Ok((channel.into(), root.into())) + } + + fn admit_signed( + payload: Value, + owner: PublicKey, + signer: PublicKey, + request_time: u64, + ) -> Result { let payload: StartPayload = serde_json::from_value(payload).map_err(|_| invalid())?; let event = payload.trigger; let tags: Vec<&[String]> = event.tags.iter().map(|tag| tag.as_slice()).collect(); @@ -182,8 +224,9 @@ impl Start { && t.get(1).map(String::as_str) == Some(payload.agent_pubkey.to_hex().as_str()) }); if event.verify().is_err() - || event.pubkey != owner + || event.pubkey != signer || payload.agent_pubkey == owner + || payload.agent_pubkey == signer || !matches!(event.kind.as_u16(), 9 | 40002) || event.created_at.as_secs() > request_time.saturating_add(60) || !addressed diff --git a/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs b/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs index a44b7066416..2affee457de 100644 --- a/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs +++ b/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs @@ -83,6 +83,61 @@ fn rejects_forged_wrong_owner_unaddressed_ambiguous_and_future_triggers() { assert!(Start::admit(value, owner().public_key(), event.created_at.as_secs() - 61).is_err()); } +/// A delegated start binds exactly like the owner's mention, but only for the +/// live gateway's own signature, never for an expired delegation, and never +/// with the gateway or the owner addressed as the agent. +/// Mutations: drop the `is_live_at` check → "expired" admits; check the +/// owner instead of the signer → "gateway" refuses and "owner signed" admits. +#[test] +fn delegated_start_requires_the_live_gateway_signature() { + let gateway = Keys::parse(&"44".repeat(32)).unwrap(); + let at = 1_800_000_000; + let head = |created_at: u64, expires_at: u64| { + buzz_sdk::gateway::Delegation::grant(gateway.public_key(), expires_at, created_at) + .unwrap() + .sign_with_keys(&owner()) + .unwrap() + }; + let live = buzz_sdk::gateway::live( + &head(at - 10, at + 3_600), + owner().public_key(), + gateway.public_key(), + at, + ) + .unwrap(); + let signed = |signer: &Keys, agent_key: nostr::PublicKey| { + let event = EventBuilder::new(Kind::Custom(9), "@agent investigate this") + .tags([ + Tag::parse(["h", CHANNEL]).unwrap(), + Tag::public_key(agent_key), + ]) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(signer) + .unwrap(); + json!({"agent_pubkey": agent_key, "relay_url": RELAY, "trigger": event}) + }; + let started = Start::delegated(signed(&gateway, agent().public_key()), &live, at).unwrap(); + assert_eq!(started.owner, owner().public_key()); + assert_eq!(started.agent(), agent().public_key()); + assert_eq!(started.scope["channel_id"], CHANNEL); + assert!( + Start::delegated(signed(&owner(), agent().public_key()), &live, at).is_err(), + "owner signed" + ); + assert!( + Start::delegated(signed(&Keys::generate(), agent().public_key()), &live, at).is_err(), + "stranger signed" + ); + assert!( + Start::delegated(signed(&gateway, gateway.public_key()), &live, at).is_err(), + "gateway addressed as the agent" + ); + assert!( + Start::delegated(signed(&gateway, agent().public_key()), &live, at + 3_600).is_err(), + "expired" + ); +} + #[tokio::test] async fn durable_registration_survives_manager_restart_and_rebinds_each_thread() { let state: sandbox_fake::Shared = Arc::new(Mutex::new(Default::default())); diff --git a/crates/buzz-sandbox-reaper/src/shared_tasks.rs b/crates/buzz-sandbox-reaper/src/shared_tasks.rs index 535e5363728..dcf80fad63b 100644 --- a/crates/buzz-sandbox-reaper/src/shared_tasks.rs +++ b/crates/buzz-sandbox-reaper/src/shared_tasks.rs @@ -52,7 +52,7 @@ pub(crate) async fn verify( .await } -async fn relay_key(relay: &str) -> Result { +pub(crate) async fn relay_key(relay: &str) -> Result { let mut url = url::Url::parse(relay).map_err(|_| policy::Error::Unavailable)?; let scheme = match url.scheme() { "wss" => "https", diff --git a/crates/buzz-sdk/src/gateway.rs b/crates/buzz-sdk/src/gateway.rs new file mode 100644 index 00000000000..b7bf3995e52 --- /dev/null +++ b/crates/buzz-sdk/src/gateway.rs @@ -0,0 +1,416 @@ +//! Owner-delegated gateway keys for driving the owner's own remote agents. +//! +//! A gateway is a separate key, for example an always-on orchestrator, that +//! the owner explicitly delegates with a signed, expiring, revocable kind +//! [`KIND_AGENT_GATEWAY_DELEGATION`] head. A live delegation lets the gateway +//! do exactly what [`Scope::RemoteAgents`] names: mention-launch the owner's +//! remote agents, prompt them in their channel threads, cancel their turns, +//! and read their observer telemetry. It never confers ownership, agent +//! identity, channel or relay membership, or access to anyone else's agents. +//! +//! Gateway authority and agent authority are disjoint by construction: +//! +//! - A gateway is named only by an owner-signed delegation head. A NIP-OA +//! `auth` attestation, which every owned agent carries, is never gateway +//! authority, and a delegation head is never agent authority. +//! - A key whose kind:0 profile carries any `auth` tag claims to be an agent, +//! so [`resolve`] refuses it as a gateway even when a delegation names it. +//! Consumers additionally refuse the owner and the consuming agent itself, +//! and the relay refuses any key it has recorded as an agent. +//! +//! Every verdict fails closed: a malformed, expired, revoked, future-dated, or +//! over-long head is "not delegated", and callers treat a lookup failure the +//! same way. Only [`live`] and [`resolve`] construct a [`Live`] value, so code +//! holding one holds proof of a verified owner signature. + +use std::collections::BTreeMap; + +use nostr::{Event, EventBuilder, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; + +use crate::{kind::KIND_AGENT_GATEWAY_DELEGATION, SdkError}; + +/// Longest delegation lifetime, measured from the head's `created_at`. +pub const MAX_DELEGATION_SECS: u64 = 90 * 24 * 60 * 60; + +/// Most live gateways one owner may have. Consumers act on at most this many, +/// newest first, and the harness encrypts each telemetry frame once per live +/// gateway, so the cap also bounds telemetry fan-out. +pub const MAX_LIVE_GATEWAYS: usize = 2; + +/// Largest accepted clock skew for a head's `created_at` in the future. +pub const MAX_FUTURE_SKEW_SECS: u64 = 60; + +/// Most heads a consumer reads per owner in one lookup. Live heads beyond this +/// window are not seen, which fails closed. +pub const LOOKUP_LIMIT: usize = 32; + +/// What a delegation permits. Unknown scopes do not deserialize, so a future +/// scope can never widen an older consumer's acceptance. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +pub enum Scope { + /// Drive the signer's own remote agents: thread prompts, mention launch, + /// `cancel_turn` control frames, and observer telemetry. + #[serde(rename = "remote-agents")] + RemoteAgents, +} + +/// The signed content of one delegation head. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Delegation { + /// Wire version; currently one. + pub version: u32, + /// The delegated gateway key. + pub gateway: PublicKey, + /// The only permitted scope. + pub scope: Scope, + /// `false` is a durable revocation head. + pub enabled: bool, +} + +/// A verified, unexpired delegation from `owner` to `gateway`. +/// +/// Fields are private: the only constructors are [`live`] and [`resolve`], +/// which verify the owner's signature and the head's lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Live { + owner: PublicKey, + gateway: PublicKey, + expires_at: u64, + created_at: u64, +} + +impl Live { + /// The delegating owner (the head's signer). + pub fn owner(&self) -> PublicKey { + self.owner + } + + /// The delegated gateway key. + pub fn gateway(&self) -> PublicKey { + self.gateway + } + + /// NIP-40 expiration, unix seconds. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// The head's `created_at`, unix seconds. + pub fn created_at(&self) -> u64 { + self.created_at + } + + /// Whether this delegation is still unexpired at `now`. + pub fn is_live_at(&self, now: u64) -> bool { + self.expires_at > now + } +} + +impl Delegation { + /// Build a grant head for `gateway` that expires at `expires_at`. + /// The caller signs with the owner's key and must set `created_at` newer + /// than any existing head for the same gateway. + pub fn grant( + gateway: PublicKey, + expires_at: u64, + created_at: u64, + ) -> Result { + if !lifetime_ok(created_at, expires_at) { + return Err(SdkError::InvalidInput(format!( + "gateway delegation must expire within {} days", + MAX_DELEGATION_SECS / 86_400 + ))); + } + Self::build(gateway, true, Some(expires_at), created_at) + } + + /// Build a revocation head for `gateway`. The caller signs with the owner's + /// key and must set `created_at` newer than the current head. + pub fn revoke(gateway: PublicKey, created_at: u64) -> Result { + Self::build(gateway, false, None, created_at) + } + + fn build( + gateway: PublicKey, + enabled: bool, + expires_at: Option, + created_at: u64, + ) -> Result { + let content = serde_json::to_string(&Self { + version: 1, + gateway, + scope: Scope::RemoteAgents, + enabled, + }) + .map_err(|e| SdkError::InvalidInput(e.to_string()))?; + let mut tags = vec![ + vec!["d".to_string(), gateway.to_hex()], + vec!["p".to_string(), gateway.to_hex()], + ]; + if let Some(expires_at) = expires_at { + tags.push(vec!["expiration".to_string(), expires_at.to_string()]); + } + let tags = tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| SdkError::InvalidTag(e.to_string()))?; + Ok( + EventBuilder::new(Kind::Custom(KIND_AGENT_GATEWAY_DELEGATION as u16), content) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)), + ) + } + + /// Verify the signature, kind, version, unambiguous signed scope, and + /// lifetime shape of a head. Returns the content and its expiration. + /// + /// An enabled head must carry exactly one canonical `expiration` after its + /// `created_at` and within [`MAX_DELEGATION_SECS`]; a revocation head must + /// carry none, so no NIP-40 purge can ever remove a revocation. Liveness at + /// a given time is checked separately by [`live`]. + pub fn parse(event: &Event) -> Result<(Self, Option), SdkError> { + if u32::from(event.kind.as_u16()) != KIND_AGENT_GATEWAY_DELEGATION + || event.verify().is_err() + { + return Err(invalid("invalid delegation signature or kind")); + } + let delegation: Self = serde_json::from_str(&event.content) + .map_err(|_| invalid("invalid delegation content"))?; + if delegation.version != 1 || delegation.gateway == event.pubkey { + return Err(invalid("invalid delegation version or gateway")); + } + let gateway = delegation.gateway.to_hex(); + for name in ["d", "p"] { + match single(event, name)? { + Some(value) if value == gateway => {} + _ => return Err(invalid("ambiguous delegation scope")), + } + } + let expiration = match single(event, "expiration")? { + Some(text) => Some(canonical_u64(text)?), + None => None, + }; + match (delegation.enabled, expiration) { + (true, Some(expires_at)) if lifetime_ok(event.created_at.as_secs(), expires_at) => {} + (true, _) => { + return Err(invalid( + "an enabled delegation requires a bounded expiration", + )) + } + (false, None) => {} + (false, Some(_)) => { + return Err(invalid("a revocation must not carry an expiration")); + } + } + Ok((delegation, expiration)) + } +} + +/// The live delegation `event` carries from `owner` to `gateway` at `now`, or +/// `None`. Expired, revoked, future-dated, and over-long heads are not live. +/// +/// This judges one head in isolation. Use [`resolve`] over relay results so a +/// newer revocation for the same coordinate always wins. +pub fn live(event: &Event, owner: PublicKey, gateway: PublicKey, now: u64) -> Option { + if event.pubkey != owner { + return None; + } + let (delegation, expiration) = Delegation::parse(event).ok()?; + let expires_at = expiration?; + let created_at = event.created_at.as_secs(); + let live = delegation.enabled + && delegation.gateway == gateway + && created_at <= now.saturating_add(MAX_FUTURE_SKEW_SECS) + && expires_at > now; + live.then_some(Live { + owner, + gateway, + expires_at, + created_at, + }) +} + +/// Whether a kind:0 profile claims agent identity: any `auth` tag at all. +/// Such a key is never accepted as a gateway, whoever the claimed owner is. +pub fn profile_claims_agent(profile: &Event) -> bool { + profile + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")) +} + +/// `owner`'s live delegations in `delegations`, before the agent-identity and +/// exclusion checks of [`resolve`]: newest first, at most +/// [`MAX_LIVE_GATEWAYS`]. Callers use the gateway keys to fetch the kind:0 +/// profiles [`resolve`] needs. +/// +/// For each coordinate (the first `d` tag, as NIP-33 addresses it) only the +/// newest correctly signed head counts, ties to the lowest id. That head must +/// itself parse and be live: a stale grant returned beside a newer revocation +/// or a newer malformed head never wins. +pub fn live_heads(owner: PublicKey, delegations: &[Event], now: u64) -> Vec { + let mut heads: BTreeMap<&str, &Event> = BTreeMap::new(); + for event in delegations { + if event.pubkey != owner + || u32::from(event.kind.as_u16()) != KIND_AGENT_GATEWAY_DELEGATION + || event.verify().is_err() + { + continue; + } + let Some(coordinate) = first_d_tag(event) else { + continue; + }; + let newer = heads.get(coordinate).is_none_or(|head| { + (event.created_at, std::cmp::Reverse(event.id)) + > (head.created_at, std::cmp::Reverse(head.id)) + }); + if newer { + heads.insert(coordinate, event); + } + } + let mut live: Vec = heads + .values() + .filter_map(|head| { + let (delegation, _) = Delegation::parse(head).ok()?; + live(head, owner, delegation.gateway, now) + }) + .collect(); + live.sort_by_key(|live| (std::cmp::Reverse(live.created_at), live.gateway.to_hex())); + live.truncate(MAX_LIVE_GATEWAYS); + live +} + +/// Resolve `owner`'s live gateways from relay results. +/// +/// Starts from [`live_heads`], then refuses gateways in `excluded` (the owner, +/// the consuming agent) and any key whose correctly signed kind:0 in +/// `profiles` claims agent identity. Refused keys still occupy their slot in +/// the [`MAX_LIVE_GATEWAYS`] window, so every consumer acts on the same set. +pub fn resolve( + owner: PublicKey, + delegations: &[Event], + profiles: &[Event], + excluded: &[PublicKey], + now: u64, +) -> Vec { + let claims_agent = |gateway: &PublicKey| { + profiles.iter().any(|profile| { + profile.pubkey == *gateway + && profile.kind == Kind::Metadata + && profile.verify().is_ok() + && profile_claims_agent(profile) + }) + }; + live_heads(owner, delegations, now) + .into_iter() + .filter(|live| !excluded.contains(&live.gateway) && !claims_agent(&live.gateway)) + .collect() +} + +/// Build and sign an owner delegation with the current timestamp. +/// +/// `expires_at` is a Unix timestamp, at most 90 days from now. The caller +/// submits this event to its community relay with the owner's identity. +pub fn build_gateway_grant( + owner_keys: &nostr::Keys, + gateway: PublicKey, + expires_at: u64, +) -> Result { + if owner_keys.public_key() == gateway { + return Err(invalid("the gateway must be a separate key")); + } + Delegation::grant(gateway, expires_at, Timestamp::now().as_secs())? + .sign_with_keys(owner_keys) + .map_err(|_| invalid("could not sign gateway delegation")) +} + +/// Whether a signer has a live verified owner delegation in these relay +/// results. Supply current gateway profiles so agent identities are excluded. +/// Consumers must also enforce community and channel membership separately. +pub fn is_live_delegate( + owner: PublicKey, + signer: PublicKey, + grants: &[Event], + profiles: &[Event], + now: u64, +) -> bool { + resolve(owner, grants, profiles, &[owner], now) + .iter() + .any(|grant| grant.gateway() == signer) +} + +/// Whether `key` has current posting membership in a relay-signed channel +/// roster (kind39002). Callers must fetch the newest roster from the writer. +/// Missing, malformed, duplicated, guest, or foreign-channel entries deny. +pub fn channel_member( + roster: &Event, + relay: PublicKey, + channel: uuid::Uuid, + key: PublicKey, +) -> bool { + if u32::from(roster.kind.as_u16()) != crate::kind::KIND_NIP29_GROUP_MEMBERS + || roster.pubkey != relay + || roster.verify().is_err() + || single(roster, "d").ok().flatten() != Some(channel.to_string().as_str()) + { + return false; + } + let key = key.to_hex(); + let entries: Vec<_> = roster + .tags + .iter() + .map(Tag::as_slice) + .filter(|tag| tag.first().map(String::as_str) == Some("p") && tag.get(1) == Some(&key)) + .collect(); + match entries.as_slice() { + [entry] => entry + .get(3) + .and_then(|role| role.parse::().ok()) + .is_some_and(|role| role.can_post()), + _ => false, + } +} + +fn lifetime_ok(created_at: u64, expires_at: u64) -> bool { + expires_at > created_at && expires_at - created_at <= MAX_DELEGATION_SECS +} + +fn first_d_tag(event: &Event) -> Option<&str> { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "d" => Some(value.as_str()), + _ => None, + }) +} + +fn single<'a>(event: &'a Event, name: &str) -> Result, SdkError> { + let mut tags = event + .tags + .iter() + .map(Tag::as_slice) + .filter(|tag| tag.first().map(String::as_str) == Some(name)); + let first = tags.next(); + if tags.next().is_some() || first.is_some_and(|tag| tag.len() != 2) { + return Err(invalid("ambiguous delegation tag")); + } + Ok(first.map(|tag| tag[1].as_str())) +} + +fn canonical_u64(text: &str) -> Result { + let value: u64 = text + .parse() + .map_err(|_| invalid("invalid delegation expiration"))?; + if value.to_string() != text { + return Err(invalid("invalid delegation expiration")); + } + Ok(value) +} + +fn invalid(message: &str) -> SdkError { + SdkError::InvalidInput(message.into()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/gateway/tests.rs b/crates/buzz-sdk/src/gateway/tests.rs new file mode 100644 index 00000000000..3141998b8c9 --- /dev/null +++ b/crates/buzz-sdk/src/gateway/tests.rs @@ -0,0 +1,400 @@ +use super::*; +use nostr::Keys; + +const NOW: u64 = 1_800_000_000; +const DAY: u64 = 86_400; + +fn keys(byte: &str) -> Keys { + Keys::parse(&byte.repeat(32)).unwrap() +} + +fn owner() -> Keys { + keys("33") +} + +fn gateway() -> Keys { + keys("44") +} + +fn grant(signer: &Keys, gateway: PublicKey, created_at: u64, expires_at: u64) -> Event { + Delegation::build(gateway, true, Some(expires_at), created_at) + .unwrap() + .sign_with_keys(signer) + .unwrap() +} + +fn revoke(signer: &Keys, gateway: PublicKey, created_at: u64) -> Event { + Delegation::revoke(gateway, created_at) + .unwrap() + .sign_with_keys(signer) + .unwrap() +} + +fn raw(signer: &Keys, content: &str, tags: &[&[&str]], created_at: u64) -> Event { + EventBuilder::new(Kind::Custom(KIND_AGENT_GATEWAY_DELEGATION as u16), content) + .tags( + tags.iter() + .map(|tag| Tag::parse(tag.iter().copied()).unwrap()), + ) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(signer) + .unwrap() +} + +#[test] +fn builders_round_trip_and_refuse_unbounded_lifetimes() { + let gw = gateway().public_key(); + let event = Delegation::grant(gw, NOW + DAY, NOW) + .unwrap() + .sign_with_keys(&owner()) + .unwrap(); + let (delegation, expiration) = Delegation::parse(&event).unwrap(); + assert_eq!(delegation.gateway, gw); + assert_eq!(delegation.scope, Scope::RemoteAgents); + assert!(delegation.enabled); + assert_eq!(expiration, Some(NOW + DAY)); + assert_eq!(event.created_at.as_secs(), NOW); + + for expires_at in [NOW, NOW - 1, NOW + MAX_DELEGATION_SECS + 1] { + assert!( + Delegation::grant(gw, expires_at, NOW).is_err(), + "{expires_at}" + ); + } + assert!(Delegation::grant(gw, NOW + MAX_DELEGATION_SECS, NOW).is_ok()); + + let revoked = revoke(&owner(), gw, NOW); + let (delegation, expiration) = Delegation::parse(&revoked).unwrap(); + assert!(!delegation.enabled); + assert_eq!(expiration, None); +} + +#[test] +fn only_an_exact_unexpired_owner_head_is_live() { + let o = owner().public_key(); + let gw = gateway().public_key(); + let gw_hex = gw.to_hex(); + let good = + format!(r#"{{"version":1,"gateway":"{gw_hex}","scope":"remote-agents","enabled":true}}"#); + let exp = (NOW + DAY).to_string(); + let cases: Vec<(&str, Event, bool)> = vec![ + ("valid", grant(&owner(), gw, NOW, NOW + DAY), true), + ( + "expired", + grant(&owner(), gw, NOW - 2 * DAY, NOW - 1), + false, + ), + ("expires now", grant(&owner(), gw, NOW - DAY, NOW), false), + ( + "lifetime over the cap", + grant(&owner(), gw, NOW - 1, NOW + MAX_DELEGATION_SECS), + false, + ), + ( + "created beyond clock skew", + grant(&owner(), gw, NOW + MAX_FUTURE_SKEW_SECS + 1, NOW + DAY), + false, + ), + ( + "created within clock skew", + grant(&owner(), gw, NOW + MAX_FUTURE_SKEW_SECS, NOW + DAY), + true, + ), + ("revoked head", revoke(&owner(), gw, NOW), false), + ( + "signed by someone else", + grant(&keys("55"), gw, NOW, NOW + DAY), + false, + ), + ( + "names another gateway", + grant(&owner(), keys("66").public_key(), NOW, NOW + DAY), + false, + ), + ( + "missing expiration", + raw(&owner(), &good, &[&["d", &gw_hex], &["p", &gw_hex]], NOW), + false, + ), + ( + "non-canonical expiration", + raw( + &owner(), + &good, + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["expiration", &format!("0{exp}")], + ], + NOW, + ), + false, + ), + ( + "duplicate expiration", + raw( + &owner(), + &good, + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["expiration", &exp], + &["expiration", &exp], + ], + NOW, + ), + false, + ), + ( + "second p tag", + raw( + &owner(), + &good, + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["p", &keys("66").public_key().to_hex()], + &["expiration", &exp], + ], + NOW, + ), + false, + ), + ( + "d tag names another key", + raw( + &owner(), + &good, + &[ + &["d", &keys("66").public_key().to_hex()], + &["p", &gw_hex], + &["expiration", &exp], + ], + NOW, + ), + false, + ), + ( + "unknown scope", + raw( + &owner(), + &good.replace("remote-agents", "everything"), + &[&["d", &gw_hex], &["p", &gw_hex], &["expiration", &exp]], + NOW, + ), + false, + ), + ( + "unknown field", + raw( + &owner(), + &good.replace("\"enabled\":true", "\"enabled\":true,\"dm\":true"), + &[&["d", &gw_hex], &["p", &gw_hex], &["expiration", &exp]], + NOW, + ), + false, + ), + ( + "version two", + raw( + &owner(), + &good.replace("\"version\":1", "\"version\":2"), + &[&["d", &gw_hex], &["p", &gw_hex], &["expiration", &exp]], + NOW, + ), + false, + ), + ]; + for (name, event, expected) in cases { + assert_eq!(live(&event, o, gw, NOW).is_some(), expected, "{name}"); + } + + let self_delegation = grant(&owner(), o, NOW, NOW + DAY); + assert!(Delegation::parse(&self_delegation).is_err()); + let mut tampered = grant(&owner(), gw, NOW, NOW + DAY); + tampered.content = tampered.content.replace("true", "false"); + assert!(Delegation::parse(&tampered).is_err()); +} + +#[test] +fn resolve_uses_the_newest_head_and_refuses_agents_and_excluded_keys() { + let o = owner().public_key(); + let gw = gateway().public_key(); + let agent = keys("77"); + let auth = crate::nip_oa::compute_auth_tag(&owner(), &agent.public_key(), "").unwrap(); + let agent_profile = EventBuilder::new(Kind::Metadata, "{}") + .tag(crate::nip_oa::parse_auth_tag(&auth).unwrap()) + .sign_with_keys(&agent) + .unwrap(); + let plain_profile = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&gateway()) + .unwrap(); + // A profile with any auth tag, even a foreign or invalid one, claims agency. + let foreign_claim = EventBuilder::new(Kind::Metadata, "{}") + .tag(Tag::parse(["auth", &o.to_hex(), "", &"0".repeat(128)]).unwrap()) + .sign_with_keys(&gateway()) + .unwrap(); + + let grant_old = grant(&owner(), gw, NOW - 10, NOW + DAY); + let revoke_new = revoke(&owner(), gw, NOW - 5); + let grant_newest = grant(&owner(), gw, NOW - 1, NOW + DAY); + let agent_grant = grant(&owner(), agent.public_key(), NOW, NOW + DAY); + let gateways = |events: &[Event], profiles: &[Event], excluded: &[PublicKey]| { + resolve(o, events, profiles, excluded, NOW) + .into_iter() + .map(|live| live.gateway()) + .collect::>() + }; + + assert_eq!( + gateways(std::slice::from_ref(&grant_old), &[], &[]), + vec![gw] + ); + // A stale grant returned beside a newer revocation never wins, in any order. + assert!(gateways(&[grant_old.clone(), revoke_new.clone()], &[], &[]).is_empty()); + assert!(gateways(&[revoke_new.clone(), grant_old.clone()], &[], &[]).is_empty()); + assert_eq!( + gateways(&[grant_old.clone(), revoke_new, grant_newest], &[], &[]), + vec![gw] + ); + assert_eq!( + gateways(std::slice::from_ref(&grant_old), &[plain_profile], &[]), + vec![gw] + ); + assert!(gateways(std::slice::from_ref(&grant_old), &[foreign_claim], &[]).is_empty()); + // An owner's own agent can never pass as the owner's gateway. + assert!(gateways(&[agent_grant], &[agent_profile], &[]).is_empty()); + assert!(gateways(std::slice::from_ref(&grant_old), &[], &[gw]).is_empty()); + // Another signer's head is ignored, not merged. + assert!(gateways(&[grant(&keys("55"), gw, NOW, NOW + DAY)], &[], &[]).is_empty()); +} + +#[test] +fn resolve_caps_live_gateways_newest_first() { + let o = owner().public_key(); + let heads: Vec = ["a1", "a2", "a3"] + .iter() + .enumerate() + .map(|(i, byte)| { + grant( + &owner(), + keys(byte).public_key(), + NOW - 10 + i as u64, + NOW + DAY, + ) + }) + .collect(); + let live = resolve(o, &heads, &[], &[], NOW); + assert_eq!(live.len(), MAX_LIVE_GATEWAYS); + assert_eq!(live[0].gateway(), keys("a3").public_key()); + assert_eq!(live[1].gateway(), keys("a2").public_key()); + // An excluded key keeps its slot, so every consumer acts on the same set. + let excluded = resolve(o, &heads, &[], &[keys("a3").public_key()], NOW); + assert_eq!(excluded.len(), 1); + assert_eq!(excluded[0].gateway(), keys("a2").public_key()); +} + +#[test] +fn a_newer_unparseable_head_hides_an_older_grant() { + let o = owner().public_key(); + let gw = gateway().public_key(); + let gw_hex = gw.to_hex(); + let older = grant(&owner(), gw, NOW - 10, NOW + DAY); + // A newer head for the same coordinate that is not a valid delegation + // (here: a revocation that illegally carries an expiration) is still the + // coordinate's head, so the older grant must not resurface. + let revoked_with_expiry = raw( + &owner(), + &format!(r#"{{"version":1,"gateway":"{gw_hex}","scope":"remote-agents","enabled":false}}"#), + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["expiration", &(NOW + DAY).to_string()], + ], + NOW - 5, + ); + assert!(Delegation::parse(&revoked_with_expiry).is_err()); + assert!(live_heads(o, &[older.clone(), revoked_with_expiry.clone()], NOW).is_empty()); + assert!(live_heads(o, &[revoked_with_expiry, older.clone()], NOW).is_empty()); + // A forged newer head (bad signature) is not the owner's and is ignored. + let mut forged = revoke(&owner(), gw, NOW - 1); + forged.content = forged.content.replace("false", "true"); + assert_eq!(live_heads(o, &[older, forged], NOW).len(), 1); +} + +#[test] +fn signed_grant_and_delegate_verifier_bind_the_owner_signature() { + let owner = owner(); + let gateway = gateway(); + let now = Timestamp::now().as_secs(); + let event = + build_gateway_grant(&owner, gateway.public_key(), now + MAX_DELEGATION_SECS).unwrap(); + assert!(is_live_delegate( + owner.public_key(), + gateway.public_key(), + std::slice::from_ref(&event), + &[], + now + )); + assert!(!is_live_delegate( + keys("55").public_key(), + gateway.public_key(), + &[event], + &[], + now + )); + assert!(build_gateway_grant(&owner, owner.public_key(), now + DAY).is_err()); + assert!(build_gateway_grant(&owner, gateway.public_key(), now).is_err()); + assert!( + build_gateway_grant(&owner, gateway.public_key(), now + MAX_DELEGATION_SECS + 60).is_err() + ); +} + +#[test] +fn channel_authority_requires_current_relay_signature_exact_channel_and_posting_role() { + let relay = keys("11"); + let channel = uuid::Uuid::new_v4(); + let member = gateway().public_key(); + let roster = |signer: &Keys, channel: uuid::Uuid, role: &str, duplicate: bool| { + let entry = Tag::parse(["p", &member.to_hex(), "", role]).unwrap(); + let mut tags = vec![ + Tag::parse(["d", &channel.to_string()]).unwrap(), + entry.clone(), + ]; + if duplicate { + tags.push(entry); + } + EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .sign_with_keys(signer) + .unwrap() + }; + for (event, expected) in [ + (roster(&relay, channel, "member", false), true), + (roster(&relay, channel, "admin", false), true), + (roster(&relay, channel, "guest", false), false), + (roster(&relay, channel, "unknown", false), false), + (roster(&relay, channel, "member", true), false), + (roster(&relay, uuid::Uuid::new_v4(), "member", false), false), + (roster(&keys("22"), channel, "member", false), false), + ] { + assert_eq!( + channel_member(&event, relay.public_key(), channel, member), + expected + ); + assert!(!channel_member( + &event, + relay.public_key(), + channel, + keys("55").public_key() + )); + } + let mut tampered = roster(&relay, channel, "member", false); + tampered.content = "forged".into(); + assert!(!channel_member( + &tampered, + relay.public_key(), + channel, + member + )); +} diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 68fb0d394bb..4b47b066bc2 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -14,6 +14,7 @@ pub mod broker; pub mod builders; +pub mod gateway; pub mod mentions; pub mod nip_oa; pub mod sharing; diff --git a/docs/agent-gateway-key.md b/docs/agent-gateway-key.md new file mode 100644 index 00000000000..ff86480f3f8 --- /dev/null +++ b/docs/agent-gateway-key.md @@ -0,0 +1,141 @@ +# Gateway keys for remote agents + +A gateway drives your remote agents with its own key. Your main Nostr key stays +with you. Existing Buzz apps continue using your main key without a grant. Direct-message +sandboxes and unbound harness sessions never enable gateway access. + +## Authority + +An owner signs a kind `30181` addressable event naming one gateway. The +coordinate is `(30181, owner, gateway hex)`. Enabled heads require a NIP-40 +`expiration` within 90 days. A newer disabled head revokes the grant permanently +until a newer enabled head replaces it. + +```mermaid +sequenceDiagram + participant Owner + participant Relay + participant Gateway + participant Agent + Owner->>Relay: Signed expiring grant naming gateway + Gateway->>Relay: Signed thread prompt mentioning agent + Agent->>Relay: Read current owner grant and gateway profile + Agent->>Agent: Verify grant, membership and thread scope + Agent->>Relay: Telemetry encrypted separately to owner and gateway + Owner->>Relay: Newer disabled head + Agent->>Relay: Refresh grant within 30 seconds + Agent->>Agent: Refuse further gateway prompts and control +``` + +| Path | Authorization | +| --- | --- | +| Mention launch | Current signed owner grant, current remote-agent registration, visible trigger and channel | +| Thread prompt | Bound channel thread in strict owner mode; live grant checked at ingress and again before queued model dispatch | +| Turn cancellation | Live grant; signed, fresh NIP-44 observer control; command exactly `cancel_turn` | +| Observer telemetry | Agent ownership verified by relay; separate NIP-44 copy for each live gateway | +| Owner DMs, profile and channel administration | No delegated authority | +| Model changes and owner announcements | Owner key required | +| Explicit checkpoint/fresh recovery requests | Not delegated; existing agent-signed deployment remains required | + +A gateway remains a separate ordinary relay member. A grant provides neither +relay membership nor channel membership. It does not impersonate the owner. +Normal actions permitted to that separate account remain available, including +posting its own records into an explicitly joined recording channel. + +Only the newest signed head per coordinate counts. Consumers accept at most two +live gateways, ordered by grant timestamp and then gateway key. They read at most +32 heads per owner. Grants outside that window fail closed. + +Agent keys never serve as gateways. The SDK refuses a gateway profile carrying +an `auth` tag. The relay also refuses keys recorded as owned agents. NIP-OA +owner attestations cannot substitute for gateway grants. + +## Enroll and grant + +The manager provisions a distinct gateway key in the per-developer daemon +Kubernetes Secret. Its enrollment reply returns only `gateway_pubkey`. +It never returns the gateway secret key. Provisioning and onboarding have +separate PRs; deploy those before using this enrollment path. + +1. Enroll with the owner key using the Buzz app or `buzz onboard`. +2. Ask a relay operator to admit the returned gateway public key as a member. +3. Add that public key to each channel the daemon should use. +4. Run the grant command with your owner key. + +```sh +# Relay operator, using the configured admin environment: +buzz-admin add-member --pubkey "$GATEWAY_PUBKEY" --role member + +# Channel administrator: +buzz channels add-member --channel "$CHANNEL_ID" --pubkey "$GATEWAY_PUBKEY" + +# Owner (BUZZ_PRIVATE_KEY is your owner key): +buzz gateway grant "$GATEWAY_PUBKEY" --expires 90d +buzz gateway list +buzz gateway revoke "$GATEWAY_PUBKEY" +``` + +The CLI refuses NIP-OA agent credentials for grant management. It reads the +current head before writing and advances its timestamp when two actions occur +within the same second. Conflicting writes fail visibly and can be retried. +`list` returns the latest 32 heads, including expired grants and revocations. + +For key rotation, revoke the old public key first. An operator then deletes the +daemon Secret, and the owner enrolls again to generate new credentials. Admit +and grant the new public key. Remove the old key's relay and channel membership +when its records no longer need to remain accessible to that account. + +## Revocation and failure behavior + +Revocation uses a newer disabled replacement, without expiration. The relay +ignores NIP-09 deletion of gateway heads. Deleting a head could otherwise let an +old signed grant be replayed into an empty coordinate. Use `gateway revoke`. + +Gateway authority also requires current posting membership in the bound channel. +The harness strongly reads the latest kind39002 roster signed by the current +NIP-11 relay key. The owner, agent, and gateway must all remain posting members. +A global grant never admits a gateway into a private channel. Membership removal +stops gateway prompts, cancellation, and telemetry after the 30-second cache +window. The launcher rechecks membership immediately before each launch. + +For an explicit strong kind39002 lookup with the current relay author and one channel +coordinate, the relay checks canonical writer membership behind its existing +membership lock. A stale discovery projection is repaired before returning it. +Only a current channel member can trigger or receive that repair. Unchanged +rosters keep their event ID; this read path never broadcasts metadata. + +Authority reads use `"consistency":"strong"` on HTTP queries and WebSocket REQ +filters. The relay pins these reads to the writer, so a lagging replica cannot +renew stale authority or hide the current revocation head. + +The harness ages successful lookups from their start, for at most 30 seconds. +Each lookup has a five-second deadline. It serializes +refreshes so an older lookup cannot overwrite a newer result. A lookup failure +refuses gateway use and is retried after five seconds. Positive verdicts never +outlive the grant expiration. Owners remain usable during a gateway lookup +failure. + +The relay caches observer verdicts for at most 30 seconds and fences local +writes by generation. Other replicas observe revocation within that cache TTL. +The mention launcher rechecks authority before each launch. New grants enter +its listener filters within the existing 60-second listener recycle interval. +Revocation does not cancel a model turn already running. The owner can cancel +that turn using the existing owner control path. + +Telemetry fan-out is capped at two gateways. Additional recipients consume +subsequent idle ticks, preserving the existing average relay publication rate. +Ephemeral observer frames are not replayable history. + +## Rollout and verification + +Deploy the relay changes before granting gateway access. Then deploy the manager +and agent image containing the updated launcher and harness. Older harnesses +continue accepting owners but ignore gateways. No database migration is needed. + +Exercise one owner agent in a test channel. Admit the gateway into that relay +and channel, grant it, and send a mention using the gateway key. Confirm one +sandbox starts and answers. Confirm the gateway receives encrypted observer +telemetry and can cancel a turn. Revoke it and wait 30 seconds. Confirm its next +prompt and cancellation are refused, while the owner's prompt still works. +Confirm a second owner's agent and the owner's DMs remain inaccessible through +the grant. diff --git a/docs/remote-agents.md b/docs/remote-agents.md index bdea3330758..18117d28989 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -2,6 +2,10 @@ `draft` +Owner-delegated gateway keys let an orchestrator drive the owner’s agents without +holding the owner key. See [Gateway keys](agent-gateway-key.md) for the scoped +grant, membership prerequisites, revocation, and rollout. + ## Abstract This document specifies the protocol by which Buzz Desktop delegates the diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 60bed113112..efc144b4b23 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -35,6 +35,9 @@ abstract final class EventKind { /// Owner-signed managed-agent records, including their runtime type. static const managedAgent = 30177; + + /// Owner-signed, expiring gateway delegation for remote agents. + static const agentGatewayDelegation = 30181; static const eventReminder = 30300; static const userStatus = 30315; static const dmVisibility = 30622; diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 7c4184de05c..85689772f69 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -8,6 +8,10 @@ Every commit an agent makes is signed with a key that is on your GitHub account, - **Admins:** start at [Onboard a developer](#onboard-a-developer). - **Developers:** start at [Set up your agents](#set-up-your-agents). +A Paseo daemon uses its own gateway key. Follow +[Gateway keys](../docs/agent-gateway-key.md) to admit that key and grant access +to your agents. The daemon never needs your main Nostr secret. + ## How it works An unchanged phone app can trigger registered agents through [server mention launches](../docs/server-mention-launch.md).