From 4899e5143863920e3245140e177a7d084e56ce48 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 09:37:52 +1000 Subject: [PATCH 01/13] keyless: add the client half of the broker (transport + backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side [C1] of the agent broker (block/buzz#6790): lets the buzz CLI run without an agent nsec, routing reads and writes through a host so relay traffic looks like a normal Buzz client. - broker_client.rs: HttpBrokerClient implements the BrokerClient transport primitive the contract crate omits — POST /v1/action with an opaque bearer credential, parse an envelope regardless of HTTP status, never interpret a verdict. Correlation stays in execute(). - backend.rs: AgentBackend trait spoken in broker vocabulary, with two impls behind a Backend enum — BrokerBackend (keyless) and LocalBackend (nsec + relay, today's path). LocalBackend reuses the shared buzz_sdk::build_message builder, so there is no parallel message path. - lib.rs: register the two modules. Not yet wired into the command surface or provisioning. 8/8 unit tests pass (4 transport, 4 backend); clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- crates/buzz-cli/src/backend.rs | 401 +++++++++++++++++++++++++++ crates/buzz-cli/src/broker_client.rs | 212 ++++++++++++++ crates/buzz-cli/src/lib.rs | 2 + 3 files changed, 615 insertions(+) create mode 100644 crates/buzz-cli/src/backend.rs create mode 100644 crates/buzz-cli/src/broker_client.rs diff --git a/crates/buzz-cli/src/backend.rs b/crates/buzz-cli/src/backend.rs new file mode 100644 index 00000000000..8658eebfa65 --- /dev/null +++ b/crates/buzz-cli/src/backend.rs @@ -0,0 +1,401 @@ +//! The relay-touching operations an agent performs, over either a local key + +//! relay ([`LocalBackend`]) or a keyless broker host ([`BrokerBackend`]). +//! +//! Both implement [`AgentBackend`] and speak the broker's vocabulary, so the +//! command layer depends on the trait and never on which side holds the key. +//! Selection is by provisioning: a broker endpoint + credential picks the +//! keyless path; a local key picks the relay path. This is the "seam that +//! covers both" from the keyless plan, done as one abstraction rather than +//! scattered conditionals. + +use nostr::Event; +use uuid::Uuid; + +use buzz_sdk::broker::{ + ActionArgs, ActionOutcome, BrokerClientExt, BrokerError, BrokerRequest, BrokerResult, + ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, PubkeyHex, +}; +use buzz_sdk::ThreadRef; + +use crate::broker_client::HttpBrokerClient; +use crate::client::BuzzClient; +use crate::error::CliError; + +/// The operations an agent performs, in the broker's vocabulary. +/// +/// A closed set today — read a channel, post, reply — mirroring the contract's +/// first slice. Adding one is a change here *and* to the contract, deliberately. +#[allow(async_fn_in_trait)] // dispatched through the `Backend` enum, never `dyn`. +pub trait AgentBackend { + async fn channel_read(&self, args: ChannelReadArgs) -> Result; + async fn message_post(&self, args: MessagePostArgs) -> Result; + async fn message_reply(&self, args: MessageReplyArgs) -> Result; +} + +/// Keyless backend: no key, no relay route. Every operation is a broker request. +pub struct BrokerBackend { + client: HttpBrokerClient, +} + +impl BrokerBackend { + #[must_use] + pub fn new(client: HttpBrokerClient) -> Self { + Self { client } + } + + /// Freeze one action, send it, and unwrap the host's verdict to its outcome. + async fn run(&self, args: ActionArgs) -> Result { + let request = BrokerRequest::new(Uuid::new_v4().to_string(), args) + .and_then(BrokerRequest::prepare) + .map_err(|e| CliError::Other(format!("broker request: {e}")))?; + let validated = self + .client + .execute(&request) + .await + .map_err(|e| CliError::Other(format!("broker transport: {e}")))?; + match validated.into_envelope().result { + BrokerResult::Succeeded { outcome } => Ok(outcome), + BrokerResult::Failed { error } => Err(broker_verdict("failed", &error)), + BrokerResult::Indeterminate { error } => Err(broker_verdict("indeterminate", &error)), + } + } +} + +impl AgentBackend for BrokerBackend { + async fn channel_read(&self, args: ChannelReadArgs) -> Result { + match self.run(ActionArgs::ChannelRead(args)).await? { + ActionOutcome::ChannelRead(page) => Ok(page), + _ => Err(unexpected_outcome("channel.read")), + } + } + + async fn message_post(&self, args: MessagePostArgs) -> Result { + match self.run(ActionArgs::MessagePost(args)).await? { + ActionOutcome::MessagePost(published) => Ok(published), + _ => Err(unexpected_outcome("message.post")), + } + } + + async fn message_reply(&self, args: MessageReplyArgs) -> Result { + match self.run(ActionArgs::MessageReply(args)).await? { + ActionOutcome::MessageReply(published) => Ok(published), + _ => Err(unexpected_outcome("message.reply")), + } + } +} + +/// Local backend: holds the key and talks to the relay directly. Preserves +/// today's behavior by reusing the shared `buzz_sdk` builders the CLI already +/// signs and submits, so there is no parallel message-construction path. +pub struct LocalBackend { + client: BuzzClient, +} + +impl LocalBackend { + #[must_use] + pub fn new(client: BuzzClient) -> Self { + Self { client } + } + + /// Compute the outcome from the locally-signed event, then submit it. + async fn publish(&self, event: Event) -> Result { + let published = EventPublished { + event_id: event.id.to_hex(), + kind: u32::from(event.kind.as_u16()), + created_at: event.created_at.as_secs(), + }; + self.client.submit_event(event).await?; + Ok(published) + } +} + +impl AgentBackend for LocalBackend { + async fn channel_read(&self, args: ChannelReadArgs) -> Result { + let mut filter = serde_json::json!({ + "kinds": [9, 40002, 40008, 45001, 45003], + "#h": [args.channel_id], + "limit": args.effective_limit(), + }); + if args.mentions_only { + filter["#p"] = serde_json::json!([self.client.keys().public_key().to_hex()]); + } + if let Some(root) = &args.root_event_id { + filter["#e"] = serde_json::json!([root]); + } + + let raw = self.client.query(&filter).await?; + let values: Vec = + serde_json::from_str(&raw).map_err(|e| CliError::Other(format!("parse read: {e}")))?; + let messages = values + .into_iter() + .filter_map(|v| serde_json::from_value::(v).ok()) + .map(buzz_sdk::broker::BrokerMessage) + .collect(); + // Local paging differs from a host's; a cursor is a host concept, so a + // local read is a single window with no continuation. Fuller paging is + // deferred until the local path is retrofitted onto the trait. + Ok(MessagePage { + messages, + next_cursor: None, + }) + } + + async fn message_post(&self, args: MessagePostArgs) -> Result { + let channel = Uuid::parse_str(&args.channel_id) + .map_err(|e| CliError::Other(format!("channel id: {e}")))?; + let mentions: Vec<&str> = args.mentions.iter().map(PubkeyHex::as_str).collect(); + let builder = buzz_sdk::build_message(channel, &args.content, None, &mentions, false, &[]) + .map_err(|e| CliError::Other(format!("build_message: {e}")))?; + let event = self.client.sign_event(builder)?; + self.publish(event).await + } + + async fn message_reply(&self, args: MessageReplyArgs) -> Result { + let channel = Uuid::parse_str(&args.channel_id) + .map_err(|e| CliError::Other(format!("channel id: {e}")))?; + let parent = nostr::EventId::from_hex(&args.reply_to_event_id) + .map_err(|e| CliError::Other(format!("reply target: {e}")))?; + // Direct reply: root == parent. Nested-thread root derivation (the + // host's job under the broker) is deferred for the local path. + let thread_ref = ThreadRef { + root_event_id: parent, + parent_event_id: parent, + }; + let mentions: Vec<&str> = args.mentions.iter().map(PubkeyHex::as_str).collect(); + let builder = buzz_sdk::build_message( + channel, + &args.content, + Some(&thread_ref), + &mentions, + false, + &[], + ) + .map_err(|e| CliError::Other(format!("build_message: {e}")))?; + let event = self.client.sign_event(builder)?; + self.publish(event).await + } +} + +/// A runtime-selected backend. Implements [`AgentBackend`] by dispatch, so +/// commands hold one value and never branch on custody. +pub enum Backend { + Local(Box), + Broker(BrokerBackend), +} + +impl Backend { + /// Keyless: talk to a broker `base_url` with `credential`. + #[must_use] + pub fn broker(base_url: impl Into, credential: impl Into) -> Self { + Self::Broker(BrokerBackend::new(HttpBrokerClient::new( + base_url, credential, + ))) + } + + /// Local: hold the key and talk to the relay. + #[must_use] + pub fn local(client: BuzzClient) -> Self { + Self::Local(Box::new(LocalBackend::new(client))) + } +} + +impl AgentBackend for Backend { + async fn channel_read(&self, args: ChannelReadArgs) -> Result { + match self { + Self::Local(b) => b.channel_read(args).await, + Self::Broker(b) => b.channel_read(args).await, + } + } + + async fn message_post(&self, args: MessagePostArgs) -> Result { + match self { + Self::Local(b) => b.message_post(args).await, + Self::Broker(b) => b.message_post(args).await, + } + } + + async fn message_reply(&self, args: MessageReplyArgs) -> Result { + match self { + Self::Local(b) => b.message_reply(args).await, + Self::Broker(b) => b.message_reply(args).await, + } + } +} + +fn broker_verdict(status: &str, error: &BrokerError) -> CliError { + CliError::Other(format!( + "broker {status}: {} [{}]", + error.message, + error.code.as_str() + )) +} + +fn unexpected_outcome(action: &str) -> CliError { + CliError::Other(format!( + "broker returned an outcome that is not for {action}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + use std::sync::Arc; + + use axum::body::{Body, Bytes}; + use axum::extract::State; + use axum::http::StatusCode; + use axum::response::Response; + use axum::routing::post; + use axum::Router; + use nostr::{EventBuilder, Keys, Kind}; + use tokio::net::TcpListener; + + const CHANNEL: &str = "5df7dfa8-e919-43df-8efd-f1dcb8af7071"; + const EVENT_ID: &str = "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359"; + + type Responder = Arc (StatusCode, String) + Send + Sync>; + + /// Spawn a broker host that echoes the request's `requestId`/`action` into + /// whatever `f` builds, so correlation always holds. + async fn spawn_host(f: F) -> BrokerBackend + where + F: Fn(&str, &str) -> (StatusCode, String) + Send + Sync + 'static, + { + let responder: Responder = Arc::new(f); + let app = Router::new() + .route( + "/v1/action", + post(|State(r): State, body: Bytes| async move { + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let rid = v.get("requestId").and_then(|x| x.as_str()).unwrap_or(""); + let action = v.get("action").and_then(|x| x.as_str()).unwrap_or(""); + let (status, out) = r(rid, action); + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(out)) + .unwrap() + }), + ) + .with_state(responder); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + BrokerBackend::new(HttpBrokerClient::new(format!("http://{addr}"), "cred")) + } + + fn succeeded(rid: &str, action: &str, outcome: serde_json::Value) -> (StatusCode, String) { + let body = serde_json::json!({ + "type": "broker_result", + "protocolVersion": 1, + "requestId": rid, + "status": "succeeded", + "action": action, + "outcome": outcome, + }); + (StatusCode::OK, body.to_string()) + } + + #[tokio::test] + async fn broker_post_returns_the_published_event() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "eventId": EVENT_ID, "kind": 9, "createdAt": 1_700_000_000u64 }), + ) + }) + .await; + + let published = backend + .message_post(MessagePostArgs { + channel_id: CHANNEL.into(), + content: "hello".into(), + mentions: Vec::new(), + }) + .await + .expect("published"); + + assert_eq!(published.event_id, EVENT_ID); + assert_eq!(published.kind, 9); + } + + #[tokio::test] + async fn broker_reply_returns_the_published_event() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "eventId": EVENT_ID, "kind": 9, "createdAt": 1_700_000_000u64 }), + ) + }) + .await; + + let published = backend + .message_reply(MessageReplyArgs { + channel_id: CHANNEL.into(), + reply_to_event_id: EVENT_ID.into(), + content: "on it".into(), + mentions: Vec::new(), + }) + .await + .expect("published"); + + assert_eq!(published.event_id, EVENT_ID); + } + + #[tokio::test] + async fn broker_read_returns_a_page_of_signed_events() { + // A real signed event, so the strict event reader accepts it. + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "hi") + .sign_with_keys(&keys) + .unwrap(); + let event_json = serde_json::to_value(&event).unwrap(); + + let backend = spawn_host(move |rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "messages": [event_json.clone()] }), + ) + }) + .await; + + let page = backend + .channel_read(ChannelReadArgs::channel(CHANNEL)) + .await + .expect("page"); + + assert_eq!(page.messages.len(), 1); + assert!(page.next_cursor.is_none()); + } + + #[tokio::test] + async fn broker_failure_is_surfaced_as_an_error() { + let backend = spawn_host(|rid, _action| { + let body = serde_json::json!({ + "type": "broker_result", + "protocolVersion": 1, + "requestId": rid, + "status": "failed", + "error": { "code": "unauthorized", "message": "not permitted" }, + }); + (StatusCode::OK, body.to_string()) + }) + .await; + + let err = backend + .message_post(MessagePostArgs { + channel_id: CHANNEL.into(), + content: "hello".into(), + mentions: Vec::new(), + }) + .await + .expect_err("a failure"); + + assert!(err.to_string().contains("unauthorized")); + } +} diff --git a/crates/buzz-cli/src/broker_client.rs b/crates/buzz-cli/src/broker_client.rs new file mode 100644 index 00000000000..3bf3f532362 --- /dev/null +++ b/crates/buzz-cli/src/broker_client.rs @@ -0,0 +1,212 @@ +//! HTTP transport for the agent broker — keyless client mode. +//! +//! Implements [`buzz_sdk::broker::BrokerClient`], the transport primitive the +//! contract crate deliberately omits: frozen request bytes out, one envelope +//! back. Callers use [`buzz_sdk::broker::BrokerClientExt::execute`], which adds +//! the correlation checks; this type must never interpret a verdict. +//! +//! The binding is one `POST /v1/action` with the opaque bearer credential in +//! the `Authorization` header. An envelope is parsed regardless of HTTP status, +//! because a host verdict lives in the body and an intermediary may remap the +//! status line. + +use buzz_sdk::broker::{ + BrokerClient, BrokerFuture, BrokerResponse, BrokerTransportError, Dispatch, PreparedRequest, + BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, +}; + +/// A broker host endpoint plus the agent's bearer credential. +/// +/// Holds no key and knows nothing of the relay: its whole authority is the +/// opaque credential it replays on every request. +pub struct HttpBrokerClient { + base_url: String, + credential: String, + http: reqwest::Client, +} + +impl HttpBrokerClient { + /// A client posting to `base_url` (scheme + authority, no path) with + /// `credential` as its bearer token. + pub fn new(base_url: impl Into, credential: impl Into) -> Self { + Self::with_client(base_url, credential, reqwest::Client::new()) + } + + /// As [`Self::new`], reusing an existing reqwest client and its pool. + pub fn with_client( + base_url: impl Into, + credential: impl Into, + http: reqwest::Client, + ) -> Self { + Self { + base_url: base_url.into(), + credential: credential.into(), + http, + } + } +} + +impl BrokerClient for HttpBrokerClient { + fn send<'a>(&'a self, request: &'a PreparedRequest, _dispatch: Dispatch) -> BrokerFuture<'a> { + Box::pin(async move { + let url = format!( + "{}{BROKER_ACTION_PATH}", + self.base_url.trim_end_matches('/') + ); + let response = self + .http + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + BROKER_CREDENTIAL_HEADER, + format!("Bearer {}", self.credential), + ) + .body(request.body().to_vec()) + .send() + .await + .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; + + let status = response.status().as_u16(); + let body = response + .bytes() + .await + .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; + + // Parse an envelope whatever the status. Only its absence makes the + // status meaningful, and then only as operator detail. + serde_json::from_slice::(&body).map_err(|e| { + BrokerTransportError::NoEnvelope { + status, + detail: e.to_string(), + } + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + use std::sync::{Arc, Mutex}; + + use axum::body::{Body, Bytes}; + use axum::extract::State; + use axum::http::{HeaderMap, StatusCode}; + use axum::response::Response; + use axum::routing::post; + use axum::Router; + use buzz_sdk::broker::{ + ActionArgs, BrokerClientExt, BrokerRequest, BrokerResult, MessagePostArgs, + }; + use tokio::net::TcpListener; + + const CHANNEL: &str = "5df7dfa8-e919-43df-8efd-f1dcb8af7071"; + const CRED: &str = "test-cred"; + + /// Spawn a broker that answers `/v1/action` with a fixed `(status, body)` + /// and records the `Authorization` header it saw. + async fn spawn(status: StatusCode, body: String) -> (String, Arc>>) { + let seen_auth = Arc::new(Mutex::new(None)); + type S = (Arc<(StatusCode, String)>, Arc>>); + let state: S = (Arc::new((status, body)), seen_auth.clone()); + + let app = Router::new() + .route( + BROKER_ACTION_PATH, + post( + |State((canned, seen)): State, headers: HeaderMap, _body: Bytes| async move { + *seen.lock().unwrap() = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + Response::builder() + .status(canned.0) + .header("content-type", "application/json") + .body(Body::from(canned.1.clone())) + .unwrap() + }, + ), + ) + .with_state(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), seen_auth) + } + + fn post_request() -> PreparedRequest { + let args = ActionArgs::MessagePost(MessagePostArgs { + channel_id: CHANNEL.to_string(), + content: "hi".to_string(), + mentions: Vec::new(), + }); + BrokerRequest::new("req-post-1", args) + .unwrap() + .prepare() + .unwrap() + } + + #[tokio::test] + async fn success_round_trips_and_sends_bearer_credential() { + let req = post_request(); + let body = format!( + r#"{{"type":"broker_result","protocolVersion":1,"requestId":"{}","status":"succeeded","action":"message.post","outcome":{{"eventId":"{}","kind":9,"createdAt":1700000000}}}}"#, + req.request_id(), + "a".repeat(64), + ); + let (base, seen_auth) = spawn(StatusCode::OK, body).await; + + let client = HttpBrokerClient::new(base, CRED); + let validated = client.execute(&req).await.expect("a verdict"); + + assert!(matches!(validated.result(), BrokerResult::Succeeded { .. })); + assert_eq!( + seen_auth.lock().unwrap().as_deref(), + Some("Bearer test-cred") + ); + } + + #[tokio::test] + async fn rejected_credential_is_a_verdict_not_a_transport_error() { + let req = post_request(); + let body = format!( + r#"{{"type":"broker_result","protocolVersion":1,"requestId":"{}","status":"failed","error":{{"code":"unauthenticated","message":"nope"}}}}"#, + req.request_id(), + ); + // A rejected credential arrives as HTTP 200 with a Failed envelope. + let (base, _) = spawn(StatusCode::OK, body).await; + + let client = HttpBrokerClient::new(base, CRED); + let validated = client.execute(&req).await.expect("a verdict"); + + match validated.result() { + BrokerResult::Failed { .. } => {} + other => panic!("expected Failed, got {other:?}"), + } + } + + #[tokio::test] + async fn non_envelope_response_is_a_transport_error() { + let req = post_request(); + let (base, _) = spawn(StatusCode::BAD_GATEWAY, "upstream boom".to_string()).await; + + let client = HttpBrokerClient::new(base, CRED); + let err = client.execute(&req).await.expect_err("no envelope"); + + assert!(matches!( + err, + BrokerTransportError::NoEnvelope { status: 502, .. } + )); + } + + #[tokio::test] + async fn unreachable_host_is_a_transport_error() { + let req = post_request(); + let client = HttpBrokerClient::new("http://127.0.0.1:1", CRED); + let err = client.execute(&req).await.expect_err("unreachable"); + + assert!(matches!(err, BrokerTransportError::Unreachable(_))); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index d0155970fa2..604dbd3a70e 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1,4 +1,6 @@ pub mod agent_management; +pub mod backend; +pub mod broker_client; mod client; mod commands; mod error; From 54c271f7dfd3638f9371486eaa937f8a590b6d5d Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 10:29:04 +1000 Subject: [PATCH 02/13] keyless: select the backend with BUZZ_AGENT_MODE and route the slice Add the mode toggle for the keyless client. --agent-mode (env BUZZ_AGENT_MODE, default "local") picks the backend; "broker" diverges before any key is read and routes the wake->reply slice through the host: - lib.rs: AgentMode enum + --agent-mode / --broker-url / --broker-credential flags; run() branches to run_broker(), which builds Backend::broker from the endpoint + credential. Broker mode fails closed if a private key is present (supplying one is a provisioning error, not silently ignored). --mentions-only added to `messages get` for the wake path. - messages.rs: dispatch_broker maps `messages get` -> channel.read and `messages send`/reply -> message.post/message.reply, taking explicit pubkey mentions. Relay-coupled extras (auto @mention resolution, file upload, forum kinds, broadcast, time/kind windowing) are refused in broker mode rather than silently dropped; the local path is unchanged. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- crates/buzz-cli/src/commands/messages.rs | 123 +++++++++++++++++++++++ crates/buzz-cli/src/lib.rs | 81 ++++++++++++++- 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751c..f4558683c9c 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -1,7 +1,9 @@ +use buzz_sdk::broker::{ChannelReadArgs, MessagePostArgs, MessageReplyArgs, PubkeyHex}; use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection}; use nostr::PublicKey; use uuid::Uuid; +use crate::backend::{AgentBackend, Backend}; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::{ @@ -353,6 +355,7 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { } } +#[allow(clippy::too_many_arguments)] pub async fn cmd_get_messages( client: &BuzzClient, channel_id: &str, @@ -360,6 +363,7 @@ pub async fn cmd_get_messages( before: Option, since: Option, kinds: Option<&str>, + mentions_only: bool, format: &crate::OutputFormat, ) -> Result<(), CliError> { validate_uuid(channel_id)?; @@ -379,6 +383,11 @@ pub async fn cmd_get_messages( } } + // The wake path: narrow to events tagging this agent. + if mentions_only { + filter["#p"] = serde_json::json!([client.keys().public_key().to_hex()]); + } + if let Some(b) = before { filter["until"] = serde_json::json!(b); } @@ -902,6 +911,118 @@ pub async fn cmd_vote_on_post( Ok(()) } +/// Keyless dispatch for the message slice: map CLI arguments straight onto the +/// broker's contract args and run them through the [`Backend`]. No relay-reads +/// happen here — the host derives the thread root and applies policy. The rich +/// local extras (auto `@mention` resolution, file upload, forum kinds, broadcast, +/// time/kind windowing) are relay-coupled or unmodeled by the contract, so they +/// are refused rather than silently dropped. +pub async fn dispatch_broker(cmd: crate::MessagesCmd, backend: &Backend) -> Result<(), CliError> { + use crate::MessagesCmd; + match cmd { + MessagesCmd::Send { + channel, + content, + kind, + reply_to, + broadcast, + files, + mentions, + } => { + if !files.is_empty() { + return Err(CliError::Usage( + "--file is not supported in keyless mode yet (slice)".into(), + )); + } + if broadcast { + return Err(CliError::Usage( + "--broadcast is not supported in keyless mode".into(), + )); + } + if let Some(k) = kind { + if k != 9 { + return Err(CliError::Usage(format!( + "--kind {k} is not supported in keyless mode (slice); only kind 9" + ))); + } + } + let content = read_or_stdin(&content)?; + validate_content_size(&content)?; + let mentions = parse_broker_mentions(&mentions)?; + let published = if let Some(reply_to_event_id) = reply_to { + backend + .message_reply(MessageReplyArgs { + channel_id: channel, + reply_to_event_id, + content, + mentions, + }) + .await? + } else { + backend + .message_post(MessagePostArgs { + channel_id: channel, + content, + mentions, + }) + .await? + }; + println!( + "{}", + serde_json::to_string(&published) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) + } + MessagesCmd::Get { + channel, + limit, + before, + since, + kinds, + mentions_only, + } => { + if before.is_some() || since.is_some() || kinds.is_some() { + return Err(CliError::Usage( + "--before/--since/--kinds are not supported in keyless mode; the host owns \ + windowing (use --limit and the returned cursor)" + .into(), + )); + } + let args = ChannelReadArgs { + channel_id: channel, + mentions_only, + limit, + ..ChannelReadArgs::default() + }; + let page = backend.channel_read(args).await?; + println!( + "{}", + serde_json::to_string(&page) + .map_err(|e| CliError::Other(format!("serialize page: {e}")))? + ); + Ok(()) + } + _ => Err(CliError::Usage( + "keyless (broker) mode currently supports only 'messages get' and 'messages send'" + .into(), + )), + } +} + +/// Parse CLI `--mention` values (hex or npub) into contract pubkeys. +fn parse_broker_mentions(values: &[String]) -> Result, CliError> { + values + .iter() + .map(|m| { + let pk = PublicKey::parse(m.trim()) + .map_err(|e| CliError::Usage(format!("invalid --mention '{m}': {e}")))?; + PubkeyHex::parse(pk.to_hex()) + .map_err(|e| CliError::Usage(format!("invalid --mention '{m}': {e}"))) + }) + .collect() +} + pub async fn dispatch( cmd: crate::MessagesCmd, client: &BuzzClient, @@ -987,6 +1108,7 @@ pub async fn dispatch( before, since, kinds, + mentions_only, } => { cmd_get_messages( client, @@ -995,6 +1117,7 @@ pub async fn dispatch( before, since, kinds.as_deref(), + mentions_only, format, ) .await diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 604dbd3a70e..74a2ead8439 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -92,6 +92,22 @@ struct Cli { #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, + /// Backend: 'local' (hold the key, talk to the relay — today's behaviour) or + /// 'broker' (keyless — route every operation through a broker host, no nsec + /// and no relay route on this box). Explicit, so a mis-provisioned broker + /// fails closed to local rather than silently. + #[arg(long, value_enum, env = "BUZZ_AGENT_MODE", default_value = "local")] + agent_mode: AgentMode, + + /// Broker base URL. Required when --agent-mode=broker; ignored otherwise. + #[arg(long, env = "BUZZ_BROKER_URL")] + broker_url: Option, + + /// Broker bearer credential. Required when --agent-mode=broker; ignored + /// otherwise. The credential is this box's whole authority — no key is read. + #[arg(long, env = "BUZZ_BROKER_CREDENTIAL", hide_env_values = true)] + broker_credential: Option, + /// Output format: 'json' (default, full fields) or 'compact' (reduced fields). #[arg(long, value_enum, default_value = "json")] format: OutputFormat, @@ -100,6 +116,17 @@ struct Cli { command: Cmd, } +/// Which [`backend::Backend`] the CLI runs against — the "mode" toggle. +#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum AgentMode { + /// Hold the key, sign locally, talk to the relay. Today's behaviour. + #[value(name = "local")] + Local, + /// Keyless: route every operation through a broker host. + #[value(name = "broker")] + Broker, +} + #[derive(Clone, clap::ValueEnum)] pub enum ChannelType { #[value(name = "stream")] @@ -481,6 +508,9 @@ pub enum MessagesCmd { /// Comma-separated event kinds to filter (e.g. 1,1984) #[arg(long)] kinds: Option, + /// Only messages mentioning this agent — the wake path. + #[arg(long, default_value_t = false)] + mentions_only: bool, }, /// Get the containing thread for a message or Buzz message link #[command( @@ -2030,7 +2060,7 @@ fn normalize_auth_tag_input(input: &str) -> String { async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); - // Pack commands are local-only — no relay connection needed. + // Pack commands are local-only — no relay connection needed, and mode-agnostic. if let Cmd::Pack(ref sub) = cli.command { return match sub { PackCmd::Validate { path } => commands::pack::cmd_validate(path), @@ -2038,6 +2068,12 @@ async fn run(cli: Cli) -> Result<(), CliError> { }; } + // Keyless mode diverges before any key is read: authority is the broker + // credential, and no `BuzzClient` is constructed on this box. + if let AgentMode::Broker = cli.agent_mode { + return run_broker(cli).await; + } + // Auth: private key is required for all relay operations. // The keypair IS the identity — no tokens, no other auth. let private_key_str = cli.private_key.ok_or_else(|| { @@ -2101,6 +2137,49 @@ async fn run(cli: Cli) -> Result<(), CliError> { } } +/// Keyless dispatch: build a broker [`backend::Backend`] from the provisioned +/// endpoint + credential and run the operation through it. No key is read and no +/// relay is touched here — the host performs every relay-facing step. +/// +/// The first slice covers the wake→reply loop only: `messages get` (optionally +/// `--mentions-only`) and `messages send`/reply. Every other command needs the +/// local backend and is refused with a pointer back to it. +async fn run_broker(cli: Cli) -> Result<(), CliError> { + // Fail closed on the keyless invariant: a key present on the box contradicts + // broker mode, and silently ignoring it would let a misprovisioned "keyless" + // agent run with an nsec sitting right there. + if cli.private_key.is_some() { + return Err(CliError::Usage( + "broker mode is keyless — don't supply a private key. Unset --private-key / \ + BUZZ_PRIVATE_KEY to run keyless, or use --agent-mode=local to sign with it." + .into(), + )); + } + + let base_url = cli.broker_url.ok_or_else(|| { + CliError::Usage( + "--broker-url (BUZZ_BROKER_URL) is required when --agent-mode=broker".into(), + ) + })?; + let credential = cli.broker_credential.ok_or_else(|| { + CliError::Auth( + "--broker-credential (BUZZ_BROKER_CREDENTIAL) is required when --agent-mode=broker" + .into(), + ) + })?; + let backend = backend::Backend::broker(base_url, credential); + + match cli.command { + Cmd::Messages(sub) => commands::messages::dispatch_broker(sub, &backend).await, + _ => Err(CliError::Usage( + "keyless (broker) mode currently supports only 'messages get' and 'messages send' \ + (the wake→reply slice); other commands need the local backend — unset --agent-mode \ + or set it to 'local'" + .into(), + )), + } +} + #[cfg(test)] mod tests { use super::*; From 1a4174013af06b56f34fc4af5015ea7aeea5f7e9 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 10:29:06 +1000 Subject: [PATCH 03/13] keyless: add a mock broker host and run instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/mock_broker.rs: a throwaway host that speaks just enough of the contract (POST /v1/action, echoes requestId/action, canned outcomes) to exercise the keyless CLI end to end before a real broker exists. Signs nothing, touches no relay. - KEYLESS.md: brief instructions — build, run against the mock, and point the CLI at your own broker. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- crates/buzz-cli/KEYLESS.md | 72 +++++++++++++++++++++ crates/buzz-cli/examples/mock_broker.rs | 85 +++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 crates/buzz-cli/KEYLESS.md create mode 100644 crates/buzz-cli/examples/mock_broker.rs diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md new file mode 100644 index 00000000000..f6afbf1699c --- /dev/null +++ b/crates/buzz-cli/KEYLESS.md @@ -0,0 +1,72 @@ +# Keyless mode (agent broker client) + +Run the `buzz` CLI without a signing key. In keyless mode the CLI holds no nsec +and opens no relay connection — every operation is sent to a **broker host** that +performs it on the agent's behalf. This is the client side of the agent-broker +contract ([#6790](https://github.com/block/buzz/pull/6790)). + +The backend is selected by `--agent-mode` (env `BUZZ_AGENT_MODE`), default +`local`: + +- `local` — hold the key, sign locally, talk to the relay (the unchanged default). +- `broker` — keyless; route every operation through a broker host. + +This first slice covers the wake→reply loop: `messages get` (with +`--mentions-only`, the wake path) and `messages send` / reply. + +## Build + +```sh +source bin/activate-hermit +cargo build -p buzz-cli --bin buzz --example mock_broker +``` + +## Try it against the bundled mock host + +The repo ships a throwaway mock host so you can exercise the round trip without a +real broker. It signs nothing and returns canned outcomes — it exists only to +prove the client wiring end to end. + +Terminal 1 — the mock host: + +```sh +cargo run -p buzz-cli --example mock_broker +# listening on http://127.0.0.1:8787 +``` + +Terminal 2 — the keyless client (note: no key in the environment): + +```sh +export BUZZ_AGENT_MODE=broker +export BUZZ_BROKER_URL=http://127.0.0.1:8787 +export BUZZ_BROKER_CREDENTIAL=dev-token +unset BUZZ_PRIVATE_KEY + +CH= +buzz messages send --channel "$CH" --content "hello from a keyless client" +buzz messages send --channel "$CH" --reply-to --content "on it" +buzz messages get --channel "$CH" --mentions-only --limit 10 +``` + +Terminal 1 logs each action, the bearer credential, and the args it received. + +## Point it at your own broker + +Swap the two broker vars for your host's endpoint and a credential it issued: + +```sh +export BUZZ_BROKER_URL=https://your-broker.example +export BUZZ_BROKER_CREDENTIAL= +``` + +Your host must accept `POST /v1/action` with `Authorization: Bearer ` +and return a broker-result envelope per the contract. `examples/mock_broker.rs` +is a minimal reference for the wire shape. + +## Notes + +- Keyless mode **fails closed** if a private key is present (`--private-key` / + `BUZZ_PRIVATE_KEY`): supplying a key in broker mode is a provisioning error, + not silently ignored. +- Credential issuance, authorization, and custody are the host's concern; the + client only needs an endpoint and a token to present. diff --git a/crates/buzz-cli/examples/mock_broker.rs b/crates/buzz-cli/examples/mock_broker.rs new file mode 100644 index 00000000000..df62beb00a1 --- /dev/null +++ b/crates/buzz-cli/examples/mock_broker.rs @@ -0,0 +1,85 @@ +//! A throwaway broker host for local keyless-CLI development. +//! +//! It speaks just enough of the agent-broker contract (block/buzz#6790) to let +//! `buzz --agent-mode broker …` complete a round trip before the real beekeeper +//! host exists: it accepts `POST /v1/action`, echoes the request's `requestId` +//! and `action` back (so correlation holds), and returns a canned success +//! outcome per action. It signs nothing and touches no relay — it exists only to +//! prove the client wiring end to end. +//! +//! Run it in one terminal: +//! cargo run -p buzz-cli --example mock_broker +//! then drive the CLI from another (see the crate's keyless docs). + +use axum::body::{Body, Bytes}; +use axum::http::StatusCode; +use axum::response::Response; +use axum::routing::post; +use axum::Router; +use tokio::net::TcpListener; + +const ADDR: &str = "127.0.0.1:8787"; +const FAKE_EVENT_ID: &str = "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359"; + +#[tokio::main] +async fn main() { + let app = Router::new().route("/v1/action", post(action)); + let listener = TcpListener::bind(ADDR).await.expect("bind"); + eprintln!("mock broker listening on http://{ADDR} (Ctrl-C to stop)"); + axum::serve(listener, app).await.expect("serve"); +} + +/// Answer one action with a canned success envelope for the wake→reply slice. +async fn action(headers: axum::http::HeaderMap, body: Bytes) -> Response { + let request: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let request_id = request + .get("requestId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let action = request.get("action").and_then(|v| v.as_str()).unwrap_or(""); + let credential = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + eprintln!( + "→ {action} requestId={request_id} auth={credential}\n args={}", + request.get("args").unwrap_or(&serde_json::Value::Null) + ); + + // A host verdict always rides in the body at HTTP 200; only the shape varies. + let body = match action { + "message.post" | "message.reply" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 9, "createdAt": 1_700_000_000u64 }), + ), + "channel.read" => succeeded(request_id, action, serde_json::json!({ "messages": [] })), + other => serde_json::json!({ + "type": "broker_result", + "protocolVersion": 1, + "requestId": request_id, + "status": "failed", + "error": { + "code": "unimplemented", + "message": format!("mock broker does not implement '{other}'"), + }, + }), + }; + + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("response") +} + +fn succeeded(request_id: &str, action: &str, outcome: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "type": "broker_result", + "protocolVersion": 1, + "requestId": request_id, + "status": "succeeded", + "action": action, + "outcome": outcome, + }) +} From 6436ee2731b30db165876dc620950034eb75f6dc Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 11:41:30 +1000 Subject: [PATCH 04/13] keyless: add reaction.add and profile.set to broker mode Extend the AgentBackend seam with reaction_add / profile_set and route two more command groups through it in keyless mode: - reactions add -> reaction.add (requires --channel, the host's scope; refuses --emoji-url, which is host-owned custom-emoji handling) - users set-profile -> profile.set (maps name/about/avatar; refuses --nip05, absent from the contract; requires >=1 field) BrokerBackend maps each to its ActionArgs and unwraps the EventPublished outcome, same as message.post. LocalBackend implements both for parity (reaction via build_reaction; profile as read-merge-write over the current kind:0, emulating the contract's 'absent fields left as-is'). Local command paths are untouched. Mock host and KEYLESS.md gain the two actions; two BrokerBackend round-trip tests added. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- crates/buzz-cli/KEYLESS.md | 7 +- crates/buzz-cli/examples/mock_broker.rs | 10 ++ crates/buzz-cli/src/backend.rs | 120 +++++++++++++++++++++- crates/buzz-cli/src/commands/reactions.rs | 47 +++++++++ crates/buzz-cli/src/commands/users.rs | 46 ++++++++- crates/buzz-cli/src/lib.rs | 11 +- 6 files changed, 232 insertions(+), 9 deletions(-) diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index f6afbf1699c..f266ac7292a 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -11,8 +11,9 @@ The backend is selected by `--agent-mode` (env `BUZZ_AGENT_MODE`), default - `local` — hold the key, sign locally, talk to the relay (the unchanged default). - `broker` — keyless; route every operation through a broker host. -This first slice covers the wake→reply loop: `messages get` (with -`--mentions-only`, the wake path) and `messages send` / reply. +Broker mode currently covers: `messages get` (with `--mentions-only`, the wake +path), `messages send` / reply, `reactions add`, and `users set-profile`. Every +other command still needs the local backend. ## Build @@ -46,6 +47,8 @@ CH= buzz messages send --channel "$CH" --content "hello from a keyless client" buzz messages send --channel "$CH" --reply-to --content "on it" buzz messages get --channel "$CH" --mentions-only --limit 10 +buzz reactions add --channel "$CH" --event --emoji "👍" +buzz users set-profile --name "Ada" --about "a keyless agent" ``` Terminal 1 logs each action, the bearer credential, and the args it received. diff --git a/crates/buzz-cli/examples/mock_broker.rs b/crates/buzz-cli/examples/mock_broker.rs index df62beb00a1..ce5c5a4b2eb 100644 --- a/crates/buzz-cli/examples/mock_broker.rs +++ b/crates/buzz-cli/examples/mock_broker.rs @@ -53,6 +53,16 @@ async fn action(headers: axum::http::HeaderMap, body: Bytes) -> Response { action, serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 9, "createdAt": 1_700_000_000u64 }), ), + "reaction.add" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 7, "createdAt": 1_700_000_000u64 }), + ), + "profile.set" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 0, "createdAt": 1_700_000_000u64 }), + ), "channel.read" => succeeded(request_id, action, serde_json::json!({ "messages": [] })), other => serde_json::json!({ "type": "broker_result", diff --git a/crates/buzz-cli/src/backend.rs b/crates/buzz-cli/src/backend.rs index 8658eebfa65..bde4c47b078 100644 --- a/crates/buzz-cli/src/backend.rs +++ b/crates/buzz-cli/src/backend.rs @@ -13,7 +13,8 @@ use uuid::Uuid; use buzz_sdk::broker::{ ActionArgs, ActionOutcome, BrokerClientExt, BrokerError, BrokerRequest, BrokerResult, - ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, PubkeyHex, + ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, + ProfileSetArgs, PubkeyHex, ReactionAddArgs, }; use buzz_sdk::ThreadRef; @@ -23,13 +24,16 @@ use crate::error::CliError; /// The operations an agent performs, in the broker's vocabulary. /// -/// A closed set today — read a channel, post, reply — mirroring the contract's -/// first slice. Adding one is a change here *and* to the contract, deliberately. +/// A closed set — read a channel; post, reply, react; set a profile — mirroring +/// the contract's actions. Adding one is a change here *and* to the contract, +/// deliberately. #[allow(async_fn_in_trait)] // dispatched through the `Backend` enum, never `dyn`. pub trait AgentBackend { async fn channel_read(&self, args: ChannelReadArgs) -> Result; async fn message_post(&self, args: MessagePostArgs) -> Result; async fn message_reply(&self, args: MessageReplyArgs) -> Result; + async fn reaction_add(&self, args: ReactionAddArgs) -> Result; + async fn profile_set(&self, args: ProfileSetArgs) -> Result; } /// Keyless backend: no key, no relay route. Every operation is a broker request. @@ -82,6 +86,20 @@ impl AgentBackend for BrokerBackend { _ => Err(unexpected_outcome("message.reply")), } } + + async fn reaction_add(&self, args: ReactionAddArgs) -> Result { + match self.run(ActionArgs::ReactionAdd(args)).await? { + ActionOutcome::ReactionAdd(published) => Ok(published), + _ => Err(unexpected_outcome("reaction.add")), + } + } + + async fn profile_set(&self, args: ProfileSetArgs) -> Result { + match self.run(ActionArgs::ProfileSet(args)).await? { + ActionOutcome::ProfileSet(published) => Ok(published), + _ => Err(unexpected_outcome("profile.set")), + } + } } /// Local backend: holds the key and talks to the relay directly. Preserves @@ -174,6 +192,40 @@ impl AgentBackend for LocalBackend { let event = self.client.sign_event(builder)?; self.publish(event).await } + + async fn reaction_add(&self, args: ReactionAddArgs) -> Result { + // A kind:7 reaction references only its target event; the channel the + // broker carries is a host-side scoping concept, unused on this path. + let target = nostr::EventId::from_hex(&args.target_event_id) + .map_err(|e| CliError::Other(format!("reaction target: {e}")))?; + let builder = buzz_sdk::build_reaction(target, &args.reaction) + .map_err(|e| CliError::Other(format!("build_reaction: {e}")))?; + let event = self.client.sign_event(builder)?; + self.publish(event).await + } + + async fn profile_set(&self, args: ProfileSetArgs) -> Result { + // Contract semantics: absent fields are left as they are. With no host to + // merge, emulate it by read-merge-writing over the current kind:0. + let current = crate::commands::users::fetch_current_profile(&self.client).await?; + let get = |key: &str| current.get(key).and_then(|v| v.as_str()).map(str::to_owned); + let display_name = args + .display_name + .or_else(|| get("display_name").or_else(|| get("name"))); + let about = args.about.or_else(|| get("about")); + let picture = args.picture.or_else(|| get("picture")); + let nip05 = get("nip05"); + let builder = buzz_sdk::build_profile( + display_name.as_deref(), + None, + picture.as_deref(), + about.as_deref(), + nip05.as_deref(), + ) + .map_err(|e| CliError::Other(format!("build_profile: {e}")))?; + let event = self.client.sign_event(builder)?; + self.publish(event).await + } } /// A runtime-selected backend. Implements [`AgentBackend`] by dispatch, so @@ -220,6 +272,20 @@ impl AgentBackend for Backend { Self::Broker(b) => b.message_reply(args).await, } } + + async fn reaction_add(&self, args: ReactionAddArgs) -> Result { + match self { + Self::Local(b) => b.reaction_add(args).await, + Self::Broker(b) => b.reaction_add(args).await, + } + } + + async fn profile_set(&self, args: ProfileSetArgs) -> Result { + match self { + Self::Local(b) => b.profile_set(args).await, + Self::Broker(b) => b.profile_set(args).await, + } + } } fn broker_verdict(status: &str, error: &BrokerError) -> CliError { @@ -346,6 +412,54 @@ mod tests { assert_eq!(published.event_id, EVENT_ID); } + #[tokio::test] + async fn broker_reaction_returns_the_published_event() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "eventId": EVENT_ID, "kind": 7, "createdAt": 1_700_000_000u64 }), + ) + }) + .await; + + let published = backend + .reaction_add(ReactionAddArgs { + channel_id: CHANNEL.into(), + target_event_id: EVENT_ID.into(), + reaction: "👍".into(), + }) + .await + .expect("published"); + + assert_eq!(published.event_id, EVENT_ID); + assert_eq!(published.kind, 7); + } + + #[tokio::test] + async fn broker_profile_set_returns_the_published_event() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "eventId": EVENT_ID, "kind": 0, "createdAt": 1_700_000_000u64 }), + ) + }) + .await; + + let published = backend + .profile_set(ProfileSetArgs { + display_name: Some("Ada".into()), + about: None, + picture: None, + }) + .await + .expect("published"); + + assert_eq!(published.event_id, EVENT_ID); + assert_eq!(published.kind, 0); + } + #[tokio::test] async fn broker_read_returns_a_page_of_signed_events() { // A real signed event, so the strict event reader accepts it. diff --git a/crates/buzz-cli/src/commands/reactions.rs b/crates/buzz-cli/src/commands/reactions.rs index 9e23d301312..11d4b979f70 100644 --- a/crates/buzz-cli/src/commands/reactions.rs +++ b/crates/buzz-cli/src/commands/reactions.rs @@ -2,6 +2,9 @@ use std::collections::HashMap; use nostr::EventId; +use buzz_sdk::broker::ReactionAddArgs; + +use crate::backend::{AgentBackend, Backend}; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -131,8 +134,52 @@ pub async fn dispatch(cmd: crate::ReactionsCmd, client: &BuzzClient) -> Result<( event, emoji, emoji_url, + channel: _, // relay-side reactions reference only the target event } => cmd_add_reaction(client, &event, &emoji, emoji_url.as_deref()).await, ReactionsCmd::Remove { event, emoji } => cmd_remove_reaction(client, &event, &emoji).await, ReactionsCmd::Get { event } => cmd_get_reactions(client, &event).await, } } + +/// Keyless (broker) dispatch for the reactions group: `reactions add` only. +pub async fn dispatch_broker(cmd: crate::ReactionsCmd, backend: &Backend) -> Result<(), CliError> { + use crate::ReactionsCmd; + match cmd { + ReactionsCmd::Add { + event, + emoji, + emoji_url, + channel, + } => { + if emoji_url.is_some() { + return Err(CliError::Usage( + "--emoji-url is not supported in keyless mode; the contract's reaction.add \ + carries an emoji or a :shortcode: string and the host owns custom emoji" + .into(), + )); + } + let channel_id = channel.ok_or_else(|| { + CliError::Usage( + "--channel is required in keyless mode (the host scopes the reaction to it)" + .into(), + ) + })?; + let published = backend + .reaction_add(ReactionAddArgs { + channel_id, + target_event_id: event, + reaction: emoji, + }) + .await?; + println!( + "{}", + serde_json::to_string(&published) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) + } + _ => Err(CliError::Usage( + "keyless (broker) mode supports only 'reactions add' in this group".into(), + )), + } +} diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index bb2d45dbf1b..59ec6101c0f 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -1,6 +1,8 @@ use buzz_core::kind::KIND_MANAGED_AGENT; +use buzz_sdk::broker::ProfileSetArgs; use nostr::PublicKey; +use crate::backend::{AgentBackend, Backend}; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; @@ -422,9 +424,51 @@ pub async fn cmd_set_profile( Ok(()) } +/// Keyless (broker) dispatch for the users group: `set-profile` only. +pub async fn dispatch_broker(cmd: crate::UsersCmd, backend: &Backend) -> Result<(), CliError> { + use crate::UsersCmd; + match cmd { + UsersCmd::SetProfile { + name, + avatar, + about, + nip05, + } => { + if nip05.is_some() { + return Err(CliError::Usage( + "--nip05 is not supported in keyless mode; the contract's profile.set carries \ + display name, about, and picture only" + .into(), + )); + } + if name.is_none() && avatar.is_none() && about.is_none() { + return Err(CliError::Usage( + "include at least one profile field to set (--name, --about, --avatar)".into(), + )); + } + let published = backend + .profile_set(ProfileSetArgs { + display_name: name, + about, + picture: avatar, + }) + .await?; + println!( + "{}", + serde_json::to_string(&published) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) + } + _ => Err(CliError::Usage( + "keyless (broker) mode supports only 'users set-profile' in this group".into(), + )), + } +} + /// Fetch the current user's profile metadata via POST /query (kind:0). /// Returns the parsed content JSON object, or an empty object if no profile exists. -async fn fetch_current_profile( +pub(crate) async fn fetch_current_profile( client: &BuzzClient, ) -> Result, CliError> { let my_pk = client.keys().public_key().to_hex(); diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 74a2ead8439..4121c930288 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -778,6 +778,9 @@ pub enum ReactionsCmd { /// Image URL for a custom emoji reaction; when set, content becomes `:shortcode:` #[arg(long = "emoji-url")] emoji_url: Option, + /// Channel UUID — required in keyless mode (the host scopes the reaction to it) + #[arg(long)] + channel: Option, }, /// Remove an emoji reaction from a message Remove { @@ -2171,10 +2174,12 @@ async fn run_broker(cli: Cli) -> Result<(), CliError> { match cli.command { Cmd::Messages(sub) => commands::messages::dispatch_broker(sub, &backend).await, + Cmd::Reactions(sub) => commands::reactions::dispatch_broker(sub, &backend).await, + Cmd::Users(sub) => commands::users::dispatch_broker(sub, &backend).await, _ => Err(CliError::Usage( - "keyless (broker) mode currently supports only 'messages get' and 'messages send' \ - (the wake→reply slice); other commands need the local backend — unset --agent-mode \ - or set it to 'local'" + "keyless (broker) mode currently supports the wake→reply slice plus reactions and \ + profile: 'messages get/send', 'reactions add', and 'users set-profile'. Other \ + commands need the local backend — unset --agent-mode or set it to 'local'" .into(), )), } From 164ef5414c18b50795df2f48716e6f6658eb77a4 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 12:09:49 +1000 Subject: [PATCH 05/13] keyless: add storage.address to broker mode Add an addressing-only bridge for encrypted memory while the end-to-end runtime storage semantics remain deferred. - add mem address and normalize shorthand to the NIP-AE slug - route storage.address through AgentBackend in broker mode and print its validated JSON outcome - derive the same address locally for command parity - extend the mock host, backend round-trip coverage, and keyless docs The returned coordinates identify a record but do not yet fetch, decrypt, encrypt, or publish it; KEYLESS.md records that deliberate temporary limit. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- crates/buzz-cli/KEYLESS.md | 10 +++- crates/buzz-cli/examples/mock_broker.rs | 10 ++++ crates/buzz-cli/src/backend.rs | 63 +++++++++++++++++++++++-- crates/buzz-cli/src/commands/mem.rs | 56 +++++++++++++++++++++- crates/buzz-cli/src/lib.rs | 8 +++- 5 files changed, 139 insertions(+), 8 deletions(-) diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index f266ac7292a..966e76fa190 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -12,7 +12,8 @@ The backend is selected by `--agent-mode` (env `BUZZ_AGENT_MODE`), default - `broker` — keyless; route every operation through a broker host. Broker mode currently covers: `messages get` (with `--mentions-only`, the wake -path), `messages send` / reply, `reactions add`, and `users set-profile`. Every +path), `messages send` / reply, `reactions add`, `users set-profile`, and +`mem address ` as an addressing-only bridge for encrypted memory. Every other command still needs the local backend. ## Build @@ -49,6 +50,7 @@ buzz messages send --channel "$CH" --reply-to --content "on it" buzz messages get --channel "$CH" --mentions-only --limit 10 buzz reactions add --channel "$CH" --event --emoji "👍" buzz users set-profile --name "Ada" --about "a keyless agent" +buzz mem address core ``` Terminal 1 logs each action, the bearer credential, and the args it received. @@ -73,3 +75,9 @@ is a minimal reference for the wire shape. not silently ignored. - Credential issuance, authorization, and custody are the host's concern; the client only needs an endpoint and a token to present. +- `buzz mem address ` prints the broker's `{authorPubkey, kind, dTag}` + outcome as JSON. This temporary bridge proves secret-dependent address + derivation without giving the client a key or relay route. It does not yet + make `mem get/set/patch/rm` keyless; fetching, decrypting, encrypting, and + publishing memory records belong to the later end-to-end runtime storage + work. diff --git a/crates/buzz-cli/examples/mock_broker.rs b/crates/buzz-cli/examples/mock_broker.rs index ce5c5a4b2eb..d0a79a2959e 100644 --- a/crates/buzz-cli/examples/mock_broker.rs +++ b/crates/buzz-cli/examples/mock_broker.rs @@ -20,6 +20,7 @@ use tokio::net::TcpListener; const ADDR: &str = "127.0.0.1:8787"; const FAKE_EVENT_ID: &str = "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359"; +const FAKE_PUBKEY: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971"; #[tokio::main] async fn main() { @@ -63,6 +64,15 @@ async fn action(headers: axum::http::HeaderMap, body: Bytes) -> Response { action, serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 0, "createdAt": 1_700_000_000u64 }), ), + "storage.address" => succeeded( + request_id, + action, + serde_json::json!({ + "authorPubkey": FAKE_PUBKEY, + "kind": 30174, + "dTag": FAKE_EVENT_ID, + }), + ), "channel.read" => succeeded(request_id, action, serde_json::json!({ "messages": [] })), other => serde_json::json!({ "type": "broker_result", diff --git a/crates/buzz-cli/src/backend.rs b/crates/buzz-cli/src/backend.rs index bde4c47b078..971a9ba5578 100644 --- a/crates/buzz-cli/src/backend.rs +++ b/crates/buzz-cli/src/backend.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use buzz_sdk::broker::{ ActionArgs, ActionOutcome, BrokerClientExt, BrokerError, BrokerRequest, BrokerResult, ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, - ProfileSetArgs, PubkeyHex, ReactionAddArgs, + ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs, }; use buzz_sdk::ThreadRef; @@ -24,9 +24,9 @@ use crate::error::CliError; /// The operations an agent performs, in the broker's vocabulary. /// -/// A closed set — read a channel; post, reply, react; set a profile — mirroring -/// the contract's actions. Adding one is a change here *and* to the contract, -/// deliberately. +/// A closed set — read a channel; post, reply, react; set a profile; derive a +/// storage address — mirroring the contract's actions. Adding one is a change +/// here *and* to the contract, deliberately. #[allow(async_fn_in_trait)] // dispatched through the `Backend` enum, never `dyn`. pub trait AgentBackend { async fn channel_read(&self, args: ChannelReadArgs) -> Result; @@ -34,6 +34,7 @@ pub trait AgentBackend { async fn message_reply(&self, args: MessageReplyArgs) -> Result; async fn reaction_add(&self, args: ReactionAddArgs) -> Result; async fn profile_set(&self, args: ProfileSetArgs) -> Result; + async fn storage_address(&self, args: StorageAddressArgs) -> Result; } /// Keyless backend: no key, no relay route. Every operation is a broker request. @@ -100,6 +101,13 @@ impl AgentBackend for BrokerBackend { _ => Err(unexpected_outcome("profile.set")), } } + + async fn storage_address(&self, args: StorageAddressArgs) -> Result { + match self.run(ActionArgs::StorageAddress(args)).await? { + ActionOutcome::StorageAddress(address) => Ok(address), + _ => Err(unexpected_outcome("storage.address")), + } + } } /// Local backend: holds the key and talks to the relay directly. Preserves @@ -226,6 +234,18 @@ impl AgentBackend for LocalBackend { let event = self.client.sign_event(builder)?; self.publish(event).await } + + async fn storage_address(&self, args: StorageAddressArgs) -> Result { + let owner = crate::commands::mem::resolve_owner(&self.client, None)?; + let conversation_key = + buzz_core::engram::conversation_key(self.client.keys().secret_key(), &owner); + Ok(StorageAddress { + author_pubkey: PubkeyHex::try_from(self.client.keys().public_key().to_hex()) + .map_err(|e| CliError::Other(format!("agent pubkey: {e}")))?, + kind: buzz_core::kind::KIND_AGENT_ENGRAM, + d_tag: buzz_core::engram::d_tag(&conversation_key, &args.slug), + }) + } } /// A runtime-selected backend. Implements [`AgentBackend`] by dispatch, so @@ -286,6 +306,13 @@ impl AgentBackend for Backend { Self::Broker(b) => b.profile_set(args).await, } } + + async fn storage_address(&self, args: StorageAddressArgs) -> Result { + match self { + Self::Local(b) => b.storage_address(args).await, + Self::Broker(b) => b.storage_address(args).await, + } + } } fn broker_verdict(status: &str, error: &BrokerError) -> CliError { @@ -319,6 +346,7 @@ mod tests { const CHANNEL: &str = "5df7dfa8-e919-43df-8efd-f1dcb8af7071"; const EVENT_ID: &str = "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359"; + const PUBKEY: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971"; type Responder = Arc (StatusCode, String) + Send + Sync>; @@ -460,6 +488,33 @@ mod tests { assert_eq!(published.kind, 0); } + #[tokio::test] + async fn broker_storage_address_returns_the_derived_address() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ + "authorPubkey": PUBKEY, + "kind": 30174, + "dTag": EVENT_ID, + }), + ) + }) + .await; + + let address = backend + .storage_address(StorageAddressArgs { + slug: "mem/preferences".into(), + }) + .await + .expect("address"); + + assert_eq!(address.author_pubkey.as_str(), PUBKEY); + assert_eq!(address.kind, 30174); + assert_eq!(address.d_tag, EVENT_ID); + } + #[tokio::test] async fn broker_read_returns_a_page_of_signed_events() { // A real signed event, so the strict event reader accepts it. diff --git a/crates/buzz-cli/src/commands/mem.rs b/crates/buzz-cli/src/commands/mem.rs index eb15921bd4c..2e2124d4fa2 100644 --- a/crates/buzz-cli/src/commands/mem.rs +++ b/crates/buzz-cli/src/commands/mem.rs @@ -1,6 +1,7 @@ //! `buzz mem` — agent-side engram management (NIP-AE). //! //! Subcommands: +//! - `buzz mem address ` — print the encrypted-memory address //! - `buzz mem ls` — list non-tombstoned memories //! - `buzz mem get ` — print the value to stdout //! - `buzz mem hash ` — print sha256(value) hex @@ -23,14 +24,19 @@ use buzz_core::engram::{ self, conversation_key, d_tag, normalize_slug, select_head, validate_and_decrypt, Body, Listing, }; use buzz_core::kind::KIND_AGENT_ENGRAM; +use buzz_sdk::broker::StorageAddressArgs; use nostr::PublicKey; +use crate::backend::{AgentBackend, Backend}; use crate::client::BuzzClient; use crate::error::CliError; /// Resolve the agent's owner pubkey: explicit `--owner` flag wins, otherwise /// fall back to the NIP-OA `auth_tag` (which carries owner pubkey in slot 1). -fn resolve_owner(client: &BuzzClient, owner_flag: Option<&str>) -> Result { +pub(crate) fn resolve_owner( + client: &BuzzClient, + owner_flag: Option<&str>, +) -> Result { if let Some(s) = owner_flag { return PublicKey::from_hex(s) .map_err(|e| CliError::Usage(format!("--owner must be a 64-hex pubkey: {e}"))); @@ -85,6 +91,26 @@ fn now_secs() -> u64 { .unwrap_or(0) } +/// `buzz mem address ` — print the NIP-AE event coordinates as JSON. +pub fn cmd_address(client: &BuzzClient, raw_slug: &str) -> Result<(), CliError> { + let slug = + normalize_slug(raw_slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + let owner = resolve_owner(client, None)?; + let conversation_key = conversation_key(client.keys().secret_key(), &owner); + let address = buzz_sdk::broker::StorageAddress { + author_pubkey: buzz_sdk::broker::PubkeyHex::try_from(client.keys().public_key().to_hex()) + .map_err(|e| CliError::Other(format!("agent pubkey: {e}")))?, + kind: KIND_AGENT_ENGRAM, + d_tag: d_tag(&conversation_key, &slug), + }; + println!( + "{}", + serde_json::to_string(&address) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) +} + /// Submit a signed engram event and confirm the relay treated it as /// authoritative. The relay returns `{accepted, message}` where the /// `message` field starts with `"duplicate:"` when the write was rejected @@ -737,6 +763,7 @@ pub async fn cmd_rm( pub async fn dispatch(cmd: crate::MemCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::MemCmd; match cmd { + MemCmd::Address { slug } => cmd_address(client, &slug), MemCmd::Ls { owner, agent, json } => { cmd_ls(client, owner.as_deref(), agent.as_deref(), json).await } @@ -777,6 +804,33 @@ pub async fn dispatch(cmd: crate::MemCmd, client: &BuzzClient) -> Result<(), Cli } } +/// Keyless (broker) dispatch for encrypted-memory addressing only. +/// +/// The returned coordinates are intentionally surfaced as-is. They identify +/// the encrypted record but do not, by themselves, provide read, decrypt, +/// encrypt, or publish semantics; those remain a later runtime slice. +pub async fn dispatch_broker(cmd: crate::MemCmd, backend: &Backend) -> Result<(), CliError> { + use crate::MemCmd; + match cmd { + MemCmd::Address { slug } => { + let slug = + normalize_slug(&slug).map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + let address = backend.storage_address(StorageAddressArgs { slug }).await?; + println!( + "{}", + serde_json::to_string(&address) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) + } + _ => Err(CliError::Usage( + "keyless (broker) mode supports only 'mem address' in this group; encrypted-memory \ + reads and writes need the later runtime storage slice" + .into(), + )), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 4121c930288..0d108cb6b64 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1834,6 +1834,8 @@ pub enum MediaCmd { /// Subcommands for `buzz mem`. #[derive(Subcommand)] pub enum MemCmd { + /// Derive and print the encrypted-memory address for a slug as JSON + Address { slug: String }, /// List non-tombstoned memory entries Ls { /// Owner pubkey (hex). Overrides BUZZ_AUTH_TAG. @@ -2176,10 +2178,12 @@ async fn run_broker(cli: Cli) -> Result<(), CliError> { Cmd::Messages(sub) => commands::messages::dispatch_broker(sub, &backend).await, Cmd::Reactions(sub) => commands::reactions::dispatch_broker(sub, &backend).await, Cmd::Users(sub) => commands::users::dispatch_broker(sub, &backend).await, + Cmd::Mem(sub) => commands::mem::dispatch_broker(sub, &backend).await, _ => Err(CliError::Usage( "keyless (broker) mode currently supports the wake→reply slice plus reactions and \ - profile: 'messages get/send', 'reactions add', and 'users set-profile'. Other \ - commands need the local backend — unset --agent-mode or set it to 'local'" + profile, plus encrypted-memory addressing: 'messages get/send', 'reactions add', \ + 'users set-profile', and 'mem address'. Other commands need the local backend — \ + unset --agent-mode or set it to 'local'" .into(), )), } From d43b0706cd5530db57381225f6115f5e81bac03d Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 13:09:21 +1000 Subject: [PATCH 06/13] keyless: run buzz-acp through the broker Promote the HTTP broker transport into a shared crate and add an explicit runtime transport seam to buzz-acp. Broker mode rejects local keys, derives its identity through storage.address, polls configured channels through channel.read, validates returned events, and provisions agent subprocesses with broker-only credentials. Relay-only housekeeping and enrichment stay disabled until the frozen contract grows an agreed host-owned path. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- Cargo.lock | 13 + Cargo.toml | 2 + crates/buzz-acp/Cargo.toml | 1 + crates/buzz-acp/README.md | 44 ++- crates/buzz-acp/src/acp.rs | 14 +- crates/buzz-acp/src/config.rs | 254 ++++++++++++- crates/buzz-acp/src/lib.rs | 181 +++++++-- crates/buzz-acp/src/pool.rs | 69 +++- crates/buzz-acp/src/relay.rs | 8 + crates/buzz-acp/src/runtime_transport.rs | 356 ++++++++++++++++++ crates/buzz-broker-client/Cargo.toml | 17 + .../src/lib.rs} | 18 +- crates/buzz-cli/Cargo.toml | 1 + crates/buzz-cli/KEYLESS.md | 7 + crates/buzz-cli/src/backend.rs | 2 +- crates/buzz-cli/src/lib.rs | 1 - 16 files changed, 908 insertions(+), 80 deletions(-) create mode 100644 crates/buzz-acp/src/runtime_transport.rs create mode 100644 crates/buzz-broker-client/Cargo.toml rename crates/{buzz-cli/src/broker_client.rs => buzz-broker-client/src/lib.rs} (90%) diff --git a/Cargo.lock b/Cargo.lock index 9544a63b899..252eb9b1bed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -831,6 +831,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base64 0.22.1", + "buzz-broker-client", "buzz-core", "buzz-persona", "buzz-sdk", @@ -973,12 +974,24 @@ dependencies = [ "tower", ] +[[package]] +name = "buzz-broker-client" +version = "0.1.0" +dependencies = [ + "axum", + "buzz-sdk", + "reqwest 0.13.4", + "serde_json", + "tokio", +] + [[package]] name = "buzz-cli" version = "0.1.0" dependencies = [ "axum", "base64 0.22.1", + "buzz-broker-client", "buzz-core", "buzz-persona", "buzz-sdk", diff --git a/Cargo.toml b/Cargo.toml index d6ee839f1b0..777e32953fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/buzz-workflow", "crates/buzz-media", "crates/buzz-cli", + "crates/buzz-broker-client", "crates/buzz-pairing-cli", "crates/buzz-sdk", "crates/buzz-persona", @@ -147,6 +148,7 @@ buzz-audit = { path = "crates/buzz-audit" } buzz-workflow = { path = "crates/buzz-workflow" } buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } +buzz-broker-client = { path = "crates/buzz-broker-client" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } buzz-datastore-tracing = { path = "crates/buzz-datastore-tracing" } diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806f..8a3dd9748ca 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" # Internal buzz-core = { workspace = true } buzz-sdk = { workspace = true } +buzz-broker-client = { workspace = true } buzz-persona = { path = "../buzz-persona" } # Nostr diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..88f866732bf 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -1,9 +1,13 @@ # buzz-acp -ACP harness that connects AI agents to Buzz. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using the Buzz CLI. +ACP harness that connects AI agents to Buzz. In local mode the harness listens +for @mentions on the relay. In keyless broker mode it polls a host that owns the +agent key and relay route. Both modes prompt the same ACP agent, which replies +using the Buzz CLI. ``` -Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent +Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent (local) +Broker Host ─HTTP→ buzz-acp ──stdio──→ Your Agent (keyless) │ Buzz CLI (send_message, etc.) @@ -64,6 +68,34 @@ buzz-acp That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Buzz CLI that the harness configures automatically. +## Keyless broker mode (prototype) + +Broker mode starts the runtime without an agent private key or direct relay +connection. The harness derives the agent public key through `storage.address`, +polls each configured channel through `channel.read`, and provisions the spawned +agent and optional MCP server with broker variables so their `buzz` commands use +the same host. + +```bash +export BUZZ_AGENT_MODE=broker +export BUZZ_BROKER_URL=http://127.0.0.1:8787 +export BUZZ_BROKER_CREDENTIAL=dev-token +export BUZZ_ACP_CHANNELS=5df7dfa8-e919-43df-8efd-f1dcb8af7071 +export BUZZ_ACP_AGENT_OWNER=a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971 +unset BUZZ_PRIVATE_KEY BUZZ_RELAY_URL BUZZ_AUTH_TAG + +buzz-acp +``` + +This slice is intentionally receive-and-reply focused. Because the frozen +broker contract does not expose channel discovery, channel metadata, profiles, +or runtime housekeeping operations, broker mode currently requires explicit +channel UUIDs and `respond-to=owner-only`. Presence, typing, reactions used as +turn status, observer/liveness events, relay conversation enrichment, setup +nudges, and core-memory injection are disabled. The `buzz` CLI operations +available to the spawned agent are documented in +[`../buzz-cli/KEYLESS.md`](../buzz-cli/KEYLESS.md). + ## Running with Codex [codex-acp](https://github.com/agentclientprotocol/codex-acp) wraps OpenAI Codex in an ACP interface. @@ -106,8 +138,14 @@ All configuration is via environment variables (or CLI flags — every env var h | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `BUZZ_PRIVATE_KEY` | **yes** | — | Agent's Nostr private key (`nsec1...`). Used for relay auth and agent identity. | +| `BUZZ_AGENT_MODE` | no | `local` | Runtime substrate: `local` or keyless `broker`. | +| `BUZZ_PRIVATE_KEY` | local only | — | Agent's Nostr private key (`nsec1...`). Rejected in broker mode. | | `BUZZ_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. | +| `BUZZ_BROKER_URL` | broker only | — | Broker base URL; actions are posted to `/v1/action`. | +| `BUZZ_BROKER_CREDENTIAL` | broker only | — | Bearer credential issued by the broker host. | +| `BUZZ_BROKER_POLL_INTERVAL_MS` | no | `1000` | Broker `channel.read` polling interval; minimum `100`. | +| `BUZZ_ACP_CHANNELS` | broker only | — | Comma-separated channel UUIDs to poll. | +| `BUZZ_ACP_AGENT_OWNER` | broker only | — | Owner pubkey accepted by the broker-mode author gate. | | `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | | `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..792221092ba 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -508,7 +508,19 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } - if std::env::var_os(key).is_none() { + // Substrate provisioning is a security boundary, not a persona + // preference. Broker mode must override inherited local relay/key + // values, including with empty tombstones. + let force_runtime_provisioning = matches!( + key.as_str(), + "BUZZ_AGENT_MODE" + | "BUZZ_BROKER_URL" + | "BUZZ_BROKER_CREDENTIAL" + | "BUZZ_RELAY_URL" + | "BUZZ_PRIVATE_KEY" + | "BUZZ_AUTH_TAG" + ); + if force_runtime_provisioning || std::env::var_os(key).is_none() { cmd.env(key, value); } } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 2d7b2128320..8aa052ed078 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -54,6 +54,33 @@ pub enum SubscribeMode { Config, } +/// Runtime substrate for inbound work and agent CLI operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum AgentMode { + /// Hold the agent key and connect directly to the relay. + Local, + /// Hold only a broker credential; no agent key or relay route. + Broker, +} + +/// Keyless broker provisioning. The credential is redacted from `Debug`. +#[derive(Clone)] +pub struct BrokerConfig { + pub base_url: String, + pub credential: String, + pub poll_interval: std::time::Duration, +} + +impl std::fmt::Debug for BrokerConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BrokerConfig") + .field("base_url", &self.base_url) + .field("credential", &"") + .field("poll_interval", &self.poll_interval) + .finish() + } +} + #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub enum DedupMode { Drop, @@ -246,9 +273,29 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] pub relay_url: String, - #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] + /// Runtime substrate. Broker mode rejects a private key and requires the + /// broker URL, credential, and explicit channel list. + #[arg(long, env = "BUZZ_AGENT_MODE", default_value = "local")] + pub agent_mode: AgentMode, + + #[arg( + long, + env = "BUZZ_PRIVATE_KEY", + hide_env_values = true, + default_value = "" + )] pub private_key: String, + #[arg(long, env = "BUZZ_BROKER_URL")] + pub broker_url: Option, + + #[arg(long, env = "BUZZ_BROKER_CREDENTIAL", hide_env_values = true)] + pub broker_credential: Option, + + /// Delay between broker polling sweeps in keyless mode. + #[arg(long, env = "BUZZ_BROKER_POLL_INTERVAL_MS", default_value_t = 1000)] + pub broker_poll_interval_ms: u64, + /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. #[arg(long, env = "BUZZ_ACP_AGENT_OWNER")] pub agent_owner: Option, @@ -517,6 +564,8 @@ pub struct ChannelFilter { #[derive(Debug)] pub struct Config { pub keys: Keys, + pub agent_mode: AgentMode, + pub broker: Option, pub relay_url: String, pub agent_command: String, pub agent_args: Vec, @@ -871,7 +920,70 @@ impl Config { /// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full /// validation path without going through process args. pub fn from_args(mut args: CliArgs) -> Result { - let keys = Keys::parse(&args.private_key)?; + let broker = match args.agent_mode { + AgentMode::Local => None, + AgentMode::Broker => { + if !args.private_key.is_empty() { + return Err(ConfigError::ConfigFile( + "broker mode is keyless — unset BUZZ_PRIVATE_KEY / --private-key".into(), + )); + } + let base_url = args.broker_url.take().ok_or_else(|| { + ConfigError::ConfigFile( + "BUZZ_BROKER_URL / --broker-url is required in broker mode".into(), + ) + })?; + let credential = args.broker_credential.take().ok_or_else(|| { + ConfigError::ConfigFile( + "BUZZ_BROKER_CREDENTIAL / --broker-credential is required in broker mode" + .into(), + ) + })?; + if args.broker_poll_interval_ms < 100 { + return Err(ConfigError::ConfigFile( + "broker poll interval must be at least 100ms".into(), + )); + } + let channels = args.channels.as_ref().ok_or_else(|| { + ConfigError::ConfigFile( + "--channels / BUZZ_ACP_CHANNELS is required in broker mode; channel discovery is host-owned and not in the frozen contract" + .into(), + ) + })?; + if channels.is_empty() + || channels + .iter() + .any(|channel| Uuid::parse_str(channel).is_err()) + { + return Err(ConfigError::ConfigFile( + "every broker-mode --channels entry must be a channel UUID".into(), + )); + } + if args.respond_to != RespondTo::OwnerOnly { + return Err(ConfigError::ConfigFile( + "broker mode currently requires --respond-to owner-only because the frozen contract does not expose channel or sibling-profile metadata" + .into(), + )); + } + if args.agent_owner.is_none() { + return Err(ConfigError::ConfigFile( + "--agent-owner / BUZZ_ACP_AGENT_OWNER is required in broker mode".into(), + )); + } + Some(BrokerConfig { + base_url, + credential, + poll_interval: std::time::Duration::from_millis(args.broker_poll_interval_ms), + }) + } + }; + let keys = match args.agent_mode { + AgentMode::Local => Keys::parse(&args.private_key)?, + // Never used as the agent identity. A placeholder keeps the local + // runtime's concrete types intact while the broker path diverges + // before relay setup; it is never exported to subprocesses. + AgentMode::Broker => Keys::generate(), + }; // Best-effort zeroize: overwrite the raw private key string to reduce // exposure via core dumps or heap inspection (#41). Without the `zeroize` // crate we can only clear the String — the allocator may retain copies. @@ -1076,6 +1188,18 @@ impl Config { // Spawned desktop agents now carry a complete instance snapshot. Team // instructions arrive independently so they can be layered at runtime. let mut persona_env_vars = Vec::new(); + if let Some(broker) = broker.as_ref() { + persona_env_vars.extend([ + ("BUZZ_AGENT_MODE".into(), "broker".into()), + ("BUZZ_BROKER_URL".into(), broker.base_url.clone()), + ("BUZZ_BROKER_CREDENTIAL".into(), broker.credential.clone()), + // Explicit tombstones keep inherited local credentials and + // routing out of the spawned agent process. + ("BUZZ_RELAY_URL".into(), String::new()), + ("BUZZ_PRIVATE_KEY".into(), String::new()), + ("BUZZ_AUTH_TAG".into(), String::new()), + ]); + } let model = args.model; // Inject CODEX_CONFIG so the @agentclientprotocol/codex-acp adapter (1.x) @@ -1093,6 +1217,8 @@ impl Config { let config = Config { keys, + agent_mode: args.agent_mode, + broker, relay_url: args.relay_url, agent_command, agent_args, @@ -1119,11 +1245,15 @@ impl Config { channels_override: args.channels, no_mention_filter: args.no_mention_filter, config_path: args.config, - context_message_limit: args.context_message_limit, + context_message_limit: if args.agent_mode == AgentMode::Broker { + 0 + } else { + args.context_message_limit + }, max_turns_per_session: args.max_turns_per_session, - presence_enabled: !args.no_presence, - typing_enabled: !args.no_typing, - memory_enabled: args.memory && !args.no_memory, + presence_enabled: args.agent_mode == AgentMode::Local && !args.no_presence, + typing_enabled: args.agent_mode == AgentMode::Local && !args.no_typing, + memory_enabled: args.agent_mode == AgentMode::Local && args.memory && !args.no_memory, model, effort_level: args.effort_level, session_title: args @@ -1136,7 +1266,7 @@ impl Config { allowed_respond_to, persona_env_vars, has_generated_codex_config, - relay_observer: args.relay_observer, + relay_observer: args.agent_mode == AgentMode::Local && args.relay_observer, exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, @@ -1150,6 +1280,20 @@ impl Config { /// Human-readable summary (no secrets). pub fn summary(&self) -> String { + let transport_detail = match (&self.agent_mode, &self.broker) { + (AgentMode::Local, _) => format!( + "mode=local relay={} pubkey={}", + self.relay_url, + self.keys.public_key().to_hex() + ), + (AgentMode::Broker, Some(broker)) => format!( + "mode=broker broker={} pubkey=(derived-at-connect)", + broker.base_url + ), + (AgentMode::Broker, None) => { + "mode=broker broker=(missing) pubkey=(derived-at-connect)".into() + } + }; let respond_to_detail = match &self.respond_to { RespondTo::Allowlist => { format!("respond_to=allowlist({})", self.respond_to_allowlist.len()) @@ -1164,9 +1308,8 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", - self.relay_url, - self.keys.public_key().to_hex(), + "{} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + transport_detail, self.agent_command, self.agent_args.join(" "), self.mcp_command, @@ -1474,6 +1617,8 @@ mod tests { fn test_config(mode: SubscribeMode) -> Config { Config { keys: nostr::Keys::generate(), + agent_mode: AgentMode::Local, + broker: None, relay_url: "ws://localhost:3000".into(), agent_command: "goose".into(), agent_args: vec!["acp".into()], @@ -1519,6 +1664,95 @@ mod tests { } } + fn broker_args(extra: &[&str]) -> CliArgs { + const CHANNEL: &str = "5df7dfa8-e919-43df-8efd-f1dcb8af7071"; + const OWNER: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971"; + let mut args = vec![ + "buzz-acp", + "--agent-mode", + "broker", + "--private-key", + "", + "--broker-url", + "http://127.0.0.1:8787", + "--broker-credential", + "cred", + "--channels", + CHANNEL, + "--agent-owner", + OWNER, + ]; + args.extend_from_slice(extra); + CliArgs::parse_from(args) + } + + #[test] + fn broker_mode_is_keyless_and_disables_relay_only_features() { + let config = Config::from_args(broker_args(&[])).expect("valid broker config"); + + assert_eq!(config.agent_mode, AgentMode::Broker); + assert!(config.broker.is_some()); + assert!(!config.presence_enabled); + assert!(!config.typing_enabled); + assert!(!config.memory_enabled); + assert!(!config.relay_observer); + assert_eq!(config.context_message_limit, 0); + assert!(config + .persona_env_vars + .iter() + .any(|(name, value)| name == "BUZZ_AGENT_MODE" && value == "broker")); + assert!(config + .persona_env_vars + .iter() + .any(|(name, value)| name == "BUZZ_PRIVATE_KEY" && value.is_empty())); + } + + #[test] + fn broker_mode_rejects_an_agent_private_key() { + let key = "1".repeat(64); + let mut args = broker_args(&[]); + args.private_key = key; + let result = Config::from_args(args); + + assert!(result + .expect_err("private key must fail closed") + .to_string() + .contains("keyless")); + } + + #[test] + fn broker_mode_requires_explicit_channels() { + const OWNER: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971"; + let args = CliArgs::parse_from([ + "buzz-acp", + "--agent-mode", + "broker", + "--private-key", + "", + "--broker-url", + "http://127.0.0.1:8787", + "--broker-credential", + "cred", + "--agent-owner", + OWNER, + ]); + + assert!(Config::from_args(args) + .expect_err("channels are required") + .to_string() + .contains("--channels")); + } + + #[test] + fn broker_mode_requires_owner_only_author_gate() { + let result = Config::from_args(broker_args(&["--respond-to", "anyone"])); + + assert!(result + .expect_err("broker mode must fail closed without channel metadata") + .to_string() + .contains("owner-only")); + } + fn make_rule( name: &str, channels: ChannelScope, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 25c6e549052..87aaac7cbea 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -11,6 +11,7 @@ mod prompt_framing; mod prompt_project; mod queue; mod relay; +mod runtime_transport; mod setup_mode; mod usage; @@ -32,7 +33,7 @@ use buzz_core::observer::{ }; use clap::Parser; use config::{ - AuthAgentArgs, AuthMethodsArgs, AuthenticateArgs, Config, DedupMode, ModelsArgs, + AgentMode, AuthAgentArgs, AuthMethodsArgs, AuthenticateArgs, Config, DedupMode, ModelsArgs, MultipleEventHandling, RespondTo, SubscribeMode, }; use filter::SubscriptionRule; @@ -45,6 +46,7 @@ use pool::{ use pool_lifecycle::PoolLifecycle; use queue::{CancelReason, EventQueue, FlushBatch, QueuedEvent, ThreadTags}; use relay::{HarnessRelay, RelayEventPublisher}; +use runtime_transport::RuntimeTransport; use tokio::sync::{mpsc, watch}; use tracing_subscriber::EnvFilter; use uuid::Uuid; @@ -1954,6 +1956,10 @@ async fn tokio_main() -> Result<()> { if let Some(payload) = setup_mode::SetupPayload::from_env() .map_err(|e| anyhow::anyhow!("setup payload error: {e}"))? { + ensure!( + config.agent_mode == AgentMode::Local, + "setup-listener mode is not available in keyless broker mode" + ); tracing::info!("buzz-acp: setup payload present, entering setup-listener mode"); return setup_mode::run_setup_listener(config, payload).await; } @@ -1996,18 +2002,50 @@ async fn tokio_main() -> Result<()> { .unwrap_or_default() .as_secs(); - let pubkey_hex = config.keys.public_key().to_hex(); + let local_pubkey_hex = config.keys.public_key().to_hex(); // Parse BUZZ_AUTH_TAG into a nostr::Tag for NIP-OA relay membership delegation. - let relay_auth_tag: Option = std::env::var("BUZZ_AUTH_TAG") - .ok() + let relay_auth_tag: Option = (config.agent_mode == AgentMode::Local) + .then(|| std::env::var("BUZZ_AUTH_TAG").ok()) + .flatten() .filter(|s| !s.is_empty()) .and_then(|s| buzz_sdk::nip_oa::parse_auth_tag(&s).ok()); - let mut relay = - HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag) + let (mut relay, pubkey_hex) = match config.agent_mode { + AgentMode::Local => { + let relay = HarnessRelay::connect( + &config.relay_url, + &config.keys, + &local_pubkey_hex, + relay_auth_tag, + ) .await .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; + (RuntimeTransport::local(relay), local_pubkey_hex) + } + AgentMode::Broker => { + let broker = config + .broker + .as_ref() + .expect("broker config validated for broker mode"); + let channel_ids = config + .channels_override + .as_ref() + .expect("channels validated for broker mode") + .iter() + .map(|channel| Uuid::parse_str(channel).expect("channel validated")) + .collect(); + RuntimeTransport::broker( + broker.base_url.clone(), + broker.credential.clone(), + channel_ids, + broker.poll_interval, + config.keys.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!("broker connect error: {e}"))? + } + }; // Tell the relay background task the watermark so it can use // `since = watermark - 5s` on the first REQ instead of `since=now`. @@ -2017,19 +2055,30 @@ async fn tokio_main() -> Result<()> { tracing::warn!("failed to set startup watermark: {e}"); } - tracing::info!("connected to relay at {}", config.relay_url); + match config.agent_mode { + AgentMode::Local => tracing::info!("connected to relay at {}", config.relay_url), + AgentMode::Broker => tracing::info!("connected to broker as {pubkey_hex}"), + } relay .subscribe_membership_notifications() .await .map_err(|e| anyhow::anyhow!("membership notification subscribe error: {e}"))?; - tracing::info!("subscribed to membership notifications"); + match config.agent_mode { + AgentMode::Local => tracing::info!("subscribed to membership notifications"), + AgentMode::Broker => tracing::info!( + "broker contract has no membership notifications; using configured channels" + ), + } let presence_publisher = relay.event_publisher(); let presence_keys = config.keys.clone(); // Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag. - let startup_owner: Option = resolve_agent_owner(&config); + let startup_owner: Option = match config.agent_mode { + AgentMode::Local => resolve_agent_owner(&config), + AgentMode::Broker => config.agent_owner.clone(), + }; if let Some(ref owner) = startup_owner { tracing::info!("agent owner: {owner}"); } else { @@ -2192,6 +2241,10 @@ async fn tokio_main() -> Result<()> { let base_prompt_content = config.base_prompt_content.take(); let cwd = current_working_directory()?; + let channel_info = match config.agent_mode { + AgentMode::Local => pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), + AgentMode::Broker => pool::ChannelInfoResolver::without_fallback(channel_info_map), + }; let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), initial_message: config.initial_message.clone(), @@ -2211,15 +2264,20 @@ async fn tokio_main() -> Result<()> { }, heartbeat_prompt: config.heartbeat_prompt.clone(), cwd, + relay_features_enabled: config.agent_mode == AgentMode::Local, rest_client: relay.rest_client(), - channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), + channel_info, context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, permission_mode: config.permission_mode, agent_keys: config.keys.clone(), - agent_owner_pubkey: startup_owner - .as_deref() - .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), + agent_owner_pubkey: (config.agent_mode == AgentMode::Local) + .then(|| { + startup_owner + .as_deref() + .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()) + }) + .flatten(), memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), @@ -2930,7 +2988,7 @@ async fn tokio_main() -> Result<()> { // Fire-and-forget: on rare fast-failure paths the // guard's cleanup may race with this add, leaving a // cosmetic stale 👀. Acceptable — see ReactionGuard docs. - if accepted { + if accepted && config.agent_mode == AgentMode::Local { let rc = ctx.rest_client.clone(); let eid = event_id_hex.clone(); tokio::spawn(async move { @@ -3161,7 +3219,7 @@ async fn tokio_main() -> Result<()> { &respawn_tx, &mut respawn_tasks, observer.clone(), - Some(&ctx.rest_client), + (config.agent_mode == AgentMode::Local).then_some(&ctx.rest_client), ) == LoopAction::Exit { break; @@ -5079,31 +5137,53 @@ fn build_mcp_servers(config: &Config) -> Vec { command: config.mcp_command.clone(), args: vec![], env: { - let mut env = vec![ - EnvVar { - name: "BUZZ_RELAY_URL".into(), - value: config.relay_url.clone(), - }, - EnvVar { - name: "BUZZ_PRIVATE_KEY".into(), - // bech32 encoding of a valid secret key is infallible. - // Panic here is correct: injecting a bogus secret would cause - // delayed, hard-to-diagnose agent failures downstream. - value: config - .keys - .secret_key() - .to_bech32() - .expect("secret key bech32 encoding should never fail"), - }, - ]; + let mut env = match config.agent_mode { + AgentMode::Local => vec![ + EnvVar { + name: "BUZZ_RELAY_URL".into(), + value: config.relay_url.clone(), + }, + EnvVar { + name: "BUZZ_PRIVATE_KEY".into(), + // bech32 encoding of a valid secret key is infallible. + value: config + .keys + .secret_key() + .to_bech32() + .expect("secret key bech32 encoding should never fail"), + }, + ], + AgentMode::Broker => { + let broker = config + .broker + .as_ref() + .expect("broker config validated for broker mode"); + vec![ + EnvVar { + name: "BUZZ_AGENT_MODE".into(), + value: "broker".into(), + }, + EnvVar { + name: "BUZZ_BROKER_URL".into(), + value: broker.base_url.clone(), + }, + EnvVar { + name: "BUZZ_BROKER_CREDENTIAL".into(), + value: broker.credential.clone(), + }, + ] + } + }; // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) // so the MCP server can attach it to every signed event. - if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { - if !auth_tag.is_empty() { - env.push(EnvVar { - name: "BUZZ_AUTH_TAG".into(), - value: auth_tag, - }); + if config.agent_mode == AgentMode::Local { + if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { + if !auth_tag.is_empty() { + env.push(EnvVar { + name: "BUZZ_AUTH_TAG".into(), + value: auth_tag, + }); + } } } // Forward the agent's display name so dev-mcp can use it as the git @@ -6792,6 +6872,8 @@ mod build_mcp_servers_tests { fn test_config() -> Config { Config { keys: nostr::Keys::generate(), + agent_mode: config::AgentMode::Local, + broker: None, relay_url: "ws://localhost:3000".into(), agent_command: "goose".into(), agent_args: vec!["acp".into()], @@ -6856,6 +6938,27 @@ mod build_mcp_servers_tests { ); } + #[test] + fn broker_mcp_server_receives_only_broker_credentials() { + let mut config = test_config(); + config.agent_mode = config::AgentMode::Broker; + config.broker = Some(config::BrokerConfig { + base_url: "http://127.0.0.1:8787".into(), + credential: "broker-token".into(), + poll_interval: std::time::Duration::from_secs(1), + }); + + let servers = build_mcp_servers(&config); + let server = &servers[0]; + let names: Vec<&str> = server.env.iter().map(|env| env.name.as_str()).collect(); + assert!(names.contains(&"BUZZ_AGENT_MODE")); + assert!(names.contains(&"BUZZ_BROKER_URL")); + assert!(names.contains(&"BUZZ_BROKER_CREDENTIAL")); + assert!(!names.contains(&"BUZZ_RELAY_URL")); + assert!(!names.contains(&"BUZZ_PRIVATE_KEY")); + assert!(!names.contains(&"BUZZ_AUTH_TAG")); + } + #[test] fn session_new_mcp_server_forwards_buzz_auth_tag() { let _guard = ENV_LOCK.lock().unwrap(); @@ -7013,6 +7116,8 @@ mod error_outcome_emission_tests { fn test_config() -> Config { Config { keys: nostr::Keys::generate(), + agent_mode: config::AgentMode::Local, + broker: None, relay_url: "ws://localhost:3000".into(), // `true` exits cleanly, so the async respawn fails fast and // harmlessly off the JoinSet — irrelevant to the synchronous diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index f18f7d6fea2..265f8eaff2c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -561,7 +561,7 @@ const PROJECT_INFO_CACHE_TTL: std::time::Duration = std::time::Duration::from_se pub struct ChannelInfoResolver { cache: std::sync::Arc>>, projects: std::sync::Arc>>, - rest_client: RestClient, + rest_client: Option, } impl ChannelInfoResolver { @@ -586,7 +586,33 @@ impl ChannelInfoResolver { Self { cache: std::sync::Arc::new(std::sync::RwLock::new(cache)), projects: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), - rest_client, + rest_client: Some(rest_client), + } + } + + /// Build a resolver that never falls back to a direct relay metadata read. + /// + /// Broker mode uses this constructor because the client has no relay route. + /// Unknown channel types therefore remain unresolved and callers fail closed. + pub fn without_fallback(startup: std::collections::HashMap) -> Self { + let cache = startup + .into_iter() + .filter_map(|(id, info)| { + (info.channel_type != "unknown").then_some(( + id, + PromptChannelInfo { + name: info.name, + channel_type: info.channel_type, + description: info.description, + project: None, + }, + )) + }) + .collect(); + Self { + cache: std::sync::Arc::new(std::sync::RwLock::new(cache)), + projects: std::sync::Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + rest_client: None, } } @@ -599,7 +625,8 @@ impl ChannelInfoResolver { { return Some(info); } - let info = fetch_channel_info(channel_id, &self.rest_client).await?; + let rest_client = self.rest_client.as_ref()?; + let info = fetch_channel_info(channel_id, rest_client).await?; if let Ok(mut cache) = self.cache.write() { cache.insert(channel_id, info.clone()); } @@ -662,7 +689,11 @@ impl ChannelInfoResolver { { return Ok(fresh.value.clone()); } - let fetched = match fetch_project_home_for_channel(channel_id, &self.rest_client).await { + // Broker mode has no relay/REST route: no project context, fail closed. + let Some(rest_client) = self.rest_client.as_ref() else { + return Ok(None); + }; + let fetched = match fetch_project_home_for_channel(channel_id, rest_client).await { Ok(fetched) => fetched, Err(error) => { if let Some(project) = cached.and_then(|stale| stale.value) { @@ -713,6 +744,9 @@ pub struct PromptContext { /// (`include_str!`) is inherently `'static`. pub base_prompt: Option<&'static str>, pub cwd: String, + /// Whether direct relay-only enrichments and housekeeping are available. + /// False in broker mode, where the runtime has no relay route. + pub relay_features_enabled: bool, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, /// Shared channel metadata for startup-known and dynamically joined channels. @@ -1952,7 +1986,9 @@ pub async fn run_prompt_task( .as_ref() .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) .unwrap_or_default(); - let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone()); + let _reaction_guard = ctx + .relay_features_enabled + .then(|| ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone())); // Resolve project authority exactly once, before any ACP session creation or // initial-message delivery. An indeterminate result is a local relay-state @@ -2071,13 +2107,15 @@ pub async fn run_prompt_task( resolve_new_session_channel_context(resolved_channel_info.as_ref()).await; title_channel = resolved_channel; origin_channel_type = resolved_channel_type; - if let Some(owner) = ctx.agent_owner_pubkey.as_ref() { - huddle_instructions = - fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await; + if ctx.relay_features_enabled { + if let Some(owner) = ctx.agent_owner_pubkey.as_ref() { + huddle_instructions = + fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await; + } } // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. - if needs_canvas && !is_dm { + if ctx.relay_features_enabled && needs_canvas && !is_dm { if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await { pending_canvas = Some((*cid, section)); } @@ -2495,8 +2533,11 @@ pub async fn run_prompt_task( conversation_context.as_ref(), )); - let profile_lookup = - fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; + let profile_lookup = if ctx.relay_features_enabled { + fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await + } else { + None + }; let known_names: Vec<&str> = profile_lookup .iter() @@ -2549,7 +2590,7 @@ pub async fn run_prompt_task( // 💬 — fire-and-forget so the prompt fires immediately. // The guard's cleanup (spawned on drop) removes 💬 after the turn completes. // A brief race where 💬 appears slightly after the agent starts is acceptable. - if !reaction_ids.is_empty() { + if ctx.relay_features_enabled && !reaction_ids.is_empty() { let rest = ctx.rest_client.clone(); let ids = reaction_ids.clone(); tokio::spawn(async move { @@ -3498,6 +3539,9 @@ async fn fetch_conversation_context( channel_info: &Option, ctx: &PromptContext, ) -> Option { + if !ctx.relay_features_enabled { + return None; + } let limit = ctx.context_message_limit; let is_dm = channel_info .as_ref() @@ -8213,6 +8257,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" heartbeat_prompt: None, base_prompt: None, cwd: ".".to_string(), + relay_features_enabled: true, rest_client: RestClient { http: reqwest::Client::new(), base_url: "http://127.0.0.1:0".to_string(), diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 6188e57a11d..1391be78aea 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -658,6 +658,14 @@ impl RelayEventPublisher { .map_err(|_| RelayError::ConnectionClosed) } + /// A publisher with no relay behind it. Used only by broker-mode plumbing + /// for code paths disabled by provisioning (presence, typing, observer). + pub(crate) fn disabled() -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel(1); + drop(cmd_rx); + Self { cmd_tx } + } + /// Test-only publisher pair: published events are forwarded to the /// returned receiver instead of a live relay socket. #[cfg(test)] diff --git a/crates/buzz-acp/src/runtime_transport.rs b/crates/buzz-acp/src/runtime_transport.rs new file mode 100644 index 00000000000..41917f57488 --- /dev/null +++ b/crates/buzz-acp/src/runtime_transport.rs @@ -0,0 +1,356 @@ +//! Runtime event transport selected by provisioning. +//! +//! Local mode delegates to the existing authenticated relay client. Broker +//! mode polls the frozen `channel.read` action and deliberately provides no +//! relay publisher: relay-only housekeeping is disabled by broker-mode config. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::time::Duration; + +use buzz_broker_client::HttpBrokerClient; +use buzz_sdk::broker::{ + ActionArgs, ActionOutcome, BrokerClientExt, BrokerRequest, BrokerResult, ChannelReadArgs, + StorageAddressArgs, +}; +use nostr::{Event, Keys}; +use tokio::time::MissedTickBehavior; +use uuid::Uuid; + +use crate::config::ChannelFilter; +use crate::relay::{ + BuzzEvent, ChannelInfo, HarnessRelay, RelayError, RelayEventPublisher, RestClient, +}; + +pub enum RuntimeTransport { + Local(HarnessRelay), + Broker(BrokerRuntime), +} + +pub struct BrokerRuntime { + client: HttpBrokerClient, + channel_ids: Vec, + filters: HashMap, + cursors: HashMap, + pending: VecDeque, + seen_current: HashSet, + seen_previous: HashSet, + poll: tokio::time::Interval, + placeholder_keys: Keys, +} + +impl BrokerRuntime { + pub async fn connect( + base_url: String, + credential: String, + channel_ids: Vec, + poll_interval: Duration, + placeholder_keys: Keys, + ) -> Result<(Self, String), RelayError> { + let client = HttpBrokerClient::new(base_url, credential); + let outcome = execute( + &client, + ActionArgs::StorageAddress(StorageAddressArgs { + slug: "core".into(), + }), + ) + .await?; + let ActionOutcome::StorageAddress(address) = outcome else { + return Err(RelayError::Http( + "broker returned the wrong outcome for storage.address".into(), + )); + }; + let agent_pubkey = address.author_pubkey.as_str().to_string(); + let mut poll = tokio::time::interval(poll_interval); + poll.set_missed_tick_behavior(MissedTickBehavior::Skip); + Ok(( + Self { + client, + channel_ids, + filters: HashMap::new(), + cursors: HashMap::new(), + pending: VecDeque::new(), + seen_current: HashSet::new(), + seen_previous: HashSet::new(), + poll, + placeholder_keys, + }, + agent_pubkey, + )) + } + + fn remember(&mut self, event_id: String) -> bool { + if self.seen_current.contains(&event_id) || self.seen_previous.contains(&event_id) { + return false; + } + self.seen_current.insert(event_id); + if self.seen_current.len() >= 2_000 { + self.seen_previous = std::mem::take(&mut self.seen_current); + } + true + } + + async fn next_event(&mut self) -> Option { + loop { + if let Some(event) = self.pending.pop_front() { + return Some(event); + } + self.poll.tick().await; + let subscriptions: Vec<(Uuid, bool)> = self + .channel_ids + .iter() + .filter_map(|channel_id| { + self.filters + .get(channel_id) + .map(|filter| (*channel_id, filter.require_mention)) + }) + .collect(); + for (channel_id, mentions_only) in subscriptions { + let result = execute( + &self.client, + ActionArgs::ChannelRead(ChannelReadArgs { + channel_id: channel_id.to_string(), + root_event_id: None, + mentions_only, + cursor: self.cursors.get(&channel_id).cloned(), + limit: Some(100), + }), + ) + .await; + let page = match result { + Ok(ActionOutcome::ChannelRead(page)) => page, + Ok(_) => { + tracing::warn!(%channel_id, "broker returned the wrong channel.read outcome"); + continue; + } + Err(error) => { + tracing::warn!(%channel_id, "broker channel.read failed: {error}"); + continue; + } + }; + if let Some(cursor) = page.next_cursor { + self.cursors.insert(channel_id, cursor); + } + for message in page.messages { + if let Err(error) = message.verify() { + tracing::warn!(%channel_id, "broker returned an unverifiable event: {error}"); + continue; + } + let event = message.0; + let addressed_channel = event + .tags + .iter() + .find(|tag| tag.kind().to_string() == "h") + .and_then(|tag| tag.content()) + .and_then(|value| Uuid::parse_str(value).ok()); + if addressed_channel != Some(channel_id) { + tracing::warn!(%channel_id, "broker returned an event for a different channel"); + continue; + } + if self.remember(event.id.to_hex()) { + self.pending.push_back(BuzzEvent { channel_id, event }); + } + } + } + } + } +} + +async fn execute(client: &HttpBrokerClient, args: ActionArgs) -> Result { + let request = BrokerRequest::new(Uuid::new_v4().to_string(), args) + .and_then(BrokerRequest::prepare) + .map_err(|error| RelayError::Http(format!("broker request: {error}")))?; + let response = client + .execute(&request) + .await + .map_err(|error| RelayError::Http(format!("broker transport: {error}")))?; + match response.into_envelope().result { + BrokerResult::Succeeded { outcome } => Ok(outcome), + BrokerResult::Failed { error } | BrokerResult::Indeterminate { error } => { + Err(RelayError::Http(format!( + "broker verdict: {} [{}]", + error.message, + error.code.as_str() + ))) + } + } +} + +impl RuntimeTransport { + pub fn local(relay: HarnessRelay) -> Self { + Self::Local(relay) + } + + pub async fn broker( + base_url: String, + credential: String, + channel_ids: Vec, + poll_interval: Duration, + placeholder_keys: Keys, + ) -> Result<(Self, String), RelayError> { + let (runtime, pubkey) = BrokerRuntime::connect( + base_url, + credential, + channel_ids, + poll_interval, + placeholder_keys, + ) + .await?; + Ok((Self::Broker(runtime), pubkey)) + } + + pub async fn set_startup_watermark(&self, timestamp: u64) -> Result<(), RelayError> { + match self { + Self::Local(relay) => relay.set_startup_watermark(timestamp).await, + Self::Broker(_) => Ok(()), + } + } + + pub async fn subscribe_membership_notifications(&mut self) -> Result<(), RelayError> { + match self { + Self::Local(relay) => relay.subscribe_membership_notifications().await, + Self::Broker(_) => Ok(()), + } + } + + pub async fn subscribe_observer_controls(&mut self) -> Result<(), RelayError> { + match self { + Self::Local(relay) => relay.subscribe_observer_controls().await, + Self::Broker(_) => Ok(()), + } + } + + pub fn take_observer_control_rx(&mut self) -> Option> { + match self { + Self::Local(relay) => relay.take_observer_control_rx(), + Self::Broker(_) => None, + } + } + + pub async fn discover_channels(&self) -> Result, RelayError> { + match self { + Self::Local(relay) => relay.discover_channels().await, + Self::Broker(runtime) => Ok(runtime + .channel_ids + .iter() + .map(|channel_id| { + ( + *channel_id, + ChannelInfo { + name: channel_id.to_string(), + // The frozen broker contract does not expose channel + // metadata. Keep the type unknown so the runtime's + // author gate treats it as a DM (fail closed). + channel_type: "unknown".into(), + description: None, + }, + ) + }) + .collect()), + } + } + + pub async fn subscribe_channel( + &mut self, + channel_id: Uuid, + filter: ChannelFilter, + ) -> Result<(), RelayError> { + match self { + Self::Local(relay) => relay.subscribe_channel(channel_id, filter).await, + Self::Broker(runtime) => { + runtime.filters.insert(channel_id, filter); + Ok(()) + } + } + } + + pub async fn subscribe_channel_from( + &mut self, + channel_id: Uuid, + filter: ChannelFilter, + replay_since: Option, + ) -> Result<(), RelayError> { + match self { + Self::Local(relay) => { + relay + .subscribe_channel_from(channel_id, filter, replay_since) + .await + } + Self::Broker(runtime) => { + runtime.filters.insert(channel_id, filter); + Ok(()) + } + } + } + + pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> { + match self { + Self::Local(relay) => relay.unsubscribe_channel(channel_id).await, + Self::Broker(runtime) => { + runtime.filters.remove(&channel_id); + Ok(()) + } + } + } + + pub async fn next_event(&mut self) -> Option { + match self { + Self::Local(relay) => relay.next_event().await, + Self::Broker(runtime) => runtime.next_event().await, + } + } + + pub async fn reconnect(&mut self) -> Result<(), RelayError> { + match self { + Self::Local(relay) => relay.reconnect().await, + Self::Broker(_) => Ok(()), + } + } + + pub fn event_publisher(&self) -> RelayEventPublisher { + match self { + Self::Local(relay) => relay.event_publisher(), + Self::Broker(_) => RelayEventPublisher::disabled(), + } + } + + pub fn rest_client(&self) -> RestClient { + match self { + Self::Local(relay) => relay.rest_client(), + Self::Broker(runtime) => RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:1".into(), + keys: runtime.placeholder_keys.clone(), + auth_tag_json: None, + }, + } + } + + pub fn build_typing_event( + &self, + channel_id: Uuid, + root_event_id: Option<&str>, + parent_event_id: Option<&str>, + ) -> Result { + match self { + Self::Local(relay) => { + relay.build_typing_event(channel_id, root_event_id, parent_event_id) + } + Self::Broker(_) => Err(RelayError::Http( + "typing indicators are disabled in broker mode".into(), + )), + } + } + + pub fn try_publish_event(&self, event: Event) -> Result<(), RelayError> { + match self { + Self::Local(relay) => relay.try_publish_event(event), + Self::Broker(_) => Err(RelayError::ConnectionClosed), + } + } + + pub async fn shutdown(self) { + if let Self::Local(relay) = self { + relay.shutdown().await; + } + } +} diff --git a/crates/buzz-broker-client/Cargo.toml b/crates/buzz-broker-client/Cargo.toml new file mode 100644 index 00000000000..f95f84985b5 --- /dev/null +++ b/crates/buzz-broker-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "buzz-broker-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared HTTP transport for the Buzz agent broker" + +[dependencies] +buzz-sdk = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +axum = { workspace = true } +tokio = { workspace = true } diff --git a/crates/buzz-cli/src/broker_client.rs b/crates/buzz-broker-client/src/lib.rs similarity index 90% rename from crates/buzz-cli/src/broker_client.rs rename to crates/buzz-broker-client/src/lib.rs index 3bf3f532362..d656cdb5b1e 100644 --- a/crates/buzz-cli/src/broker_client.rs +++ b/crates/buzz-broker-client/src/lib.rs @@ -1,14 +1,9 @@ -//! HTTP transport for the agent broker — keyless client mode. +//! Shared HTTP transport for the agent broker. //! //! Implements [`buzz_sdk::broker::BrokerClient`], the transport primitive the //! contract crate deliberately omits: frozen request bytes out, one envelope //! back. Callers use [`buzz_sdk::broker::BrokerClientExt::execute`], which adds -//! the correlation checks; this type must never interpret a verdict. -//! -//! The binding is one `POST /v1/action` with the opaque bearer credential in -//! the `Authorization` header. An envelope is parsed regardless of HTTP status, -//! because a host verdict lives in the body and an intermediary may remap the -//! status line. +//! correlation checks; this type never interprets a verdict. use buzz_sdk::broker::{ BrokerClient, BrokerFuture, BrokerResponse, BrokerTransportError, Dispatch, PreparedRequest, @@ -26,8 +21,7 @@ pub struct HttpBrokerClient { } impl HttpBrokerClient { - /// A client posting to `base_url` (scheme + authority, no path) with - /// `credential` as its bearer token. + /// A client posting to `base_url` with `credential` as its bearer token. pub fn new(base_url: impl Into, credential: impl Into) -> Self { Self::with_client(base_url, credential, reqwest::Client::new()) } @@ -175,16 +169,12 @@ mod tests { r#"{{"type":"broker_result","protocolVersion":1,"requestId":"{}","status":"failed","error":{{"code":"unauthenticated","message":"nope"}}}}"#, req.request_id(), ); - // A rejected credential arrives as HTTP 200 with a Failed envelope. let (base, _) = spawn(StatusCode::OK, body).await; let client = HttpBrokerClient::new(base, CRED); let validated = client.execute(&req).await.expect("a verdict"); - match validated.result() { - BrokerResult::Failed { .. } => {} - other => panic!("expected Failed, got {other:?}"), - } + assert!(matches!(validated.result(), BrokerResult::Failed { .. })); } #[tokio::test] diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee6..6d65a631f67 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -43,6 +43,7 @@ chrono = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } +buzz-broker-client = { workspace = true } buzz-core = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index 966e76fa190..0050d1099de 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -16,6 +16,13 @@ path), `messages send` / reply, `reactions add`, `users set-profile`, and `mem address ` as an addressing-only bridge for encrypted memory. Every other command still needs the local backend. +The same provisioning variables now select the prototype keyless `buzz-acp` +runtime. It derives its public identity through `storage.address`, polls +configured channels through `channel.read`, and passes broker provisioning to +the CLI used by the spawned agent. See +[`../buzz-acp/README.md`](../buzz-acp/README.md#keyless-broker-mode-prototype) +for the runtime command and its deliberate housekeeping limits. + ## Build ```sh diff --git a/crates/buzz-cli/src/backend.rs b/crates/buzz-cli/src/backend.rs index 971a9ba5578..a975848c06c 100644 --- a/crates/buzz-cli/src/backend.rs +++ b/crates/buzz-cli/src/backend.rs @@ -18,9 +18,9 @@ use buzz_sdk::broker::{ }; use buzz_sdk::ThreadRef; -use crate::broker_client::HttpBrokerClient; use crate::client::BuzzClient; use crate::error::CliError; +use buzz_broker_client::HttpBrokerClient; /// The operations an agent performs, in the broker's vocabulary. /// diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0d108cb6b64..8a553dc4a24 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1,6 +1,5 @@ pub mod agent_management; pub mod backend; -pub mod broker_client; mod client; mod commands; mod error; From 8e439bd8bea1ba45a274323ed5dfe27abc8c0079 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Thu, 27 Aug 2026 13:40:56 +1000 Subject: [PATCH 07/13] keyless: restore the broker reply path Remove empty local-credential tombstones from spawned agent environments and defensively treat an empty CLI private-key value as absent, while continuing to reject all real key material. Bound broker HTTP actions, terminate polling on rejected credentials, and document the cursor, restart, and thread-context limits that remain for real-host integration. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Joel Robotham --- crates/buzz-acp/README.md | 6 ++ crates/buzz-acp/src/acp.rs | 65 ++++++++++++++++++- crates/buzz-acp/src/runtime_transport.rs | 64 ++++++++++++++---- crates/buzz-broker-client/Cargo.toml | 2 +- crates/buzz-broker-client/src/lib.rs | 82 ++++++++++++++++++------ crates/buzz-cli/KEYLESS.md | 11 ++++ crates/buzz-cli/src/commands/messages.rs | 2 +- crates/buzz-cli/src/lib.rs | 20 +++++- 8 files changed, 218 insertions(+), 34 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 88f866732bf..3d7656c5f4b 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -96,6 +96,12 @@ nudges, and core-memory injection are disabled. The `buzz` CLI operations available to the spawned agent are documented in [`../buzz-cli/KEYLESS.md`](../buzz-cli/KEYLESS.md). +Broker actions time out after 30 seconds, and an `unauthenticated` polling +verdict terminates the harness so a revoked credential cannot leave a +warn-spamming process that appears healthy. Poll cursors and event deduplication +remain in memory only; restart-window semantics are a required real-host +integration check. + ## Running with Codex [codex-acp](https://github.com/agentclientprotocol/codex-acp) wraps OpenAI Codex in an ACP interface. diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 792221092ba..e6a17fe5372 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -520,7 +520,9 @@ impl AcpClient { | "BUZZ_PRIVATE_KEY" | "BUZZ_AUTH_TAG" ); - if force_runtime_provisioning || std::env::var_os(key).is_none() { + if force_runtime_provisioning && value.is_empty() { + cmd.env_remove(key); + } else if force_runtime_provisioning || std::env::var_os(key).is_none() { cmd.env(key, value); } } @@ -3107,6 +3109,67 @@ mod tests { observed } + /// Spawn a probe that distinguishes an absent variable from a present but + /// empty one. This pins broker tombstones to `env_remove`, not `KEY=`. + #[cfg(unix)] + async fn spawn_named_and_probe_child_env_presence( + file_name: &str, + var: &str, + extra_env: &[(String, String)], + ) -> String { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!( + "buzz-acp-env-presence-probe-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).expect("create env presence probe dir"); + let path = dir.join(file_name); + std::fs::write( + &path, + format!( + "#!/bin/sh\nif [ \"${{{var}+x}}\" = x ]; then printf 'set\\n'; else printf 'unset\\n'; fi\n" + ), + ) + .expect("write env presence probe script"); + let mut permissions = std::fs::metadata(&path) + .expect("stat presence probe") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&path, permissions).expect("chmod presence probe"); + + let mut client = AcpClient::spawn( + path.to_str().expect("probe path is UTF-8"), + &[], + extra_env, + false, + ) + .await + .expect("spawn env presence probe script"); + let observed = client + .reader + .next() + .await + .expect("child produced no presence output") + .expect("child presence output was not readable"); + client.shutdown().await; + std::fs::remove_dir_all(&dir).expect("remove env presence probe dir"); + observed + } + + #[cfg(unix)] + #[tokio::test] + async fn empty_broker_tombstone_removes_inherited_key_from_child() { + let observed = spawn_named_and_probe_child_env_presence( + "goose", + "BUZZ_PRIVATE_KEY", + &[("BUZZ_PRIVATE_KEY".into(), String::new())], + ) + .await; + + assert_eq!(observed, "unset"); + } + /// Buzz-owned Hermes processes get the configured-MCP isolation default, /// and an explicit persona entry still overrides it (defaults are applied /// before `extra_env`, so the later `Command::env` write wins). diff --git a/crates/buzz-acp/src/runtime_transport.rs b/crates/buzz-acp/src/runtime_transport.rs index 41917f57488..95cc0fcc64c 100644 --- a/crates/buzz-acp/src/runtime_transport.rs +++ b/crates/buzz-acp/src/runtime_transport.rs @@ -9,8 +9,8 @@ use std::time::Duration; use buzz_broker_client::HttpBrokerClient; use buzz_sdk::broker::{ - ActionArgs, ActionOutcome, BrokerClientExt, BrokerRequest, BrokerResult, ChannelReadArgs, - StorageAddressArgs, + ActionArgs, ActionOutcome, BrokerClientExt, BrokerErrorCode, BrokerRequest, BrokerResult, + ChannelReadArgs, StorageAddressArgs, }; use nostr::{Event, Keys}; use tokio::time::MissedTickBehavior; @@ -36,6 +36,24 @@ pub struct BrokerRuntime { seen_previous: HashSet, poll: tokio::time::Interval, placeholder_keys: Keys, + terminal_error: Option, +} + +struct BrokerActionError { + detail: String, + code: Option, +} + +impl std::fmt::Display for BrokerActionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.detail) + } +} + +impl BrokerActionError { + fn relay_error(self) -> RelayError { + RelayError::Http(self.detail) + } } impl BrokerRuntime { @@ -53,7 +71,8 @@ impl BrokerRuntime { slug: "core".into(), }), ) - .await?; + .await + .map_err(BrokerActionError::relay_error)?; let ActionOutcome::StorageAddress(address) = outcome else { return Err(RelayError::Http( "broker returned the wrong outcome for storage.address".into(), @@ -73,6 +92,7 @@ impl BrokerRuntime { seen_previous: HashSet::new(), poll, placeholder_keys, + terminal_error: None, }, agent_pubkey, )) @@ -122,6 +142,11 @@ impl BrokerRuntime { tracing::warn!(%channel_id, "broker returned the wrong channel.read outcome"); continue; } + Err(error) if error.code == Some(BrokerErrorCode::Unauthenticated) => { + tracing::error!(%channel_id, "broker credential was rejected: {error}"); + self.terminal_error = Some(error.to_string()); + return None; + } Err(error) => { tracing::warn!(%channel_id, "broker channel.read failed: {error}"); continue; @@ -155,22 +180,34 @@ impl BrokerRuntime { } } -async fn execute(client: &HttpBrokerClient, args: ActionArgs) -> Result { +async fn execute( + client: &HttpBrokerClient, + args: ActionArgs, +) -> Result { let request = BrokerRequest::new(Uuid::new_v4().to_string(), args) .and_then(BrokerRequest::prepare) - .map_err(|error| RelayError::Http(format!("broker request: {error}")))?; + .map_err(|error| BrokerActionError { + detail: format!("broker request: {error}"), + code: None, + })?; let response = client .execute(&request) .await - .map_err(|error| RelayError::Http(format!("broker transport: {error}")))?; + .map_err(|error| BrokerActionError { + detail: format!("broker transport: {error}"), + code: None, + })?; match response.into_envelope().result { BrokerResult::Succeeded { outcome } => Ok(outcome), BrokerResult::Failed { error } | BrokerResult::Indeterminate { error } => { - Err(RelayError::Http(format!( - "broker verdict: {} [{}]", - error.message, - error.code.as_str() - ))) + Err(BrokerActionError { + detail: format!( + "broker verdict: {} [{}]", + error.message, + error.code.as_str() + ), + code: Some(error.code), + }) } } } @@ -302,7 +339,10 @@ impl RuntimeTransport { pub async fn reconnect(&mut self) -> Result<(), RelayError> { match self { Self::Local(relay) => relay.reconnect().await, - Self::Broker(_) => Ok(()), + Self::Broker(runtime) => match &runtime.terminal_error { + Some(error) => Err(RelayError::Http(error.clone())), + None => Ok(()), + }, } } diff --git a/crates/buzz-broker-client/Cargo.toml b/crates/buzz-broker-client/Cargo.toml index f95f84985b5..ce36cfd2431 100644 --- a/crates/buzz-broker-client/Cargo.toml +++ b/crates/buzz-broker-client/Cargo.toml @@ -11,7 +11,7 @@ description = "Shared HTTP transport for the Buzz agent broker" buzz-sdk = { workspace = true } reqwest = { workspace = true } serde_json = { workspace = true } +tokio = { workspace = true } [dev-dependencies] axum = { workspace = true } -tokio = { workspace = true } diff --git a/crates/buzz-broker-client/src/lib.rs b/crates/buzz-broker-client/src/lib.rs index d656cdb5b1e..f0ca5c36a93 100644 --- a/crates/buzz-broker-client/src/lib.rs +++ b/crates/buzz-broker-client/src/lib.rs @@ -10,6 +10,8 @@ use buzz_sdk::broker::{ BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, }; +const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + /// A broker host endpoint plus the agent's bearer credential. /// /// Holds no key and knows nothing of the relay: its whole authority is the @@ -18,6 +20,7 @@ pub struct HttpBrokerClient { base_url: String, credential: String, http: reqwest::Client, + request_timeout: std::time::Duration, } impl HttpBrokerClient { @@ -31,11 +34,21 @@ impl HttpBrokerClient { base_url: impl Into, credential: impl Into, http: reqwest::Client, + ) -> Self { + Self::with_client_timeout(base_url, credential, http, DEFAULT_REQUEST_TIMEOUT) + } + + fn with_client_timeout( + base_url: impl Into, + credential: impl Into, + http: reqwest::Client, + request_timeout: std::time::Duration, ) -> Self { Self { base_url: base_url.into(), credential: credential.into(), http, + request_timeout, } } } @@ -47,24 +60,34 @@ impl BrokerClient for HttpBrokerClient { "{}{BROKER_ACTION_PATH}", self.base_url.trim_end_matches('/') ); - let response = self - .http - .post(url) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header( - BROKER_CREDENTIAL_HEADER, - format!("Bearer {}", self.credential), - ) - .body(request.body().to_vec()) - .send() - .await - .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; - - let status = response.status().as_u16(); - let body = response - .bytes() - .await - .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; + let (status, body) = tokio::time::timeout(self.request_timeout, async { + let response = self + .http + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + BROKER_CREDENTIAL_HEADER, + format!("Bearer {}", self.credential), + ) + .body(request.body().to_vec()) + .send() + .await + .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; + + let status = response.status().as_u16(); + let body = response + .bytes() + .await + .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; + Ok::<_, BrokerTransportError>((status, body)) + }) + .await + .map_err(|_| { + BrokerTransportError::Unreachable(format!( + "broker request timed out after {} seconds", + self.request_timeout.as_secs_f64() + )) + })??; // Parse an envelope whatever the status. Only its absence makes the // status meaningful, and then only as operator detail. @@ -199,4 +222,27 @@ mod tests { assert!(matches!(err, BrokerTransportError::Unreachable(_))); } + + #[tokio::test] + async fn hung_host_is_bounded_by_the_transport_timeout() { + let app = Router::new().route( + BROKER_ACTION_PATH, + post(|| async { std::future::pending::().await }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let client = HttpBrokerClient::with_client_timeout( + format!("http://{addr}"), + CRED, + reqwest::Client::new(), + std::time::Duration::from_millis(20), + ); + let err = client.execute(&post_request()).await.expect_err("timeout"); + + assert!( + matches!(err, BrokerTransportError::Unreachable(message) if message.contains("timed out")) + ); + } } diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index 0050d1099de..176c1f99620 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -82,6 +82,17 @@ is a minimal reference for the wire shape. not silently ignored. - Credential issuance, authorization, and custody are the host's concern; the client only needs an endpoint and a token to present. +- Broker HTTP actions have a 30-second end-to-end transport timeout. A runtime + credential rejected as `unauthenticated` is terminal instead of being polled + forever. +- Runtime cursors and event deduplication are currently in memory only. The + first cursorless read after restart follows the host's default-window + semantics, which must be pinned at the real-host integration checkpoint to + prevent missed or replayed mentions. +- This CLI slice exposes only the first `messages get --limit` window; broker + cursor pagination and thread reads are not yet command-line options. The ACP + runtime therefore prompts from the triggering event without relay-fetched + thread history. - `buzz mem address ` prints the broker's `{authorPubkey, kind, dTag}` outcome as JSON. This temporary bridge proves secret-dependent address derivation without giving the client a key or relay route. It does not yet diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f4558683c9c..de35376e520 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -985,7 +985,7 @@ pub async fn dispatch_broker(cmd: crate::MessagesCmd, backend: &Backend) -> Resu if before.is_some() || since.is_some() || kinds.is_some() { return Err(CliError::Usage( "--before/--since/--kinds are not supported in keyless mode; the host owns \ - windowing (use --limit and the returned cursor)" + windowing (only the first --limit window is exposed by this CLI slice)" .into(), )); } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 8a553dc4a24..05ca8ee4f2c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2152,7 +2152,7 @@ async fn run_broker(cli: Cli) -> Result<(), CliError> { // Fail closed on the keyless invariant: a key present on the box contradicts // broker mode, and silently ignoring it would let a misprovisioned "keyless" // agent run with an nsec sitting right there. - if cli.private_key.is_some() { + if broker_private_key_present(cli.private_key.as_deref()) { return Err(CliError::Usage( "broker mode is keyless — don't supply a private key. Unset --private-key / \ BUZZ_PRIVATE_KEY to run keyless, or use --agent-mode=local to sign with it." @@ -2188,11 +2188,29 @@ async fn run_broker(cli: Cli) -> Result<(), CliError> { } } +/// Whether broker provisioning contains actual key material. +/// +/// Clap represents a present-but-empty environment variable as `Some("")`. +/// Treat that exact tombstone as absent so a parent process can scrub an +/// inherited key without breaking broker mode; every non-empty value still +/// fails closed. +fn broker_private_key_present(private_key: Option<&str>) -> bool { + private_key.is_some_and(|value| !value.is_empty()) +} + #[cfg(test)] mod tests { use super::*; use clap::CommandFactory; + #[test] + fn broker_key_guard_treats_only_an_empty_tombstone_as_absent() { + assert!(!broker_private_key_present(None)); + assert!(!broker_private_key_present(Some(""))); + assert!(broker_private_key_present(Some(" "))); + assert!(broker_private_key_present(Some("nsec1secret"))); + } + /// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty /// conditions field becomes `""`. #[test] From 8254e763ec5a2e0351f4ffa1735968fbd493f4a2 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Fri, 28 Aug 2026 15:29:26 +1000 Subject: [PATCH 08/13] fix(keyless): preserve broker metadata fallback fence Signed-off-by: Joel Robotham --- crates/buzz-acp/src/pool.rs | 49 ++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 265f8eaff2c..718d1f876d8 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -653,10 +653,14 @@ impl ChannelInfoResolver { // one bounded attempt so relay degradation cannot add the full retry // window to every prompt. Unknown channels still use the retrying lazy // fetch below because callers must fail closed without metadata. - let refreshed = if cached.is_some() { - fetch_channel_info_once(channel_id, &self.rest_client).await + let refreshed = if let Some(rest_client) = self.rest_client.as_ref() { + if cached.is_some() { + fetch_channel_info_once(channel_id, rest_client).await + } else { + fetch_channel_info(channel_id, rest_client).await + } } else { - fetch_channel_info(channel_id, &self.rest_client).await + None }; let mut info = match refreshed { Some(fresh) => { @@ -8707,6 +8711,45 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" json!([{ "tags": event_tags }]) } + #[tokio::test] + async fn broker_resolver_uses_known_metadata_without_a_relay_fallback() { + let known_id = Uuid::new_v4(); + let unknown_id = Uuid::new_v4(); + let resolver = ChannelInfoResolver::without_fallback( + [ + ( + known_id, + crate::relay::ChannelInfo { + name: "known".into(), + channel_type: "stream".into(), + description: None, + }, + ), + ( + unknown_id, + crate::relay::ChannelInfo { + name: "unknown".into(), + channel_type: "unknown".into(), + description: None, + }, + ), + ] + .into_iter() + .collect(), + ); + + let known = resolver + .resolve(known_id) + .await + .expect("known broker metadata resolves") + .expect("known broker metadata remains cached"); + assert_eq!(known.name, "known"); + assert!( + resolver.resolve(unknown_id).await.unwrap().is_none(), + "unknown broker metadata must stay unresolved without a relay route" + ); + } + #[tokio::test] async fn expired_absence_refreshes_to_project_without_restart() { use std::sync::atomic::Ordering; From f46641ffe819fe06f83b22b3371104e8ac7af7da Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Fri, 28 Aug 2026 16:17:11 +1000 Subject: [PATCH 09/13] feat(keyless): route broker capabilities Signed-off-by: Joel Robotham --- Cargo.lock | 1 + crates/buzz-acp/Cargo.toml | 1 + crates/buzz-acp/README.md | 24 +- crates/buzz-acp/src/config.rs | 74 +++-- crates/buzz-acp/src/engram_fetch.rs | 16 +- crates/buzz-acp/src/lib.rs | 161 ++++++++--- crates/buzz-acp/src/pool.rs | 108 ++++++-- crates/buzz-acp/src/relay.rs | 68 ++--- crates/buzz-acp/src/runtime_transport.rs | 339 +++++++++++++++++++++-- crates/buzz-broker-client/src/lib.rs | 133 +++++++-- crates/buzz-cli/KEYLESS.md | 31 ++- crates/buzz-cli/examples/mock_broker.rs | 36 +++ crates/buzz-cli/src/backend.rs | 115 +++++++- crates/buzz-cli/src/commands/mem.rs | 96 ++++++- crates/buzz-cli/src/commands/users.rs | 21 +- crates/buzz-cli/src/lib.rs | 7 +- 16 files changed, 996 insertions(+), 235 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 252eb9b1bed..dbdca88cc19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,6 +830,7 @@ name = "buzz-acp" version = "0.1.0" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "buzz-broker-client", "buzz-core", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index 8a3dd9748ca..f10b001ab79 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -78,5 +78,6 @@ evalexpr = { workspace = true } nix = { version = "0.31", default-features = false, features = ["signal"] } [dev-dependencies] +axum = { workspace = true } tokio = { workspace = true, features = ["test-util"] } httparse = "1" diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 3d7656c5f4b..d42247b05d8 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -87,20 +87,20 @@ unset BUZZ_PRIVATE_KEY BUZZ_RELAY_URL BUZZ_AUTH_TAG buzz-acp ``` -This slice is intentionally receive-and-reply focused. Because the frozen -broker contract does not expose channel discovery, channel metadata, profiles, -or runtime housekeeping operations, broker mode currently requires explicit -channel UUIDs and `respond-to=owner-only`. Presence, typing, reactions used as -turn status, observer/liveness events, relay conversation enrichment, setup -nudges, and core-memory injection are disabled. The `buzz` CLI operations -available to the spawned agent are documented in +Broker mode requires explicit channel UUIDs and `respond-to=owner-only` because +the contract does not expose channel discovery or metadata. Core-memory reads, +presence, typing, observer telemetry, and turn liveness use broker actions. +Relay-only conversation enrichment, setup nudges, sibling-profile lookup, and +reaction-based turn status remain disabled. The `buzz` CLI operations available +to the spawned agent are documented in [`../buzz-cli/KEYLESS.md`](../buzz-cli/KEYLESS.md). -Broker actions time out after 30 seconds, and an `unauthenticated` polling -verdict terminates the harness so a revoked credential cannot leave a -warn-spamming process that appears healthy. Poll cursors and event deduplication -remain in memory only; restart-window semantics are a required real-host -integration check. +Broker actions time out after 30 seconds and require TLS except on loopback. An +`unauthenticated` polling verdict terminates the harness so a revoked credential +cannot leave a warn-spamming process that appears healthy. Poll cursors and +event deduplication remain in memory only. After a pagination chain is drained, +the runtime returns to cursorless polling and relies on bounded event-ID deduplication so later +messages remain visible. ## Running with Codex diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 8aa052ed078..62ac20565e5 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use clap::Parser; use clap::ValueEnum; -use nostr::Keys; +use nostr::{Keys, PublicKey}; use thiserror::Error; use url::Url; use uuid::Uuid; @@ -965,11 +965,17 @@ impl Config { .into(), )); } - if args.agent_owner.is_none() { - return Err(ConfigError::ConfigFile( + let owner = args.agent_owner.as_deref().ok_or_else(|| { + ConfigError::ConfigFile( "--agent-owner / BUZZ_ACP_AGENT_OWNER is required in broker mode".into(), - )); - } + ) + })?; + let owner = PublicKey::from_hex(owner.trim()).map_err(|error| { + ConfigError::ConfigFile(format!( + "broker-mode --agent-owner must be a 64-hex public key: {error}" + )) + })?; + args.agent_owner = Some(owner.to_hex()); Some(BrokerConfig { base_url, credential, @@ -1188,8 +1194,8 @@ impl Config { // Spawned desktop agents now carry a complete instance snapshot. Team // instructions arrive independently so they can be layered at runtime. let mut persona_env_vars = Vec::new(); - if let Some(broker) = broker.as_ref() { - persona_env_vars.extend([ + match broker.as_ref() { + Some(broker) => persona_env_vars.extend([ ("BUZZ_AGENT_MODE".into(), "broker".into()), ("BUZZ_BROKER_URL".into(), broker.base_url.clone()), ("BUZZ_BROKER_CREDENTIAL".into(), broker.credential.clone()), @@ -1198,7 +1204,14 @@ impl Config { ("BUZZ_RELAY_URL".into(), String::new()), ("BUZZ_PRIVATE_KEY".into(), String::new()), ("BUZZ_AUTH_TAG".into(), String::new()), - ]); + ]), + None => persona_env_vars.extend([ + ("BUZZ_AGENT_MODE".into(), "local".into()), + ("BUZZ_BROKER_URL".into(), String::new()), + ("BUZZ_BROKER_CREDENTIAL".into(), String::new()), + ("BUZZ_RELAY_URL".into(), args.relay_url.clone()), + ("BUZZ_PRIVATE_KEY".into(), keys.secret_key().to_secret_hex()), + ]), } let model = args.model; @@ -1251,9 +1264,9 @@ impl Config { args.context_message_limit }, max_turns_per_session: args.max_turns_per_session, - presence_enabled: args.agent_mode == AgentMode::Local && !args.no_presence, - typing_enabled: args.agent_mode == AgentMode::Local && !args.no_typing, - memory_enabled: args.agent_mode == AgentMode::Local && args.memory && !args.no_memory, + presence_enabled: !args.no_presence, + typing_enabled: !args.no_typing, + memory_enabled: args.memory && !args.no_memory, model, effort_level: args.effort_level, session_title: args @@ -1266,7 +1279,7 @@ impl Config { allowed_respond_to, persona_env_vars, has_generated_codex_config, - relay_observer: args.agent_mode == AgentMode::Local && args.relay_observer, + relay_observer: args.relay_observer, exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, @@ -1687,14 +1700,14 @@ mod tests { } #[test] - fn broker_mode_is_keyless_and_disables_relay_only_features() { + fn broker_mode_is_keyless_and_enables_broker_capabilities() { let config = Config::from_args(broker_args(&[])).expect("valid broker config"); assert_eq!(config.agent_mode, AgentMode::Broker); assert!(config.broker.is_some()); - assert!(!config.presence_enabled); - assert!(!config.typing_enabled); - assert!(!config.memory_enabled); + assert!(config.presence_enabled); + assert!(config.typing_enabled); + assert!(config.memory_enabled); assert!(!config.relay_observer); assert_eq!(config.context_message_limit, 0); assert!(config @@ -1707,6 +1720,35 @@ mod tests { .any(|(name, value)| name == "BUZZ_PRIVATE_KEY" && value.is_empty())); } + #[test] + fn broker_mode_rejects_an_invalid_owner_pubkey() { + let mut args = broker_args(&[]); + args.agent_owner = Some("not-a-pubkey".into()); + assert!(Config::from_args(args) + .expect_err("invalid owner must fail at startup") + .to_string() + .contains("64-hex")); + } + + #[test] + fn explicit_local_mode_tombstones_inherited_broker_provisioning() { + let key = "1".repeat(64); + let args = + CliArgs::parse_from(["buzz-acp", "--agent-mode", "local", "--private-key", &key]); + let config = Config::from_args(args).expect("valid local config"); + let value = |name: &str| { + config + .persona_env_vars + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + assert_eq!(value("BUZZ_AGENT_MODE"), Some("local")); + assert_eq!(value("BUZZ_BROKER_URL"), Some("")); + assert_eq!(value("BUZZ_BROKER_CREDENTIAL"), Some("")); + assert!(value("BUZZ_PRIVATE_KEY").is_some_and(|value| !value.is_empty())); + } + #[test] fn broker_mode_rejects_an_agent_private_key() { let key = "1".repeat(64); diff --git a/crates/buzz-acp/src/engram_fetch.rs b/crates/buzz-acp/src/engram_fetch.rs index d5ae6df0762..3863d419226 100644 --- a/crates/buzz-acp/src/engram_fetch.rs +++ b/crates/buzz-acp/src/engram_fetch.rs @@ -25,6 +25,13 @@ pub const ONBOARDING_NUDGE: &str = "No core memory found. \ Use `buzz mem set core \"…\"` to create one (it will hold your identity, \ rules, and goals across sessions). Ask your user about yourself."; +pub fn render_core_section(profile: Option) -> Option { + Some(crate::prompt_framing::semantic_section( + "core-memory", + profile.as_deref().unwrap_or(ONBOARDING_NUDGE), + )) +} + /// Build the rendered prompt section for the agent's core. /// /// Returns: @@ -39,14 +46,7 @@ pub async fn build_core_section( owner: &PublicKey, ) -> Option { match fetch_core_body(rest, agent_keys, owner).await { - Ok(Some(profile)) => Some(crate::prompt_framing::semantic_section( - "core-memory", - &profile, - )), - Ok(None) => Some(crate::prompt_framing::semantic_section( - "core-memory", - ONBOARDING_NUDGE, - )), + Ok(profile) => render_core_section(profile), Err(reason) => { tracing::warn!( target: "engram::core", diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 87aaac7cbea..31da7c4bf3d 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -86,30 +86,6 @@ fn current_working_directory() -> Result { Ok(cwd.to_string_lossy().into_owned()) } -/// Publish a kind:20001 presence update event via the WebSocket connection. -/// -/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence -/// updates must be routed through the WS path. -/// -/// Content is a bare status string (`"online"`, `"away"`, `"offline"`) matching -/// the desktop client's format. The relay stores this in Redis and synthesizes -/// it back on presence queries. -async fn publish_presence( - publisher: &relay::RelayEventPublisher, - keys: &nostr::Keys, - status: &str, -) -> Result<(), relay::RelayError> { - use buzz_core::kind::KIND_PRESENCE_UPDATE; - use nostr::{EventBuilder, Kind}; - - let event = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status) - .tags([]) - .sign_with_keys(keys) - .map_err(|e| relay::RelayError::Http(format!("presence sign error: {e}")))?; - publisher.publish_event(event).await?; - Ok(()) -} - fn emit_runtime_lifecycle( observer: Option<&observer::ObserverHandle>, start_nonce: &str, @@ -648,6 +624,69 @@ fn spawn_relay_observer_publisher( }) } +fn spawn_broker_observer_publisher( + observer: observer::ObserverHandle, + broker: runtime_transport::BrokerActions, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut rx = observer.subscribe(); + let snapshot = observer.snapshot(); + let mut queue = ObserverPublishQueue::default(); + let max_snapshot_seq = snapshot.iter().map(|event| event.seq).max().unwrap_or(0); + for event in snapshot { + queue.ingest(event); + } + + let mut publish_tick = tokio::time::interval_at( + tokio::time::Instant::now() + OBSERVER_PUBLISH_TICK, + OBSERVER_PUBLISH_TICK, + ); + publish_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut closed = false; + loop { + tokio::select! { + result = rx.recv(), if !closed => match result { + Ok(event) if event.seq > max_snapshot_seq => queue.ingest(event), + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + tracing::warn!(dropped = count, "broker observer publisher lagged"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => closed = true, + }, + _ = publish_tick.tick() => { + if let Some(mut event) = queue.next_frame() { + fit_observer_event_to_budget(&mut event); + match serde_json::to_string(&event) { + Ok(payload) => { + let frame = buzz_sdk::broker::ObserverFrame { + kind: event.kind.clone(), + payload, + }; + match broker.observer_emit(vec![frame]).await { + Ok(receipt) if receipt.accepted == 1 => {} + Ok(receipt) => tracing::warn!( + accepted = receipt.accepted, + "broker did not accept the observer frame" + ), + Err(error) => tracing::warn!( + "broker observer frame dropped: {error}" + ), + } + } + Err(error) => tracing::warn!( + "failed to serialize broker observer frame: {error}" + ), + } + } + if closed && queue.is_empty() { + break; + } + } + } + } + }) +} + async fn run_relay_observer_publisher( snapshot: Vec, mut rx: tokio::sync::broadcast::Receiver, @@ -2071,8 +2110,8 @@ async fn tokio_main() -> Result<()> { ), } - let presence_publisher = relay.event_publisher(); - let presence_keys = config.keys.clone(); + let signal_publisher = relay.signal_publisher(config.keys.clone()); + let broker_actions = relay.broker_actions(); // Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag. let startup_owner: Option = match config.agent_mode { @@ -2108,8 +2147,14 @@ async fn tokio_main() -> Result<()> { let mut relay_observer_control_rx = None; let mut relay_observer_publisher_task = None; let mut relay_observer_publisher = None; + let mut broker_observer_publisher = None; if config.relay_observer { - if let (Some(observer), Some(owner_pubkey_hex)) = + if config.agent_mode == AgentMode::Broker { + if let (Some(observer), Some(actions)) = (observer.clone(), broker_actions.clone()) { + broker_observer_publisher = Some((observer, actions)); + tracing::info!("broker observer enabled"); + } + } else if let (Some(observer), Some(owner_pubkey_hex)) = (observer.clone(), owner_cache.pubkey.clone()) { match PublicKey::from_hex(&owner_pubkey_hex) { @@ -2212,6 +2257,9 @@ async fn tokio_main() -> Result<()> { owner, )); } + if let Some((observer, actions)) = broker_observer_publisher.take() { + relay_observer_publisher_task = Some(spawn_broker_observer_publisher(observer, actions)); + } let runtime_start_nonce = std::env::var("BUZZ_MANAGED_AGENT_START_NONCE").unwrap_or_default(); let dedup_mode = config.dedup_mode; @@ -2222,7 +2270,10 @@ async fn tokio_main() -> Result<()> { // connected. Publishing after channel subscriptions gives desktop callers // a durable readiness boundary before they send a startup mention. if config.presence_enabled { - match publish_presence(&presence_publisher, &presence_keys, "online").await { + match signal_publisher + .presence_set(buzz_core::presence::PresenceStatus::Online) + .await + { Ok(_) => tracing::info!("presence set to online"), Err(e) => tracing::warn!("failed to set initial presence: {e}"), } @@ -2265,6 +2316,7 @@ async fn tokio_main() -> Result<()> { heartbeat_prompt: config.heartbeat_prompt.clone(), cwd, relay_features_enabled: config.agent_mode == AgentMode::Local, + broker_actions, rest_client: relay.rest_client(), channel_info, context_message_limit: config.context_message_limit, @@ -3163,10 +3215,12 @@ async fn tokio_main() -> Result<()> { if let Some(h) = presence_task.take() { h.abort(); } - let pp = presence_publisher.clone(); - let pk = presence_keys.clone(); + let signals = signal_publisher.clone(); presence_task = Some(tokio::spawn(async move { - if let Err(e) = publish_presence(&pp, &pk, "online").await { + if let Err(e) = signals + .presence_set(buzz_core::presence::PresenceStatus::Online) + .await + { tracing::warn!("presence heartbeat failed: {e}"); } })); @@ -3179,19 +3233,28 @@ async fn tokio_main() -> Result<()> { } } => { let _ = result_rx; - // Use try_publish (non-blocking) for typing indicators — - // they're ephemeral and must not block the main loop during - // relay reconnection (#35). + // Signals are ephemeral. Publish each on a detached task so + // a slow relay or broker never stalls the main event loop. for (&ch, thread_tags) in &typing_channels { - if let Ok(event) = relay.build_typing_event( - ch, - thread_tags.root_event_id.as_deref(), - thread_tags.parent_event_id.as_deref(), - ) { - if let Err(e) = relay.try_publish_event(event) { - tracing::debug!("typing indicator dropped for {ch}: {e}"); + let signals = signal_publisher.clone(); + let root = thread_tags.root_event_id.clone(); + let parent = thread_tags.parent_event_id.clone(); + tokio::spawn(async move { + match tokio::time::timeout( + Duration::from_secs(2), + signals.typing_set(ch, root, parent), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::debug!( + "typing indicator dropped for {ch}: {e}" + ), + Err(_) => tracing::debug!( + "typing indicator timed out for {ch}" + ), } - } + }); } None } @@ -3572,7 +3635,7 @@ async fn tokio_main() -> Result<()> { if config.presence_enabled { match tokio::time::timeout( Duration::from_secs(2), - publish_presence(&presence_publisher, &presence_keys, "offline"), + signal_publisher.presence_set(buzz_core::presence::PresenceStatus::Offline), ) .await { @@ -5139,6 +5202,18 @@ fn build_mcp_servers(config: &Config) -> Vec { env: { let mut env = match config.agent_mode { AgentMode::Local => vec![ + EnvVar { + name: "BUZZ_AGENT_MODE".into(), + value: "local".into(), + }, + EnvVar { + name: "BUZZ_BROKER_URL".into(), + value: String::new(), + }, + EnvVar { + name: "BUZZ_BROKER_CREDENTIAL".into(), + value: String::new(), + }, EnvVar { name: "BUZZ_RELAY_URL".into(), value: config.relay_url.clone(), diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 718d1f876d8..bc2560a0e49 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -42,6 +42,7 @@ use crate::queue::{ PromptProfile, PromptProfileLookup, ThreadTags, }; use crate::relay::{ChannelInfo, RestClient}; +use crate::runtime_transport::BrokerActions; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). @@ -751,6 +752,9 @@ pub struct PromptContext { /// Whether direct relay-only enrichments and housekeeping are available. /// False in broker mode, where the runtime has no relay route. pub relay_features_enabled: bool, + /// Broker-backed features that replace direct relay operations in keyless + /// mode. `None` for local mode. + pub broker_actions: Option, /// REST client for pre-prompt context fetches (thread/DM history). pub rest_client: RestClient, /// Shared channel metadata for startup-known and dynamically joined channels. @@ -1970,7 +1974,10 @@ pub async fn run_prompt_task( })); let liveness = run_turn_liveness( agent.acp.observer_handle(), + ctx.broker_actions.clone(), agent.acp.observer_agent_index(), + observer_channel_id, + turn_id.clone(), observer::context_for_turn( observer_channel_id, None, @@ -2047,19 +2054,38 @@ pub async fn run_prompt_task( // // Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch. if ctx.memory_enabled { - if let (PromptSource::Channel(cid), Some(owner_pk)) = - (&source, ctx.agent_owner_pubkey.as_ref()) - { + if let PromptSource::Channel(cid) = &source { let is_new_channel_session = !agent.state.sessions.contains_key(cid); if is_new_channel_session && !agent.state.core_sections.contains_key(cid) { // Bounded — we'd rather start the session with no core hint // than block session creation on a stalled relay. const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); - let fetch = crate::engram_fetch::build_core_section( - &ctx.rest_client, - &ctx.agent_keys, - owner_pk, - ); + let fetch = async { + if let Some(broker) = ctx.broker_actions.as_ref() { + match broker + .storage_get(buzz_core::engram::CORE_SLUG.to_string()) + .await + { + Ok(record) => crate::engram_fetch::render_core_section(record.value), + Err(error) => { + tracing::warn!( + target: "engram::core", + "broker core fetch failed: {error} — emitting no section" + ); + None + } + } + } else if let Some(owner_pk) = ctx.agent_owner_pubkey.as_ref() { + crate::engram_fetch::build_core_section( + &ctx.rest_client, + &ctx.agent_keys, + owner_pk, + ) + .await + } else { + None + } + }; let section = match tokio::time::timeout(CORE_FETCH_TIMEOUT, fetch).await { Ok(s) => s, Err(_) => { @@ -4430,20 +4456,25 @@ impl Drop for ReactionGuard { // by `run_prompt_task` after session resolution — so pings emitted for the // remainder of the turn carry the real session, matching every other // observer frame for this turn instead of a permanent `None`. +#[allow(clippy::too_many_arguments)] async fn run_turn_liveness( observer: Option, + broker: Option, agent_index: Option, + channel_id: Option, + turn_id: String, mut context: observer::ObserverContext, interval: Duration, state: Arc>, ) { - let Some(observer) = observer else { + if observer.is_none() && broker.is_none() { return std::future::pending::<()>().await; - }; + } if interval.is_zero() { return std::future::pending::<()>().await; } let mut ticker = tokio::time::interval(interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); // The first tick completes immediately; skip it so the first liveness ping // fires one interval after the turn starts, not at t=0 (turn_started already // marks t=0). @@ -4453,21 +4484,31 @@ async fn run_turn_liveness( // Nothing awaitable between the lock and the emit: `LivenessGuard::drop` // takes this same lock before its `abort()`, so the guard can only ever // observe this tick fully emitted or not yet started — never mid-emit. - let guard = match state.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - if guard.closed { - return; + { + let guard = match state.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + if guard.closed { + return; + } + context.session_id = guard.session_id.clone(); + if broker.is_none() { + if let Some(observer) = observer.as_ref() { + observer.emit( + "turn_liveness", + agent_index, + &context, + serde_json::json!({}), + ); + } + } + } + if let (Some(broker), Some(channel_id)) = (broker.as_ref(), channel_id) { + if let Err(error) = broker.liveness_ping(channel_id, turn_id.clone()).await { + tracing::debug!(%channel_id, "broker liveness ping dropped: {error}"); + } } - context.session_id = guard.session_id.clone(); - observer.emit( - "turn_liveness", - agent_index, - &context, - serde_json::json!({}), - ); - drop(guard); } } @@ -7432,7 +7473,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let _liveness_guard = LivenessGuard::new( tokio::spawn(run_turn_liveness( Some(observer.clone()), + None, Some(0), + None, + "turn".into(), context, Duration::from_secs(10), Arc::clone(&state), @@ -7482,7 +7526,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let guard = LivenessGuard::new( tokio::spawn(run_turn_liveness( Some(observer.clone()), + None, Some(0), + None, + "turn".into(), context, Duration::from_secs(10), Arc::clone(&state), @@ -7534,7 +7581,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let guard = LivenessGuard::new( tokio::spawn(run_turn_liveness( Some(observer.clone()), + None, Some(0), + None, + "turn".into(), context, Duration::from_secs(10), Arc::clone(&state), @@ -7576,7 +7626,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let context = observer::context_for(None, None, Some("t-1".into())); let liveness = run_turn_liveness( Some(observer.clone()), + None, Some(0), + None, + "turn".into(), context, Duration::ZERO, open_liveness_state(), @@ -7600,6 +7653,9 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let liveness = run_turn_liveness( None, None, + None, + None, + "turn".into(), context, Duration::from_secs(10), open_liveness_state(), @@ -7638,7 +7694,10 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" })); let liveness = run_turn_liveness( Some(observer.clone()), + None, Some(0), + None, + "turn".into(), context, Duration::from_secs(10), state, @@ -8262,6 +8321,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" base_prompt: None, cwd: ".".to_string(), relay_features_enabled: true, + broker_actions: None, rest_client: RestClient { http: reqwest::Client::new(), base_url: "http://127.0.0.1:0".to_string(), diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 1391be78aea..49e5e9491b4 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -118,7 +118,6 @@ use std::time::Instant; use buzz_core::kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_TYPING_INDICATOR, }; use futures_util::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag}; @@ -658,8 +657,18 @@ impl RelayEventPublisher { .map_err(|_| RelayError::ConnectionClosed) } - /// A publisher with no relay behind it. Used only by broker-mode plumbing - /// for code paths disabled by provisioning (presence, typing, observer). + /// Best-effort publish for ephemeral signals. Drops immediately when the + /// relay command queue is unavailable or full. + pub fn try_publish_event(&self, event: Event) -> Result<(), RelayError> { + self.cmd_tx + .try_send(RelayCommand::PublishEvent { + event: Box::new(event), + }) + .map_err(|_| RelayError::ConnectionClosed) + } + + /// A publisher with no relay behind it. Retained as a fail-closed sentinel + /// for any relay-only path accidentally reached in broker mode. pub(crate) fn disabled() -> Self { let (cmd_tx, cmd_rx) = mpsc::channel(1); drop(cmd_rx); @@ -924,48 +933,6 @@ impl HarnessRelay { .map_err(|_| RelayError::ConnectionClosed) } - /// Fire-and-forget publish — uses `try_send` so it never blocks the caller. - /// - /// Suitable for ephemeral commands like typing indicators where dropping - /// the event on a full command channel is acceptable. - pub fn try_publish_event(&self, event: Event) -> Result<(), RelayError> { - self.cmd_tx - .try_send(RelayCommand::PublishEvent { - event: Box::new(event), - }) - .map_err(|_| RelayError::ConnectionClosed) - } - - /// Build a typing indicator event (kind:20002) for a channel. - pub fn build_typing_event( - &self, - channel_id: Uuid, - root_event_id: Option<&str>, - parent_event_id: Option<&str>, - ) -> Result { - let h_tag = Tag::parse(["h", &channel_id.to_string()]) - .map_err(|e| RelayError::AuthFailed(e.to_string()))?; - let mut tags = vec![h_tag]; - if let Some(parent) = parent_event_id { - if let Some(root) = root_event_id { - if root != parent { - tags.push( - Tag::parse(["e", root, "", "root"]) - .map_err(|e| RelayError::AuthFailed(e.to_string()))?, - ); - } - } - tags.push( - Tag::parse(["e", parent, "", "reply"]) - .map_err(|e| RelayError::AuthFailed(e.to_string()))?, - ); - } - let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "") - .tags(tags) - .sign_with_keys(&self.keys)?; - Ok(event) - } - /// Pins the floor `since` for membership notification replay. /// /// Call once after `connect()` with the Unix timestamp captured just before @@ -5970,10 +5937,13 @@ mod tests { ); // Typing indicator while gated: still dropped, not parked. - let typing = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "") - .tags([Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()]) - .sign_with_keys(&keys) - .expect("sign typing indicator"); + let typing = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_TYPING_INDICATOR as u16), + "", + ) + .tags([Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign typing indicator"); let ok = execute_connected_command( &mut client, &mut state, diff --git a/crates/buzz-acp/src/runtime_transport.rs b/crates/buzz-acp/src/runtime_transport.rs index 95cc0fcc64c..d89585b1568 100644 --- a/crates/buzz-acp/src/runtime_transport.rs +++ b/crates/buzz-acp/src/runtime_transport.rs @@ -1,8 +1,8 @@ //! Runtime event transport selected by provisioning. //! //! Local mode delegates to the existing authenticated relay client. Broker -//! mode polls the frozen `channel.read` action and deliberately provides no -//! relay publisher: relay-only housekeeping is disabled by broker-mode config. +//! mode polls `channel.read` and routes keyless storage and live signals back +//! through the broker. Capabilities with no broker action remain relay-only. use std::collections::{HashMap, HashSet, VecDeque}; use std::time::Duration; @@ -10,7 +10,9 @@ use std::time::Duration; use buzz_broker_client::HttpBrokerClient; use buzz_sdk::broker::{ ActionArgs, ActionOutcome, BrokerClientExt, BrokerErrorCode, BrokerRequest, BrokerResult, - ChannelReadArgs, StorageAddressArgs, + ChannelReadArgs, EventPublished, LivenessPingArgs, ObserverEmitArgs, ObserverFrame, + ObserverReceipt, PresenceSetArgs, StorageAddressArgs, StorageGetArgs, StorageRecord, + TypingSetArgs, }; use nostr::{Event, Keys}; use tokio::time::MissedTickBehavior; @@ -26,6 +28,77 @@ pub enum RuntimeTransport { Broker(BrokerRuntime), } +#[derive(Clone)] +pub enum RuntimeSignalPublisher { + Local { + publisher: RelayEventPublisher, + keys: Keys, + }, + Broker(BrokerActions), +} + +impl RuntimeSignalPublisher { + pub async fn presence_set( + &self, + status: buzz_core::presence::PresenceStatus, + ) -> Result<(), RelayError> { + match self { + Self::Local { publisher, keys } => { + use buzz_core::kind::KIND_PRESENCE_UPDATE; + use nostr::{EventBuilder, Kind}; + + let event = EventBuilder::new( + Kind::Custom(KIND_PRESENCE_UPDATE as u16), + status.to_string(), + ) + .tags([]) + .sign_with_keys(keys) + .map_err(|error| RelayError::Http(format!("presence sign error: {error}")))?; + publisher.publish_event(event).await + } + Self::Broker(actions) => actions.presence_set(status).await.map(|_| ()), + } + } + + pub async fn typing_set( + &self, + channel_id: Uuid, + root_event_id: Option, + parent_event_id: Option, + ) -> Result<(), RelayError> { + match self { + Self::Local { publisher, keys } => { + use buzz_core::kind::KIND_TYPING_INDICATOR; + use nostr::{EventBuilder, Kind, Tag}; + + let h_tag = Tag::parse(["h", &channel_id.to_string()]) + .map_err(|error| RelayError::Http(error.to_string()))?; + let mut tags = vec![h_tag]; + if let Some(parent) = parent_event_id.as_deref() { + if let Some(root) = root_event_id.as_deref() { + if root != parent { + tags.push( + Tag::parse(["e", root, "", "root"]) + .map_err(|error| RelayError::Http(error.to_string()))?, + ); + } + } + tags.push( + Tag::parse(["e", parent, "", "reply"]) + .map_err(|error| RelayError::Http(error.to_string()))?, + ); + } + let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "") + .tags(tags) + .sign_with_keys(keys) + .map_err(|error| RelayError::Http(format!("typing sign error: {error}")))?; + publisher.try_publish_event(event) + } + Self::Broker(actions) => actions.typing_set(channel_id).await.map(|_| ()), + } + } +} + pub struct BrokerRuntime { client: HttpBrokerClient, channel_ids: Vec, @@ -39,6 +112,102 @@ pub struct BrokerRuntime { terminal_error: Option, } +/// Cloneable handle for broker-backed capabilities used outside the polling +/// loop (memory, presence, typing, observer telemetry, and turn liveness). +#[derive(Clone)] +pub struct BrokerActions { + client: HttpBrokerClient, +} + +impl BrokerActions { + async fn run(&self, args: ActionArgs) -> Result { + execute(&self.client, args) + .await + .map_err(BrokerActionError::relay_error) + } + + pub async fn storage_get(&self, slug: String) -> Result { + match self + .run(ActionArgs::StorageGet(StorageGetArgs { slug })) + .await? + { + ActionOutcome::StorageGet(record) => Ok(record), + _ => Err(wrong_outcome("storage.get")), + } + } + + pub async fn presence_set( + &self, + status: buzz_core::presence::PresenceStatus, + ) -> Result { + match self + .run(ActionArgs::PresenceSet(PresenceSetArgs { status })) + .await? + { + ActionOutcome::PresenceSet(published) => Ok(published), + _ => Err(wrong_outcome("presence.set")), + } + } + + pub async fn typing_set(&self, channel_id: Uuid) -> Result { + match self + .run(ActionArgs::TypingSet(TypingSetArgs { + channel_id: channel_id.to_string(), + })) + .await? + { + ActionOutcome::TypingSet(published) => Ok(published), + _ => Err(wrong_outcome("typing.set")), + } + } + + pub async fn observer_emit( + &self, + frames: Vec, + ) -> Result { + match self + .run(ActionArgs::ObserverEmit(ObserverEmitArgs { frames })) + .await? + { + ActionOutcome::ObserverEmit(receipt) => Ok(receipt), + _ => Err(wrong_outcome("observer.emit")), + } + } + + pub async fn liveness_ping( + &self, + channel_id: Uuid, + turn_id: String, + ) -> Result { + match self + .run(ActionArgs::LivenessPing(LivenessPingArgs { + channel_id: channel_id.to_string(), + turn_id, + })) + .await? + { + ActionOutcome::LivenessPing(published) => Ok(published), + _ => Err(wrong_outcome("liveness.ping")), + } + } +} + +fn wrong_outcome(action: &str) -> RelayError { + RelayError::Http(format!("broker returned the wrong outcome for {action}")) +} + +fn advance_cursor( + cursors: &mut HashMap, + channel_id: Uuid, + next_cursor: Option, +) { + if let Some(cursor) = next_cursor { + cursors.insert(channel_id, cursor); + } else { + cursors.remove(&channel_id); + } +} + struct BrokerActionError { detail: String, code: Option, @@ -64,7 +233,8 @@ impl BrokerRuntime { poll_interval: Duration, placeholder_keys: Keys, ) -> Result<(Self, String), RelayError> { - let client = HttpBrokerClient::new(base_url, credential); + let client = HttpBrokerClient::new(base_url, credential) + .map_err(|error| RelayError::Http(format!("broker config: {error}")))?; let outcome = execute( &client, ActionArgs::StorageAddress(StorageAddressArgs { @@ -152,9 +322,11 @@ impl BrokerRuntime { continue; } }; - if let Some(cursor) = page.next_cursor { - self.cursors.insert(channel_id, cursor); - } + // `nextCursor` is a pagination continuation, not a durable + // tail watermark. Return to the host's current default window + // after draining so newly-arrived events remain visible; the + // rotating dedup sets absorb the replay. + advance_cursor(&mut self.cursors, channel_id, page.next_cursor); for message in page.messages { if let Err(error) = message.verify() { tracing::warn!(%channel_id, "broker returned an unverifiable event: {error}"); @@ -353,6 +525,27 @@ impl RuntimeTransport { } } + pub fn broker_actions(&self) -> Option { + match self { + Self::Local(_) => None, + Self::Broker(runtime) => Some(BrokerActions { + client: runtime.client.clone(), + }), + } + } + + pub fn signal_publisher(&self, keys: Keys) -> RuntimeSignalPublisher { + match self { + Self::Local(relay) => RuntimeSignalPublisher::Local { + publisher: relay.event_publisher(), + keys, + }, + Self::Broker(runtime) => RuntimeSignalPublisher::Broker(BrokerActions { + client: runtime.client.clone(), + }), + } + } + pub fn rest_client(&self) -> RestClient { match self { Self::Local(relay) => relay.rest_client(), @@ -365,32 +558,120 @@ impl RuntimeTransport { } } - pub fn build_typing_event( - &self, - channel_id: Uuid, - root_event_id: Option<&str>, - parent_event_id: Option<&str>, - ) -> Result { - match self { - Self::Local(relay) => { - relay.build_typing_event(channel_id, root_event_id, parent_event_id) - } - Self::Broker(_) => Err(RelayError::Http( - "typing indicators are disabled in broker mode".into(), - )), + pub async fn shutdown(self) { + if let Self::Local(relay) = self { + relay.shutdown().await; } } +} - pub fn try_publish_event(&self, event: Event) -> Result<(), RelayError> { - match self { - Self::Local(relay) => relay.try_publish_event(event), - Self::Broker(_) => Err(RelayError::ConnectionClosed), - } +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{Body, Bytes}; + use axum::http::StatusCode; + use axum::response::Response; + use axum::routing::post; + use axum::Router; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + #[test] + fn terminal_page_returns_polling_to_the_default_window() { + let channel_id = Uuid::new_v4(); + let mut cursors = HashMap::new(); + advance_cursor(&mut cursors, channel_id, Some("continuation".into())); + assert_eq!( + cursors.get(&channel_id).map(String::as_str), + Some("continuation") + ); + + advance_cursor(&mut cursors, channel_id, None); + assert!(!cursors.contains_key(&channel_id)); } - pub async fn shutdown(self) { - if let Self::Local(relay) = self { - relay.shutdown().await; - } + #[tokio::test] + async fn broker_actions_route_storage_and_live_signals() { + let seen = Arc::new(Mutex::new(Vec::::new())); + let app = Router::new().route( + "/v1/action", + post({ + let seen = Arc::clone(&seen); + move |body: Bytes| { + let seen = Arc::clone(&seen); + async move { + let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let request_id = request["requestId"].as_str().unwrap(); + let action = request["action"].as_str().unwrap(); + seen.lock().unwrap().push(action.to_string()); + let outcome = match action { + "storage.get" => serde_json::json!({ "value": "core" }), + "observer.emit" => serde_json::json!({ "accepted": 1 }), + _ => serde_json::json!({ + "eventId": "cacf5f811cc8ef3f4af3f92cc222f92a86cdf6a26728a144c8e63b74ab6db359", + "kind": 20001, + "createdAt": 1_700_000_000u64, + }), + }; + let response = serde_json::json!({ + "type": "broker_result", + "protocolVersion": 1, + "requestId": request_id, + "status": "succeeded", + "action": action, + "outcome": outcome, + }); + Response::builder() + .status(StatusCode::OK) + .body(Body::from(response.to_string())) + .unwrap() + } + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let actions = BrokerActions { + client: HttpBrokerClient::new(format!("http://{address}"), "credential").unwrap(), + }; + let channel_id = Uuid::new_v4(); + + assert_eq!( + actions + .storage_get("core".into()) + .await + .unwrap() + .value + .as_deref(), + Some("core") + ); + actions + .presence_set(buzz_core::presence::PresenceStatus::Online) + .await + .unwrap(); + actions.typing_set(channel_id).await.unwrap(); + actions + .observer_emit(vec![ObserverFrame { + kind: "turn_started".into(), + payload: "{}".into(), + }]) + .await + .unwrap(); + actions + .liveness_ping(channel_id, "turn-1".into()) + .await + .unwrap(); + + assert_eq!( + *seen.lock().unwrap(), + [ + "storage.get", + "presence.set", + "typing.set", + "observer.emit", + "liveness.ping", + ] + ); } } diff --git a/crates/buzz-broker-client/src/lib.rs b/crates/buzz-broker-client/src/lib.rs index f0ca5c36a93..bf346d4a1c5 100644 --- a/crates/buzz-broker-client/src/lib.rs +++ b/crates/buzz-broker-client/src/lib.rs @@ -11,11 +11,26 @@ use buzz_sdk::broker::{ }; const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +/// Invalid broker provisioning rejected before a bearer credential can leave +/// the process. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrokerClientConfigError(String); + +impl std::fmt::Display for BrokerClientConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for BrokerClientConfigError {} /// A broker host endpoint plus the agent's bearer credential. /// /// Holds no key and knows nothing of the relay: its whole authority is the /// opaque credential it replays on every request. +#[derive(Clone)] pub struct HttpBrokerClient { base_url: String, credential: String, @@ -25,8 +40,17 @@ pub struct HttpBrokerClient { impl HttpBrokerClient { /// A client posting to `base_url` with `credential` as its bearer token. - pub fn new(base_url: impl Into, credential: impl Into) -> Self { - Self::with_client(base_url, credential, reqwest::Client::new()) + pub fn new( + base_url: impl Into, + credential: impl Into, + ) -> Result { + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| { + BrokerClientConfigError(format!("failed to build broker HTTP client: {error}")) + })?; + Self::with_client(base_url, credential, http) } /// As [`Self::new`], reusing an existing reqwest client and its pool. @@ -34,7 +58,7 @@ impl HttpBrokerClient { base_url: impl Into, credential: impl Into, http: reqwest::Client, - ) -> Self { + ) -> Result { Self::with_client_timeout(base_url, credential, http, DEFAULT_REQUEST_TIMEOUT) } @@ -43,13 +67,47 @@ impl HttpBrokerClient { credential: impl Into, http: reqwest::Client, request_timeout: std::time::Duration, - ) -> Self { - Self { - base_url: base_url.into(), - credential: credential.into(), + ) -> Result { + let base_url = base_url.into(); + let parsed = reqwest::Url::parse(&base_url) + .map_err(|error| BrokerClientConfigError(format!("invalid broker URL: {error}")))?; + let loopback = parsed.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) + }); + if parsed.scheme() != "https" && !(parsed.scheme() == "http" && loopback) { + return Err(BrokerClientConfigError( + "broker URL must use HTTPS (plain HTTP is allowed only for loopback)".into(), + )); + } + if parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(BrokerClientConfigError( + "broker URL must be an origin/path without credentials, query, or fragment".into(), + )); + } + + let credential = credential.into(); + if credential.trim().is_empty() { + return Err(BrokerClientConfigError( + "broker credential must not be empty".into(), + )); + } + reqwest::header::HeaderValue::from_str(&format!("Bearer {credential}")) + .map_err(|_| BrokerClientConfigError("broker credential is not header-safe".into()))?; + + Ok(Self { + base_url, + credential, http, request_timeout, - } + }) } } @@ -61,7 +119,7 @@ impl BrokerClient for HttpBrokerClient { self.base_url.trim_end_matches('/') ); let (status, body) = tokio::time::timeout(self.request_timeout, async { - let response = self + let mut response = self .http .post(url) .header(reqwest::header::CONTENT_TYPE, "application/json") @@ -75,10 +133,29 @@ impl BrokerClient for HttpBrokerClient { .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; let status = response.status().as_u16(); - let body = response - .bytes() + if response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + { + return Err(BrokerTransportError::NoEnvelope { + status, + detail: format!("response exceeds {MAX_RESPONSE_BYTES} bytes"), + }); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() .await - .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))?; + .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))? + { + if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err(BrokerTransportError::NoEnvelope { + status, + detail: format!("response exceeds {MAX_RESPONSE_BYTES} bytes"), + }); + } + body.extend_from_slice(&chunk); + } Ok::<_, BrokerTransportError>((status, body)) }) .await @@ -175,7 +252,7 @@ mod tests { ); let (base, seen_auth) = spawn(StatusCode::OK, body).await; - let client = HttpBrokerClient::new(base, CRED); + let client = HttpBrokerClient::new(base, CRED).unwrap(); let validated = client.execute(&req).await.expect("a verdict"); assert!(matches!(validated.result(), BrokerResult::Succeeded { .. })); @@ -194,7 +271,7 @@ mod tests { ); let (base, _) = spawn(StatusCode::OK, body).await; - let client = HttpBrokerClient::new(base, CRED); + let client = HttpBrokerClient::new(base, CRED).unwrap(); let validated = client.execute(&req).await.expect("a verdict"); assert!(matches!(validated.result(), BrokerResult::Failed { .. })); @@ -205,7 +282,7 @@ mod tests { let req = post_request(); let (base, _) = spawn(StatusCode::BAD_GATEWAY, "upstream boom".to_string()).await; - let client = HttpBrokerClient::new(base, CRED); + let client = HttpBrokerClient::new(base, CRED).unwrap(); let err = client.execute(&req).await.expect_err("no envelope"); assert!(matches!( @@ -217,7 +294,7 @@ mod tests { #[tokio::test] async fn unreachable_host_is_a_transport_error() { let req = post_request(); - let client = HttpBrokerClient::new("http://127.0.0.1:1", CRED); + let client = HttpBrokerClient::new("http://127.0.0.1:1", CRED).unwrap(); let err = client.execute(&req).await.expect_err("unreachable"); assert!(matches!(err, BrokerTransportError::Unreachable(_))); @@ -238,11 +315,33 @@ mod tests { CRED, reqwest::Client::new(), std::time::Duration::from_millis(20), - ); + ) + .unwrap(); let err = client.execute(&post_request()).await.expect_err("timeout"); assert!( matches!(err, BrokerTransportError::Unreachable(message) if message.contains("timed out")) ); } + + #[test] + fn remote_plaintext_and_empty_credentials_are_rejected() { + assert!(HttpBrokerClient::new("http://broker.example", CRED).is_err()); + assert!(HttpBrokerClient::new("https://broker.example", " ").is_err()); + assert!(HttpBrokerClient::new("http://localhost:8787", CRED).is_ok()); + } + + #[tokio::test] + async fn oversized_response_is_rejected_before_parsing() { + let (base, _) = spawn(StatusCode::OK, "x".repeat(MAX_RESPONSE_BYTES + 1)).await; + let error = HttpBrokerClient::new(base, CRED) + .unwrap() + .execute(&post_request()) + .await + .expect_err("oversized response"); + assert!(matches!( + error, + BrokerTransportError::NoEnvelope { detail, .. } if detail.contains("exceeds") + )); + } } diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index 176c1f99620..0eca14ad094 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -12,14 +12,15 @@ The backend is selected by `--agent-mode` (env `BUZZ_AGENT_MODE`), default - `broker` — keyless; route every operation through a broker host. Broker mode currently covers: `messages get` (with `--mentions-only`, the wake -path), `messages send` / reply, `reactions add`, `users set-profile`, and -`mem address ` as an addressing-only bridge for encrypted memory. Every -other command still needs the local backend. +path), `messages send` / reply, `reactions add`, `users set-profile/set-presence`, and +`mem address/get/set/hash`. The ACP runtime also routes core-memory reads, +presence, typing, observer telemetry, and turn liveness through the broker. +Commands without a matching broker capability still need the local backend. The same provisioning variables now select the prototype keyless `buzz-acp` runtime. It derives its public identity through `storage.address`, polls -configured channels through `channel.read`, and passes broker provisioning to -the CLI used by the spawned agent. See +configured channels through `channel.read`, uses broker-backed memory and live +signals, and passes broker provisioning to the CLI used by the spawned agent. See [`../buzz-acp/README.md`](../buzz-acp/README.md#keyless-broker-mode-prototype) for the runtime command and its deliberate housekeeping limits. @@ -57,7 +58,10 @@ buzz messages send --channel "$CH" --reply-to --content "on it" buzz messages get --channel "$CH" --mentions-only --limit 10 buzz reactions add --channel "$CH" --event --emoji "👍" buzz users set-profile --name "Ada" --about "a keyless agent" +buzz users set-presence online buzz mem address core +buzz mem get core +buzz mem set core "I am Ada." ``` Terminal 1 logs each action, the bearer credential, and the args it received. @@ -85,17 +89,14 @@ is a minimal reference for the wire shape. - Broker HTTP actions have a 30-second end-to-end transport timeout. A runtime credential rejected as `unauthenticated` is terminal instead of being polled forever. -- Runtime cursors and event deduplication are currently in memory only. The - first cursorless read after restart follows the host's default-window - semantics, which must be pinned at the real-host integration checkpoint to - prevent missed or replayed mentions. +- Runtime cursors and event deduplication are currently in memory only. Once a + pagination chain ends, polling returns to the host's default window and + bounded event-ID deduplication absorbs replayed messages. - This CLI slice exposes only the first `messages get --limit` window; broker cursor pagination and thread reads are not yet command-line options. The ACP runtime therefore prompts from the triggering event without relay-fetched thread history. -- `buzz mem address ` prints the broker's `{authorPubkey, kind, dTag}` - outcome as JSON. This temporary bridge proves secret-dependent address - derivation without giving the client a key or relay route. It does not yet - make `mem get/set/patch/rm` keyless; fetching, decrypting, encrypting, and - publishing memory records belong to the later end-to-end runtime storage - work. +- `buzz mem address ` prints `{authorPubkey, kind, dTag}`. `mem get/set` + use broker-side decryption/encryption and `mem hash` composes a read locally. + Listing, patching, and deletion remain unavailable because listing and + tombstone semantics are not represented by the current contract. diff --git a/crates/buzz-cli/examples/mock_broker.rs b/crates/buzz-cli/examples/mock_broker.rs index d0a79a2959e..7e19cc0d71e 100644 --- a/crates/buzz-cli/examples/mock_broker.rs +++ b/crates/buzz-cli/examples/mock_broker.rs @@ -73,6 +73,42 @@ async fn action(headers: axum::http::HeaderMap, body: Bytes) -> Response { "dTag": FAKE_EVENT_ID, }), ), + "storage.get" => succeeded( + request_id, + action, + serde_json::json!({ "value": "I am a keyless agent using broker-backed memory." }), + ), + "storage.put" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 30174, "createdAt": 1_700_000_000u64 }), + ), + "presence.set" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 20001, "createdAt": 1_700_000_000u64 }), + ), + "typing.set" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 20002, "createdAt": 1_700_000_000u64 }), + ), + "observer.emit" => succeeded( + request_id, + action, + serde_json::json!({ + "accepted": request + .get("args") + .and_then(|args| args.get("frames")) + .and_then(|frames| frames.as_array()) + .map_or(0, Vec::len), + }), + ), + "liveness.ping" => succeeded( + request_id, + action, + serde_json::json!({ "eventId": FAKE_EVENT_ID, "kind": 24200, "createdAt": 1_700_000_000u64 }), + ), "channel.read" => succeeded(request_id, action, serde_json::json!({ "messages": [] })), other => serde_json::json!({ "type": "broker_result", diff --git a/crates/buzz-cli/src/backend.rs b/crates/buzz-cli/src/backend.rs index a975848c06c..f395a71fc5f 100644 --- a/crates/buzz-cli/src/backend.rs +++ b/crates/buzz-cli/src/backend.rs @@ -14,7 +14,8 @@ use uuid::Uuid; use buzz_sdk::broker::{ ActionArgs, ActionOutcome, BrokerClientExt, BrokerError, BrokerRequest, BrokerResult, ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, - ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs, + PresenceSetArgs, ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, + StorageAddressArgs, StorageGetArgs, StoragePutArgs, StorageRecord, }; use buzz_sdk::ThreadRef; @@ -34,7 +35,10 @@ pub trait AgentBackend { async fn message_reply(&self, args: MessageReplyArgs) -> Result; async fn reaction_add(&self, args: ReactionAddArgs) -> Result; async fn profile_set(&self, args: ProfileSetArgs) -> Result; + async fn presence_set(&self, args: PresenceSetArgs) -> Result; async fn storage_address(&self, args: StorageAddressArgs) -> Result; + async fn storage_get(&self, args: StorageGetArgs) -> Result; + async fn storage_put(&self, args: StoragePutArgs) -> Result; } /// Keyless backend: no key, no relay route. Every operation is a broker request. @@ -102,12 +106,33 @@ impl AgentBackend for BrokerBackend { } } + async fn presence_set(&self, args: PresenceSetArgs) -> Result { + match self.run(ActionArgs::PresenceSet(args)).await? { + ActionOutcome::PresenceSet(published) => Ok(published), + _ => Err(unexpected_outcome("presence.set")), + } + } + async fn storage_address(&self, args: StorageAddressArgs) -> Result { match self.run(ActionArgs::StorageAddress(args)).await? { ActionOutcome::StorageAddress(address) => Ok(address), _ => Err(unexpected_outcome("storage.address")), } } + + async fn storage_get(&self, args: StorageGetArgs) -> Result { + match self.run(ActionArgs::StorageGet(args)).await? { + ActionOutcome::StorageGet(record) => Ok(record), + _ => Err(unexpected_outcome("storage.get")), + } + } + + async fn storage_put(&self, args: StoragePutArgs) -> Result { + match self.run(ActionArgs::StoragePut(args)).await? { + ActionOutcome::StoragePut(published) => Ok(published), + _ => Err(unexpected_outcome("storage.put")), + } + } } /// Local backend: holds the key and talks to the relay directly. Preserves @@ -235,6 +260,12 @@ impl AgentBackend for LocalBackend { self.publish(event).await } + async fn presence_set(&self, _args: PresenceSetArgs) -> Result { + Err(CliError::Other( + "local presence updates use the existing signed command path".into(), + )) + } + async fn storage_address(&self, args: StorageAddressArgs) -> Result { let owner = crate::commands::mem::resolve_owner(&self.client, None)?; let conversation_key = @@ -246,6 +277,18 @@ impl AgentBackend for LocalBackend { d_tag: buzz_core::engram::d_tag(&conversation_key, &args.slug), }) } + + async fn storage_get(&self, _args: StorageGetArgs) -> Result { + Err(CliError::Other( + "local storage reads use the existing encrypted-memory command path".into(), + )) + } + + async fn storage_put(&self, _args: StoragePutArgs) -> Result { + Err(CliError::Other( + "local storage writes use the existing encrypted-memory command path".into(), + )) + } } /// A runtime-selected backend. Implements [`AgentBackend`] by dispatch, so @@ -257,11 +300,13 @@ pub enum Backend { impl Backend { /// Keyless: talk to a broker `base_url` with `credential`. - #[must_use] - pub fn broker(base_url: impl Into, credential: impl Into) -> Self { - Self::Broker(BrokerBackend::new(HttpBrokerClient::new( - base_url, credential, - ))) + pub fn broker( + base_url: impl Into, + credential: impl Into, + ) -> Result { + let client = HttpBrokerClient::new(base_url, credential) + .map_err(|error| CliError::Usage(format!("invalid broker configuration: {error}")))?; + Ok(Self::Broker(BrokerBackend::new(client))) } /// Local: hold the key and talk to the relay. @@ -307,12 +352,33 @@ impl AgentBackend for Backend { } } + async fn presence_set(&self, args: PresenceSetArgs) -> Result { + match self { + Self::Local(b) => b.presence_set(args).await, + Self::Broker(b) => b.presence_set(args).await, + } + } + async fn storage_address(&self, args: StorageAddressArgs) -> Result { match self { Self::Local(b) => b.storage_address(args).await, Self::Broker(b) => b.storage_address(args).await, } } + + async fn storage_get(&self, args: StorageGetArgs) -> Result { + match self { + Self::Local(b) => b.storage_get(args).await, + Self::Broker(b) => b.storage_get(args).await, + } + } + + async fn storage_put(&self, args: StoragePutArgs) -> Result { + match self { + Self::Local(b) => b.storage_put(args).await, + Self::Broker(b) => b.storage_put(args).await, + } + } } fn broker_verdict(status: &str, error: &BrokerError) -> CliError { @@ -377,7 +443,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr: SocketAddr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - BrokerBackend::new(HttpBrokerClient::new(format!("http://{addr}"), "cred")) + BrokerBackend::new(HttpBrokerClient::new(format!("http://{addr}"), "cred").unwrap()) } fn succeeded(rid: &str, action: &str, outcome: serde_json::Value) -> (StatusCode, String) { @@ -515,6 +581,41 @@ mod tests { assert_eq!(address.d_tag, EVENT_ID); } + #[tokio::test] + async fn broker_storage_get_returns_plaintext_or_absence() { + let backend = spawn_host(|rid, action| { + succeeded(rid, action, serde_json::json!({ "value": "remember me" })) + }) + .await; + let record = backend + .storage_get(StorageGetArgs { + slug: "core".into(), + }) + .await + .expect("record"); + assert_eq!(record.value.as_deref(), Some("remember me")); + } + + #[tokio::test] + async fn broker_storage_put_returns_the_published_event() { + let backend = spawn_host(|rid, action| { + succeeded( + rid, + action, + serde_json::json!({ "eventId": EVENT_ID, "kind": 30174, "createdAt": 1_700_000_000u64 }), + ) + }) + .await; + let published = backend + .storage_put(StoragePutArgs { + slug: "core".into(), + value: "remember me".into(), + }) + .await + .expect("published"); + assert_eq!(published.kind, 30174); + } + #[tokio::test] async fn broker_read_returns_a_page_of_signed_events() { // A real signed event, so the strict event reader accepts it. diff --git a/crates/buzz-cli/src/commands/mem.rs b/crates/buzz-cli/src/commands/mem.rs index 2e2124d4fa2..d0b88526306 100644 --- a/crates/buzz-cli/src/commands/mem.rs +++ b/crates/buzz-cli/src/commands/mem.rs @@ -24,7 +24,7 @@ use buzz_core::engram::{ self, conversation_key, d_tag, normalize_slug, select_head, validate_and_decrypt, Body, Listing, }; use buzz_core::kind::KIND_AGENT_ENGRAM; -use buzz_sdk::broker::StorageAddressArgs; +use buzz_sdk::broker::{StorageAddressArgs, StorageGetArgs, StoragePutArgs}; use nostr::PublicKey; use crate::backend::{AgentBackend, Backend}; @@ -804,11 +804,16 @@ pub async fn dispatch(cmd: crate::MemCmd, client: &BuzzClient) -> Result<(), Cli } } -/// Keyless (broker) dispatch for encrypted-memory addressing only. -/// -/// The returned coordinates are intentionally surfaced as-is. They identify -/// the encrypted record but do not, by themselves, provide read, decrypt, -/// encrypt, or publish semantics; those remain a later runtime slice. +async fn broker_value(backend: &Backend, slug: String) -> Result { + backend + .storage_get(StorageGetArgs { slug: slug.clone() }) + .await? + .value + .ok_or_else(|| CliError::NotFound(format!("not found: {slug}"))) +} + +/// Keyless (broker) dispatch. The host owns encryption, signing, and relay +/// publication; the CLI sees only slug-addressed plaintext records. pub async fn dispatch_broker(cmd: crate::MemCmd, backend: &Backend) -> Result<(), CliError> { use crate::MemCmd; match cmd { @@ -823,9 +828,82 @@ pub async fn dispatch_broker(cmd: crate::MemCmd, backend: &Backend) -> Result<() ); Ok(()) } - _ => Err(CliError::Usage( - "keyless (broker) mode supports only 'mem address' in this group; encrypted-memory \ - reads and writes need the later runtime storage slice" + MemCmd::Get { slug, owner, agent } => { + if owner.is_some() || agent.is_some() { + return Err(CliError::Usage( + "--owner/--agent are unavailable in broker mode; identity is credential-bound" + .into(), + )); + } + let slug = normalize_slug(&slug) + .map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + use std::io::Write; + std::io::stdout() + .write_all(broker_value(backend, slug).await?.as_bytes()) + .map_err(|e| CliError::Other(e.to_string())) + } + MemCmd::Hash { slug, owner, agent } => { + if owner.is_some() || agent.is_some() { + return Err(CliError::Usage( + "--owner/--agent are unavailable in broker mode; identity is credential-bound" + .into(), + )); + } + let slug = normalize_slug(&slug) + .map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + println!("{}", sha256_hex(&broker_value(backend, slug).await?)); + Ok(()) + } + MemCmd::Set { + slug, + value, + owner, + allow_empty, + } => { + if owner.is_some() { + return Err(CliError::Usage( + "--owner is unavailable in broker mode; identity is credential-bound".into(), + )); + } + let slug = normalize_slug(&slug) + .map_err(|e| CliError::Usage(format!("invalid slug: {e}")))?; + let value = if value == "-" { + let mut input = String::new(); + std::io::stdin() + .take((engram::NIP44_PLAINTEXT_MAX + 1) as u64) + .read_to_string(&mut input) + .map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?; + input + } else { + value + }; + if value.is_empty() { + let detail = if allow_empty { + "the broker storage contract does not support empty records" + } else { + "refusing to write an empty broker record" + }; + return Err(CliError::Usage(detail.into())); + } + let published = backend + .storage_put(StoragePutArgs { slug: slug.clone(), value }) + .await?; + eprintln!( + "wrote {slug} (event {}, created_at {})", + published.event_id, published.created_at + ); + Ok(()) + } + MemCmd::Ls { .. } => Err(CliError::Usage( + "'mem ls' is unavailable in broker mode because the contract has no storage listing action" + .into(), + )), + MemCmd::Patch { .. } => Err(CliError::Usage( + "'mem patch' is not yet wired to broker read-modify-write; use mem get/hash/set" + .into(), + )), + MemCmd::Rm { .. } => Err(CliError::Usage( + "'mem rm' is unavailable because the broker contract has no delete/tombstone action" .into(), )), } diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 59ec6101c0f..3484612197a 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -424,7 +424,7 @@ pub async fn cmd_set_profile( Ok(()) } -/// Keyless (broker) dispatch for the users group: `set-profile` only. +/// Keyless (broker) dispatch for profile and presence updates. pub async fn dispatch_broker(cmd: crate::UsersCmd, backend: &Backend) -> Result<(), CliError> { use crate::UsersCmd; match cmd { @@ -460,8 +460,25 @@ pub async fn dispatch_broker(cmd: crate::UsersCmd, backend: &Backend) -> Result< ); Ok(()) } + UsersCmd::SetPresence { status } => { + let status = match status { + crate::PresenceStatus::Online => buzz_core::presence::PresenceStatus::Online, + crate::PresenceStatus::Away => buzz_core::presence::PresenceStatus::Away, + crate::PresenceStatus::Offline => buzz_core::presence::PresenceStatus::Offline, + }; + let published = backend + .presence_set(buzz_sdk::broker::PresenceSetArgs { status }) + .await?; + println!( + "{}", + serde_json::to_string(&published) + .map_err(|e| CliError::Other(format!("serialize outcome: {e}")))? + ); + Ok(()) + } _ => Err(CliError::Usage( - "keyless (broker) mode supports only 'users set-profile' in this group".into(), + "keyless (broker) mode supports only 'users set-profile' and 'users set-presence' in this group" + .into(), )), } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 05ca8ee4f2c..7b7907558a5 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2171,7 +2171,7 @@ async fn run_broker(cli: Cli) -> Result<(), CliError> { .into(), ) })?; - let backend = backend::Backend::broker(base_url, credential); + let backend = backend::Backend::broker(base_url, credential)?; match cli.command { Cmd::Messages(sub) => commands::messages::dispatch_broker(sub, &backend).await, @@ -2179,9 +2179,8 @@ async fn run_broker(cli: Cli) -> Result<(), CliError> { Cmd::Users(sub) => commands::users::dispatch_broker(sub, &backend).await, Cmd::Mem(sub) => commands::mem::dispatch_broker(sub, &backend).await, _ => Err(CliError::Usage( - "keyless (broker) mode currently supports the wake→reply slice plus reactions and \ - profile, plus encrypted-memory addressing: 'messages get/send', 'reactions add', \ - 'users set-profile', and 'mem address'. Other commands need the local backend — \ + "keyless (broker) mode currently supports messages, reactions, profile/presence updates, and \ + broker-backed memory get/set/address/hash. Other commands need the local backend — \ unset --agent-mode or set it to 'local'" .into(), )), From 59b47285964ce71eb4eae49357a2b05dc452b05c Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Fri, 28 Aug 2026 16:31:59 +1000 Subject: [PATCH 10/13] refactor(acp): model private key as optional Signed-off-by: Joel Robotham --- crates/buzz-acp/src/config.rs | 42 ++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 62ac20565e5..74c1d9d1326 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -278,13 +278,8 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_AGENT_MODE", default_value = "local")] pub agent_mode: AgentMode, - #[arg( - long, - env = "BUZZ_PRIVATE_KEY", - hide_env_values = true, - default_value = "" - )] - pub private_key: String, + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] + pub private_key: Option, #[arg(long, env = "BUZZ_BROKER_URL")] pub broker_url: Option, @@ -920,10 +915,13 @@ impl Config { /// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full /// validation path without going through process args. pub fn from_args(mut args: CliArgs) -> Result { + // Clap preserves an explicitly empty argument or environment variable as + // `Some("")`; normalize it to absence before applying mode-specific rules. + let mut private_key = args.private_key.take().filter(|key| !key.is_empty()); let broker = match args.agent_mode { AgentMode::Local => None, AgentMode::Broker => { - if !args.private_key.is_empty() { + if private_key.is_some() { return Err(ConfigError::ConfigFile( "broker mode is keyless — unset BUZZ_PRIVATE_KEY / --private-key".into(), )); @@ -984,7 +982,14 @@ impl Config { } }; let keys = match args.agent_mode { - AgentMode::Local => Keys::parse(&args.private_key)?, + AgentMode::Local => { + let private_key = private_key.as_deref().ok_or_else(|| { + ConfigError::ConfigFile( + "BUZZ_PRIVATE_KEY / --private-key is required in local mode".into(), + ) + })?; + Keys::parse(private_key)? + } // Never used as the agent identity. A placeholder keeps the local // runtime's concrete types intact while the broker path diverges // before relay setup; it is never exported to subprocesses. @@ -993,9 +998,10 @@ impl Config { // Best-effort zeroize: overwrite the raw private key string to reduce // exposure via core dumps or heap inspection (#41). Without the `zeroize` // crate we can only clear the String — the allocator may retain copies. - args.private_key - .replace_range(.., &"0".repeat(args.private_key.len())); - args.private_key.clear(); + if let Some(private_key) = private_key.as_mut() { + private_key.replace_range(.., &"0".repeat(private_key.len())); + private_key.clear(); + } let system_prompt = if let Some(text) = args.system_prompt { Some(text) @@ -1753,7 +1759,7 @@ mod tests { fn broker_mode_rejects_an_agent_private_key() { let key = "1".repeat(64); let mut args = broker_args(&[]); - args.private_key = key; + args.private_key = Some(key); let result = Config::from_args(args); assert!(result @@ -1762,6 +1768,16 @@ mod tests { .contains("keyless")); } + #[test] + fn local_mode_requires_a_private_key() { + let args = CliArgs::parse_from(["buzz-acp", "--agent-mode", "local"]); + + assert!(Config::from_args(args) + .expect_err("local mode must require a private key") + .to_string() + .contains("required in local mode")); + } + #[test] fn broker_mode_requires_explicit_channels() { const OWNER: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971"; From 55724007ecc604a886f84023e1923f094c980089 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Fri, 28 Aug 2026 16:36:37 +1000 Subject: [PATCH 11/13] docs(keyless): remove prototype label Signed-off-by: Joel Robotham --- crates/buzz-acp/README.md | 2 +- crates/buzz-cli/KEYLESS.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index d42247b05d8..39da438ab09 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -68,7 +68,7 @@ buzz-acp That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Buzz CLI that the harness configures automatically. -## Keyless broker mode (prototype) +## Keyless broker mode Broker mode starts the runtime without an agent private key or direct relay connection. The harness derives the agent public key through `storage.address`, diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index 0eca14ad094..db1269f12d7 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -17,11 +17,11 @@ path), `messages send` / reply, `reactions add`, `users set-profile/set-presence presence, typing, observer telemetry, and turn liveness through the broker. Commands without a matching broker capability still need the local backend. -The same provisioning variables now select the prototype keyless `buzz-acp` +The same provisioning variables now select the keyless `buzz-acp` runtime. It derives its public identity through `storage.address`, polls configured channels through `channel.read`, uses broker-backed memory and live signals, and passes broker provisioning to the CLI used by the spawned agent. See -[`../buzz-acp/README.md`](../buzz-acp/README.md#keyless-broker-mode-prototype) +[`../buzz-acp/README.md`](../buzz-acp/README.md#keyless-broker-mode) for the runtime command and its deliberate housekeeping limits. ## Build From cf3e96eccb1af55278662b228454ad36656913b5 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Fri, 28 Aug 2026 17:03:52 +1000 Subject: [PATCH 12/13] fix(keyless): harden broker runtime boundaries Signed-off-by: Joel Robotham --- Cargo.lock | 1 + crates/buzz-acp/README.md | 4 ++ crates/buzz-acp/src/acp.rs | 99 +++++++++++++++++++++++++++- crates/buzz-acp/src/config.rs | 98 +++++++++++++++++++++++---- crates/buzz-acp/src/lib.rs | 1 + crates/buzz-broker-client/Cargo.toml | 1 + crates/buzz-broker-client/src/lib.rs | 73 ++++++++++++++++++-- crates/buzz-cli/KEYLESS.md | 4 +- 8 files changed, 258 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dbdca88cc19..a972b1b95d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -981,6 +981,7 @@ version = "0.1.0" dependencies = [ "axum", "buzz-sdk", + "nostr 0.44.7", "reqwest 0.13.4", "serde_json", "tokio", diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 39da438ab09..d4f4113afba 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -80,6 +80,7 @@ the same host. export BUZZ_AGENT_MODE=broker export BUZZ_BROKER_URL=http://127.0.0.1:8787 export BUZZ_BROKER_CREDENTIAL=dev-token +export BUZZ_BROKER_RELAY_URL=wss://relay.example export BUZZ_ACP_CHANNELS=5df7dfa8-e919-43df-8efd-f1dcb8af7071 export BUZZ_ACP_AGENT_OWNER=a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971 unset BUZZ_PRIVATE_KEY BUZZ_RELAY_URL BUZZ_AUTH_TAG @@ -90,6 +91,8 @@ buzz-acp Broker mode requires explicit channel UUIDs and `respond-to=owner-only` because the contract does not expose channel discovery or metadata. Core-memory reads, presence, typing, observer telemetry, and turn liveness use broker actions. +`BUZZ_BROKER_RELAY_URL` identifies the relay behind the broker for desktop +observer/runtime pairing only; the harness and its children never connect to it. Relay-only conversation enrichment, setup nudges, sibling-profile lookup, and reaction-based turn status remain disabled. The `buzz` CLI operations available to the spawned agent are documented in @@ -149,6 +152,7 @@ All configuration is via environment variables (or CLI flags — every env var h | `BUZZ_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. | | `BUZZ_BROKER_URL` | broker only | — | Broker base URL; actions are posted to `/v1/action`. | | `BUZZ_BROKER_CREDENTIAL` | broker only | — | Bearer credential issued by the broker host. | +| `BUZZ_BROKER_RELAY_URL` | broker only | — | Relay identity behind the broker, used only for observer/runtime pairing; never connected to directly. | | `BUZZ_BROKER_POLL_INTERVAL_MS` | no | `1000` | Broker `channel.read` polling interval; minimum `100`. | | `BUZZ_ACP_CHANNELS` | broker only | — | Comma-separated channel UUIDs to poll. | | `BUZZ_ACP_AGENT_OWNER` | broker only | — | Owner pubkey accepted by the broker-mode author gate. | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index e6a17fe5372..2f187915211 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -241,6 +241,39 @@ fn deep_merge( } } +const OBSERVER_REDACTED_ENV_VALUE: &str = ""; + +/// Return the ACP wire payload that observer telemetry may retain. +/// +/// The actual request sent to the child keeps its original MCP environment; +/// only the observer copy is redacted. Environment names remain visible for +/// diagnostics, but values are treated as potentially sensitive and never +/// enter telemetry. +fn redact_acp_write_for_observer(value: &serde_json::Value) -> serde_json::Value { + let mut redacted = value.clone(); + let Some(servers) = redacted + .pointer_mut("/params/mcpServers") + .and_then(serde_json::Value::as_array_mut) + else { + return redacted; + }; + + for server in servers { + let Some(env) = server + .get_mut("env") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + for entry in env { + if let Some(value) = entry.get_mut("value") { + *value = serde_json::Value::String(OBSERVER_REDACTED_ENV_VALUE.into()); + } + } + } + redacted +} + /// Build the merged `CODEX_CONFIG` environment-variable value for a Codex agent spawn. /// /// Returns `Some(json_string)` when `has_generated_codex_config` is true (Buzz injected a @@ -516,6 +549,7 @@ impl AcpClient { "BUZZ_AGENT_MODE" | "BUZZ_BROKER_URL" | "BUZZ_BROKER_CREDENTIAL" + | "BUZZ_BROKER_RELAY_URL" | "BUZZ_RELAY_URL" | "BUZZ_PRIVATE_KEY" | "BUZZ_AUTH_TAG" @@ -816,7 +850,8 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); + let diagnostic_msg = redact_acp_write_for_observer(&msg); + tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&diagnostic_msg).unwrap_or_default()); if let Err(e) = self.write_ndjson(&msg).await { self.last_prompt_id = None; self.current_hard_deadline = None; @@ -1091,7 +1126,7 @@ impl AcpClient { .await .map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))? .map_err(AcpError::Io)?; - self.observe("acp_write", value.clone()); + self.observe("acp_write", redact_acp_write_for_observer(value)); Ok(()) } @@ -2569,6 +2604,53 @@ mod tests { ); } + #[test] + fn acp_write_observer_payload_redacts_mcp_credentials() { + let wire = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "buzz-mcp", + "command": "buzz-mcp", + "args": [], + "env": [ + {"name": "BUZZ_AGENT_MODE", "value": "broker"}, + {"name": "BUZZ_BROKER_URL", "value": "https://broker.example"}, + {"name": "BUZZ_BROKER_CREDENTIAL", "value": "broker-secret"}, + {"name": "BUZZ_PRIVATE_KEY", "value": "nsec-secret"}, + {"name": "BUZZ_AUTH_TAG", "value": "auth-secret"}, + {"name": "OPENAI_API_KEY", "value": "provider-secret"} + ] + }] + } + }); + + let observed = redact_acp_write_for_observer(&wire); + let serialized = serde_json::to_string(&observed).unwrap(); + for secret in [ + "broker-secret", + "nsec-secret", + "auth-secret", + "provider-secret", + ] { + assert!(!serialized.contains(secret), "observer leaked {secret}"); + } + assert_eq!( + observed["params"]["mcpServers"][0]["env"][0]["value"], + OBSERVER_REDACTED_ENV_VALUE + ); + assert_eq!( + observed["params"]["mcpServers"][0]["env"][2]["value"], + OBSERVER_REDACTED_ENV_VALUE + ); + assert_eq!( + wire["params"]["mcpServers"][0]["env"][2]["value"], + "broker-secret" + ); + } + #[test] fn session_prompt_request_format() { let prompt_text = "[Buzz @mention]\nChannel: test\nFrom: npub1...\nMessage: hello"; @@ -3170,6 +3252,19 @@ mod tests { assert_eq!(observed, "unset"); } + #[cfg(unix)] + #[tokio::test] + async fn empty_broker_relay_tombstone_removes_inherited_identity_from_child() { + let observed = spawn_named_and_probe_child_env_presence( + "goose", + "BUZZ_BROKER_RELAY_URL", + &[("BUZZ_BROKER_RELAY_URL".into(), String::new())], + ) + .await; + + assert_eq!(observed, "unset"); + } + /// Buzz-owned Hermes processes get the configured-MCP isolation default, /// and an explicit persona entry still overrides it (defaults are applied /// before `extra_env`, so the later `Command::env` write wins). diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 74c1d9d1326..988d9029da1 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -68,6 +68,9 @@ pub enum AgentMode { pub struct BrokerConfig { pub base_url: String, pub credential: String, + /// Canonical relay identity for observer/runtime pairing. The harness does + /// not connect to this URL in broker mode. + pub relay_url: String, pub poll_interval: std::time::Duration, } @@ -76,6 +79,7 @@ impl std::fmt::Debug for BrokerConfig { f.debug_struct("BrokerConfig") .field("base_url", &self.base_url) .field("credential", &"") + .field("relay_url", &self.relay_url) .field("poll_interval", &self.poll_interval) .finish() } @@ -287,6 +291,11 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_BROKER_CREDENTIAL", hide_env_values = true)] pub broker_credential: Option, + /// Relay identity behind the broker, used only for observer/runtime + /// pairing. Broker mode never opens a connection to this URL. + #[arg(long, env = "BUZZ_BROKER_RELAY_URL")] + pub broker_relay_url: Option, + /// Delay between broker polling sweeps in keyless mode. #[arg(long, env = "BUZZ_BROKER_POLL_INTERVAL_MS", default_value_t = 1000)] pub broker_poll_interval_ms: u64, @@ -805,10 +814,11 @@ pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'sta /// /// Codex sandboxes MCP subprocesses (including `buzz-cli`) behind a Seatbelt sandbox /// that blocks all outbound network by default. Without this env var, `buzz-cli` -/// requests are blocked before they can reach the relay WebSocket. +/// requests are blocked before they can reach the selected runtime endpoint (the +/// relay locally, or the broker in keyless mode). /// /// Returns `Some(("CODEX_CONFIG", "{\"sandbox_workspace_write\":{\"network_access\":true}}"))` for -/// Codex agents, or `None` for non-Codex agents or when the relay URL cannot be parsed. +/// Codex agents, or `None` for non-Codex agents or when the endpoint URL cannot be parsed. /// /// The env var is forwarded by the `@agentclientprotocol/codex-acp` adapter (1.x) as a /// session-level config override (via `CODEX_CONFIG` → `thread/start config`), which is @@ -816,11 +826,11 @@ pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'sta /// That sets `NetworkSandboxPolicy::Enabled`, causing the Seatbelt policy to include /// `(allow network-outbound)` — full outbound TCP/TLS at the OS level. /// -/// URL validation is preserved as a guard: injection is skipped when the relay URL cannot +/// URL validation is preserved as a guard: injection is skipped when the endpoint URL cannot /// be parsed, avoiding accidental sandbox widening for malformed configs. /// /// Handles `ws://`, `wss://`, `http://`, and `https://` schemes. -pub fn codex_network_env(agent_command: &str, relay_url: &str) -> Option<(String, String)> { +pub fn codex_network_env(agent_command: &str, endpoint_url: &str) -> Option<(String, String)> { match normalize_agent_command_identity(agent_command).as_str() { "codex" | "codex-acp" => {} _ => return None, @@ -828,19 +838,19 @@ pub fn codex_network_env(agent_command: &str, relay_url: &str) -> Option<(String // Validate the relay URL before injecting broader network access. On parse failure, // skip injection rather than panicking or widening the sandbox unconditionally. - let host = match Url::parse(relay_url) { + let host = match Url::parse(endpoint_url) { Ok(u) => match u.host_str() { Some(h) => h.to_owned(), None => { tracing::warn!( - relay_url, - "codex network config: no host in relay URL — skipping injection" + endpoint_url, + "codex network config: no host in endpoint URL — skipping injection" ); return None; } }, Err(e) => { - tracing::warn!(relay_url, error = %e, "codex network config: failed to parse relay URL — skipping injection"); + tracing::warn!(endpoint_url, error = %e, "codex network config: failed to parse endpoint URL — skipping injection"); return None; } }; @@ -937,6 +947,17 @@ impl Config { .into(), ) })?; + let relay_url = args.broker_relay_url.take().ok_or_else(|| { + ConfigError::ConfigFile( + "BUZZ_BROKER_RELAY_URL / --broker-relay-url is required in broker mode" + .into(), + ) + })?; + let relay_url = buzz_core::relay::normalize_relay_url(&relay_url).map_err(|error| { + ConfigError::ConfigFile(format!( + "invalid broker relay identity in BUZZ_BROKER_RELAY_URL / --broker-relay-url: {error}" + )) + })?; if args.broker_poll_interval_ms < 100 { return Err(ConfigError::ConfigFile( "broker poll interval must be at least 100ms".into(), @@ -977,6 +998,7 @@ impl Config { Some(BrokerConfig { base_url, credential, + relay_url, poll_interval: std::time::Duration::from_millis(args.broker_poll_interval_ms), }) } @@ -1199,6 +1221,9 @@ impl Config { // Spawned desktop agents now carry a complete instance snapshot. Team // instructions arrive independently so they can be layered at runtime. + let relay_url = broker + .as_ref() + .map_or_else(|| args.relay_url.clone(), |broker| broker.relay_url.clone()); let mut persona_env_vars = Vec::new(); match broker.as_ref() { Some(broker) => persona_env_vars.extend([ @@ -1207,6 +1232,7 @@ impl Config { ("BUZZ_BROKER_CREDENTIAL".into(), broker.credential.clone()), // Explicit tombstones keep inherited local credentials and // routing out of the spawned agent process. + ("BUZZ_BROKER_RELAY_URL".into(), String::new()), ("BUZZ_RELAY_URL".into(), String::new()), ("BUZZ_PRIVATE_KEY".into(), String::new()), ("BUZZ_AUTH_TAG".into(), String::new()), @@ -1215,7 +1241,8 @@ impl Config { ("BUZZ_AGENT_MODE".into(), "local".into()), ("BUZZ_BROKER_URL".into(), String::new()), ("BUZZ_BROKER_CREDENTIAL".into(), String::new()), - ("BUZZ_RELAY_URL".into(), args.relay_url.clone()), + ("BUZZ_BROKER_RELAY_URL".into(), String::new()), + ("BUZZ_RELAY_URL".into(), relay_url.clone()), ("BUZZ_PRIVATE_KEY".into(), keys.secret_key().to_secret_hex()), ]), } @@ -1224,8 +1251,11 @@ impl Config { // Inject CODEX_CONFIG so the @agentclientprotocol/codex-acp adapter (1.x) // opens the Seatbelt network sandbox for buzz-cli (an MCP subprocess). No-op // for non-Codex agents or unparseable relay URLs. + let network_url = broker + .as_ref() + .map_or(relay_url.as_str(), |broker| broker.base_url.as_str()); let has_generated_codex_config = - if let Some(network_env) = codex_network_env(&agent_command, &args.relay_url) { + if let Some(network_env) = codex_network_env(&agent_command, network_url) { persona_env_vars.push(network_env); true } else { @@ -1238,7 +1268,7 @@ impl Config { keys, agent_mode: args.agent_mode, broker, - relay_url: args.relay_url, + relay_url, agent_command, agent_args, mcp_command: args.mcp_command, @@ -1306,8 +1336,8 @@ impl Config { self.keys.public_key().to_hex() ), (AgentMode::Broker, Some(broker)) => format!( - "mode=broker broker={} pubkey=(derived-at-connect)", - broker.base_url + "mode=broker broker={} relay_identity={} pubkey=(derived-at-connect)", + broker.base_url, broker.relay_url ), (AgentMode::Broker, None) => { "mode=broker broker=(missing) pubkey=(derived-at-connect)".into() @@ -1696,6 +1726,8 @@ mod tests { "http://127.0.0.1:8787", "--broker-credential", "cred", + "--broker-relay-url", + "wss://relay.example", "--channels", CHANNEL, "--agent-owner", @@ -1711,6 +1743,7 @@ mod tests { assert_eq!(config.agent_mode, AgentMode::Broker); assert!(config.broker.is_some()); + assert_eq!(config.relay_url, "wss://relay.example"); assert!(config.presence_enabled); assert!(config.typing_enabled); assert!(config.memory_enabled); @@ -1768,6 +1801,43 @@ mod tests { .contains("keyless")); } + #[test] + fn broker_mode_requires_an_explicit_relay_identity() { + let mut args = broker_args(&[]); + args.broker_relay_url = None; + + assert!(Config::from_args(args) + .expect_err("broker relay identity must be required") + .to_string() + .contains("BUZZ_BROKER_RELAY_URL")); + } + + #[test] + fn broker_mode_canonicalizes_relay_identity_without_forwarding_it() { + let mut args = broker_args(&[]); + args.broker_relay_url = Some("WSS://Relay.Example:443/".into()); + let config = Config::from_args(args).expect("valid broker config"); + + assert_eq!(config.relay_url, "wss://relay.example"); + assert!(config + .persona_env_vars + .iter() + .any(|(name, value)| name == "BUZZ_BROKER_RELAY_URL" && value.is_empty())); + assert!(config + .persona_env_vars + .iter() + .any(|(name, value)| name == "BUZZ_RELAY_URL" && value.is_empty())); + } + + #[test] + fn broker_mode_rejects_invalid_relay_identity() { + let mut args = broker_args(&[]); + args.broker_relay_url = Some("https://relay.example".into()); + + let error = Config::from_args(args).expect_err("invalid relay identity should fail"); + assert!(error.to_string().contains("scheme must be ws or wss")); + } + #[test] fn local_mode_requires_a_private_key() { let args = CliArgs::parse_from(["buzz-acp", "--agent-mode", "local"]); @@ -1791,6 +1861,8 @@ mod tests { "http://127.0.0.1:8787", "--broker-credential", "cred", + "--broker-relay-url", + "wss://relay.example", "--agent-owner", OWNER, ]); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 31da7c4bf3d..752d4ee78bc 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -7020,6 +7020,7 @@ mod build_mcp_servers_tests { config.broker = Some(config::BrokerConfig { base_url: "http://127.0.0.1:8787".into(), credential: "broker-token".into(), + relay_url: "wss://relay.example".into(), poll_interval: std::time::Duration::from_secs(1), }); diff --git a/crates/buzz-broker-client/Cargo.toml b/crates/buzz-broker-client/Cargo.toml index ce36cfd2431..c810464db12 100644 --- a/crates/buzz-broker-client/Cargo.toml +++ b/crates/buzz-broker-client/Cargo.toml @@ -15,3 +15,4 @@ tokio = { workspace = true } [dev-dependencies] axum = { workspace = true } +nostr = { workspace = true } diff --git a/crates/buzz-broker-client/src/lib.rs b/crates/buzz-broker-client/src/lib.rs index bf346d4a1c5..1d9f1331c9a 100644 --- a/crates/buzz-broker-client/src/lib.rs +++ b/crates/buzz-broker-client/src/lib.rs @@ -6,12 +6,23 @@ //! correlation checks; this type never interprets a verdict. use buzz_sdk::broker::{ - BrokerClient, BrokerFuture, BrokerResponse, BrokerTransportError, Dispatch, PreparedRequest, - BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, + Action, BrokerClient, BrokerFuture, BrokerResponse, BrokerTransportError, Dispatch, + PreparedRequest, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, }; const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +// A channel.read page may contain 500 messages whose content alone is up to +// 64 KiB each. Keep the transport bounded while leaving room for the signed +// event envelopes, tags, and JSON encoding around that contract-valid page. +const MAX_CHANNEL_READ_RESPONSE_BYTES: usize = 64 * 1024 * 1024; + +fn max_response_bytes(request: &PreparedRequest) -> usize { + match request.action() { + Action::ChannelRead => MAX_CHANNEL_READ_RESPONSE_BYTES, + _ => MAX_RESPONSE_BYTES, + } +} /// Invalid broker provisioning rejected before a bearer credential can leave /// the process. @@ -114,6 +125,7 @@ impl HttpBrokerClient { impl BrokerClient for HttpBrokerClient { fn send<'a>(&'a self, request: &'a PreparedRequest, _dispatch: Dispatch) -> BrokerFuture<'a> { Box::pin(async move { + let max_response_bytes = max_response_bytes(request); let url = format!( "{}{BROKER_ACTION_PATH}", self.base_url.trim_end_matches('/') @@ -135,11 +147,11 @@ impl BrokerClient for HttpBrokerClient { let status = response.status().as_u16(); if response .content_length() - .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + .is_some_and(|length| length > max_response_bytes as u64) { return Err(BrokerTransportError::NoEnvelope { status, - detail: format!("response exceeds {MAX_RESPONSE_BYTES} bytes"), + detail: format!("response exceeds {max_response_bytes} bytes"), }); } let mut body = Vec::new(); @@ -148,10 +160,10 @@ impl BrokerClient for HttpBrokerClient { .await .map_err(|e| BrokerTransportError::Unreachable(e.to_string()))? { - if body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + if body.len().saturating_add(chunk.len()) > max_response_bytes { return Err(BrokerTransportError::NoEnvelope { status, - detail: format!("response exceeds {MAX_RESPONSE_BYTES} bytes"), + detail: format!("response exceeds {max_response_bytes} bytes"), }); } body.extend_from_slice(&chunk); @@ -190,9 +202,12 @@ mod tests { use axum::response::Response; use axum::routing::post; use axum::Router; + use buzz_sdk::broker::actions::MAX_CONTENT_BYTES; use buzz_sdk::broker::{ - ActionArgs, BrokerClientExt, BrokerRequest, BrokerResult, MessagePostArgs, + ActionArgs, ActionOutcome, BrokerClientExt, BrokerMessage, BrokerRequest, BrokerResult, + ChannelReadArgs, MessagePage, MessagePostArgs, }; + use nostr::{EventBuilder, Keys, Kind}; use tokio::net::TcpListener; const CHANNEL: &str = "5df7dfa8-e919-43df-8efd-f1dcb8af7071"; @@ -242,6 +257,20 @@ mod tests { .unwrap() } + fn channel_read_request() -> PreparedRequest { + let args = ActionArgs::ChannelRead(ChannelReadArgs { + channel_id: CHANNEL.to_string(), + root_event_id: None, + mentions_only: false, + cursor: None, + limit: Some(100), + }); + BrokerRequest::new("req-read-1", args) + .unwrap() + .prepare() + .unwrap() + } + #[tokio::test] async fn success_round_trips_and_sends_bearer_credential() { let req = post_request(); @@ -344,4 +373,34 @@ mod tests { BrokerTransportError::NoEnvelope { detail, .. } if detail.contains("exceeds") )); } + + #[tokio::test] + async fn contract_valid_channel_page_may_exceed_default_response_bound() { + let request = channel_read_request(); + let message = BrokerMessage( + EventBuilder::new(Kind::Custom(9), "x".repeat(MAX_CONTENT_BYTES)) + .sign_with_keys(&Keys::generate()) + .expect("fixture event signs"), + ); + let outcome = ActionOutcome::ChannelRead(MessagePage { + messages: vec![message; 40], + next_cursor: None, + }); + let body = serde_json::to_string(&BrokerResponse::new( + request.request_id(), + BrokerResult::succeeded(outcome), + )) + .unwrap(); + assert!(body.len() > MAX_RESPONSE_BYTES); + assert!(body.len() < MAX_CHANNEL_READ_RESPONSE_BYTES); + let (base, _) = spawn(StatusCode::OK, body).await; + + let response = HttpBrokerClient::new(base, CRED) + .unwrap() + .execute(&request) + .await + .expect("valid channel page"); + + assert!(matches!(response.result(), BrokerResult::Succeeded { .. })); + } } diff --git a/crates/buzz-cli/KEYLESS.md b/crates/buzz-cli/KEYLESS.md index db1269f12d7..51fa1c1a207 100644 --- a/crates/buzz-cli/KEYLESS.md +++ b/crates/buzz-cli/KEYLESS.md @@ -20,7 +20,9 @@ Commands without a matching broker capability still need the local backend. The same provisioning variables now select the keyless `buzz-acp` runtime. It derives its public identity through `storage.address`, polls configured channels through `channel.read`, uses broker-backed memory and live -signals, and passes broker provisioning to the CLI used by the spawned agent. See +signals, and passes broker provisioning to the CLI used by the spawned agent. +The ACP harness additionally requires `BUZZ_BROKER_RELAY_URL` as observer/runtime +identity metadata, but never connects to that relay URL directly. See [`../buzz-acp/README.md`](../buzz-acp/README.md#keyless-broker-mode) for the runtime command and its deliberate housekeeping limits. From f42a4a3c1bbeeaf3e1a7c3de7c70af48c49e6bd7 Mon Sep 17 00:00:00 2001 From: Joel Robotham Date: Sat, 29 Aug 2026 10:03:01 +1000 Subject: [PATCH 13/13] fix(keyless): address broker security review Signed-off-by: Joel Robotham --- crates/buzz-acp/src/acp.rs | 79 ++++++++++++++-- crates/buzz-acp/src/lib.rs | 64 ++++++++++++- crates/buzz-broker-client/src/lib.rs | 133 +++++++++++++++++++++------ crates/buzz-sdk/src/broker/mod.rs | 18 +++- 4 files changed, 252 insertions(+), 42 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 2f187915211..f9429b021ee 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -274,6 +274,15 @@ fn redact_acp_write_for_observer(value: &serde_json::Value) -> serde_json::Value redacted } +fn log_acp_write(value: &serde_json::Value) { + let diagnostic = redact_acp_write_for_observer(value); + tracing::debug!( + target: "acp::wire", + "→ {}", + serde_json::to_string(&diagnostic).unwrap_or_default() + ); +} + /// Build the merged `CODEX_CONFIG` environment-variable value for a Codex agent spawn. /// /// Returns `Some(json_string)` when `has_generated_codex_config` is true (Buzz injected a @@ -850,8 +859,6 @@ impl AcpClient { "params": params, }); - let diagnostic_msg = redact_acp_write_for_observer(&msg); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&diagnostic_msg).unwrap_or_default()); if let Err(e) = self.write_ndjson(&msg).await { self.last_prompt_id = None; self.current_hard_deadline = None; @@ -1116,6 +1123,7 @@ impl AcpClient { /// (e.g., it's stuck or dead), the write would otherwise block forever. async fn write_ndjson(&mut self, value: &serde_json::Value) -> Result<(), AcpError> { const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + log_acp_write(value); let line = serde_json::to_string(value)?; tokio::time::timeout(WRITE_TIMEOUT, async { self.stdin.write_all(line.as_bytes()).await?; @@ -1157,8 +1165,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); - // Wrap write + read in a single timeout so a hung agent can't block forever. // We cannot use an async block that borrows `self` mutably across two awaits // inside timeout(), so we sequence them with early-return on timeout. @@ -1222,7 +1228,6 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ (notification) {}", &serde_json::to_string(&msg).unwrap_or_default()); self.write_ndjson(&msg).await?; Ok(()) } @@ -1508,11 +1513,6 @@ impl AcpClient { "method": method, "params": params, }); - tracing::debug!( - target: "acp::wire", - "→ {}", - serde_json::to_string(&msg).unwrap_or_default() - ); match self.write_ndjson(&msg).await { Ok(()) => { pending_steer = Some((id, transport, req.ack_tx)); @@ -2401,6 +2401,30 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + #[derive(Clone, Default)] + struct CapturedLogs(std::sync::Arc>>); + + struct CapturedLogWriter(std::sync::Arc>>); + + impl std::io::Write for CapturedLogWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for CapturedLogs { + type Writer = CapturedLogWriter; + + fn make_writer(&'a self) -> Self::Writer { + CapturedLogWriter(self.0.clone()) + } + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); @@ -2651,6 +2675,41 @@ mod tests { ); } + #[test] + fn session_new_wire_log_redacts_mcp_environment_values() { + let wire = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": { + "mcpServers": [{ + "name": "buzz-mcp", + "command": "buzz-mcp", + "args": [], + "env": [{ + "name": "BUZZ_BROKER_CREDENTIAL", + "value": "must-not-reach-the-wire-log" + }] + }] + } + }); + let logs = CapturedLogs::default(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .with_writer(logs.clone()) + .with_ansi(false) + .without_time() + .finish(); + + tracing::subscriber::with_default(subscriber, || log_acp_write(&wire)); + + let captured = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap(); + assert!(captured.contains("session/new")); + assert!(captured.contains("BUZZ_BROKER_CREDENTIAL")); + assert!(captured.contains(OBSERVER_REDACTED_ENV_VALUE)); + assert!(!captured.contains("must-not-reach-the-wire-log")); + } + #[test] fn session_prompt_request_format() { let prompt_text = "[Buzz @mention]\nChannel: test\nFrom: npub1...\nMessage: hello"; diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 752d4ee78bc..e79151659a1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -655,7 +655,7 @@ fn spawn_broker_observer_publisher( }, _ = publish_tick.tick() => { if let Some(mut event) = queue.next_frame() { - fit_observer_event_to_budget(&mut event); + fit_broker_observer_event_to_budget(&mut event); match serde_json::to_string(&event) { Ok(payload) => { let frame = buzz_sdk::broker::ObserverFrame { @@ -949,7 +949,38 @@ const OBSERVER_LEAF_RETAIN_BYTES: usize = 3_000; /// are out of this change's scope (buzz-core stays untouched). The clean `&mut` /// signature with one cheap redundant serialize is the deliberate tradeoff. fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { - if serialized_len(event) <= OBSERVER_MAX_PLAINTEXT_LEN { + fit_observer_event_until(event, |event| { + serialized_len(event) <= OBSERVER_MAX_PLAINTEXT_LEN + }); +} + +/// Trim an observer event until its serialized payload also fits inside the +/// complete broker `observer.emit` argument after JSON string escaping. +fn fit_broker_observer_event_to_budget(event: &mut observer::ObserverEvent) { + fit_observer_event_until(event, broker_observer_event_fits); +} + +fn broker_observer_event_fits(event: &observer::ObserverEvent) -> bool { + let Ok(payload) = serde_json::to_string(event) else { + return false; + }; + buzz_sdk::broker::ObserverEmitArgs { + frames: vec![buzz_sdk::broker::ObserverFrame { + kind: event.kind.clone(), + payload, + }], + } + .validated() + .is_ok() +} + +/// Apply the common deterministic elision algorithm until `fits` accepts the +/// complete destination-specific envelope. +fn fit_observer_event_until( + event: &mut observer::ObserverEvent, + fits: impl Fn(&observer::ObserverEvent) -> bool, +) { + if fits(event) { return; } @@ -965,7 +996,7 @@ fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { // never be re-elided, so the loop is bounded by the leaf count. while let Some(leaf) = largest_shrinkable_leaf(&mut event.payload) { elide_leaf(leaf); - if serialized_len(event) <= OBSERVER_MAX_PLAINTEXT_LEN { + if fits(event) { return; } } @@ -976,6 +1007,10 @@ fn fit_observer_event_to_budget(event: &mut observer::ObserverEvent) { "elided": format!("{} payload too large", event.kind), "originalBytes": original_payload_bytes, }); + debug_assert!( + fits(event), + "observer elision stub must fit its destination" + ); } fn serialized_len(event: &observer::ObserverEvent) -> usize { @@ -8802,6 +8837,29 @@ mod observer_payload_trim_tests { ); } + #[test] + fn test_broker_frame_accounts_for_outer_json_escaping() { + let mut event = event_with_payload( + "acp_write", + serde_json::json!({ "body": "\"".repeat(30_000) }), + ); + assert!( + serialized(&event).len() <= OBSERVER_MAX_PLAINTEXT_LEN, + "inner observer plaintext fits before broker wrapping" + ); + assert!( + !broker_observer_event_fits(&event), + "outer broker JSON escaping must be part of the bound" + ); + + fit_broker_observer_event_to_budget(&mut event); + + assert!(broker_observer_event_fits(&event)); + assert!(event.payload["body"] + .as_str() + .is_some_and(|body| body.contains("…[elided"))); + } + #[test] fn test_single_giant_leaf_is_elided_to_fit_with_envelope_intact() { let big = "x".repeat(100_000); diff --git a/crates/buzz-broker-client/src/lib.rs b/crates/buzz-broker-client/src/lib.rs index 1d9f1331c9a..68f6b5c06ba 100644 --- a/crates/buzz-broker-client/src/lib.rs +++ b/crates/buzz-broker-client/src/lib.rs @@ -6,22 +6,26 @@ //! correlation checks; this type never interprets a verdict. use buzz_sdk::broker::{ - Action, BrokerClient, BrokerFuture, BrokerResponse, BrokerTransportError, Dispatch, - PreparedRequest, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, + BrokerClient, BrokerFuture, BrokerResponse, BrokerTransportError, Dispatch, PreparedRequest, + BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, }; const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +const MAX_CHANNEL_READ_MESSAGE_BYTES: usize = 128 * 1024; // A channel.read page may contain 500 messages whose content alone is up to // 64 KiB each. Keep the transport bounded while leaving room for the signed // event envelopes, tags, and JSON encoding around that contract-valid page. const MAX_CHANNEL_READ_RESPONSE_BYTES: usize = 64 * 1024 * 1024; fn max_response_bytes(request: &PreparedRequest) -> usize { - match request.action() { - Action::ChannelRead => MAX_CHANNEL_READ_RESPONSE_BYTES, - _ => MAX_RESPONSE_BYTES, - } + request + .channel_read_limit() + .map_or(MAX_RESPONSE_BYTES, |limit| { + MAX_RESPONSE_BYTES + .saturating_add((limit as usize).saturating_mul(MAX_CHANNEL_READ_MESSAGE_BYTES)) + .min(MAX_CHANNEL_READ_RESPONSE_BYTES) + }) } /// Invalid broker provisioning rejected before a bearer credential can leave @@ -55,28 +59,18 @@ impl HttpBrokerClient { base_url: impl Into, credential: impl Into, ) -> Result { - let http = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| { - BrokerClientConfigError(format!("failed to build broker HTTP client: {error}")) - })?; - Self::with_client(base_url, credential, http) - } - - /// As [`Self::new`], reusing an existing reqwest client and its pool. - pub fn with_client( - base_url: impl Into, - credential: impl Into, - http: reqwest::Client, - ) -> Result { - Self::with_client_timeout(base_url, credential, http, DEFAULT_REQUEST_TIMEOUT) + Self::with_builder_timeout( + base_url, + credential, + reqwest::Client::builder(), + DEFAULT_REQUEST_TIMEOUT, + ) } - fn with_client_timeout( + fn with_builder_timeout( base_url: impl Into, credential: impl Into, - http: reqwest::Client, + mut http: reqwest::ClientBuilder, request_timeout: std::time::Duration, ) -> Result { let base_url = base_url.into(); @@ -113,6 +107,17 @@ impl HttpBrokerClient { reqwest::header::HeaderValue::from_str(&format!("Bearer {credential}")) .map_err(|_| BrokerClientConfigError("broker credential is not header-safe".into()))?; + http = http.redirect(reqwest::redirect::Policy::none()); + if parsed.scheme() == "http" && loopback { + // Plaintext loopback is safe only when it is structurally direct. + // System or explicitly configured proxies must never receive the + // bearer credential on this path. + http = http.no_proxy(); + } + let http = http.build().map_err(|error| { + BrokerClientConfigError(format!("failed to build broker HTTP client: {error}")) + })?; + Ok(Self { base_url, credential, @@ -200,7 +205,7 @@ mod tests { use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::response::Response; - use axum::routing::post; + use axum::routing::{any, post}; use axum::Router; use buzz_sdk::broker::actions::MAX_CONTENT_BYTES; use buzz_sdk::broker::{ @@ -245,6 +250,26 @@ mod tests { (format!("http://{addr}"), seen_auth) } + async fn spawn_capture_proxy() -> (String, Arc>>) { + let seen_auth = Arc::new(Mutex::new(None)); + let state = seen_auth.clone(); + let app = Router::new() + .fallback(any( + |State(seen): State>>>, headers: HeaderMap| async move { + *seen.lock().unwrap() = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + StatusCode::BAD_GATEWAY + }, + )) + .with_state(state); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (format!("http://{addr}"), seen_auth) + } + fn post_request() -> PreparedRequest { let args = ActionArgs::MessagePost(MessagePostArgs { channel_id: CHANNEL.to_string(), @@ -339,10 +364,10 @@ mod tests { let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let client = HttpBrokerClient::with_client_timeout( + let client = HttpBrokerClient::with_builder_timeout( format!("http://{addr}"), CRED, - reqwest::Client::new(), + reqwest::Client::builder(), std::time::Duration::from_millis(20), ) .unwrap(); @@ -360,6 +385,36 @@ mod tests { assert!(HttpBrokerClient::new("http://localhost:8787", CRED).is_ok()); } + #[tokio::test] + async fn plaintext_loopback_never_sends_the_bearer_through_a_proxy() { + let request = post_request(); + let body = format!( + r#"{{"type":"broker_result","protocolVersion":1,"requestId":"{}","status":"succeeded","action":"message.post","outcome":{{"eventId":"{}","kind":9,"createdAt":1700000000}}}}"#, + request.request_id(), + "a".repeat(64), + ); + let (broker, broker_auth) = spawn(StatusCode::OK, body).await; + let (proxy, proxy_auth) = spawn_capture_proxy().await; + let client = HttpBrokerClient::with_builder_timeout( + broker, + CRED, + reqwest::Client::builder().proxy(reqwest::Proxy::all(proxy).unwrap()), + DEFAULT_REQUEST_TIMEOUT, + ) + .unwrap(); + + client + .execute(&request) + .await + .expect("direct broker response"); + + assert_eq!( + broker_auth.lock().unwrap().as_deref(), + Some("Bearer test-cred") + ); + assert_eq!(proxy_auth.lock().unwrap().as_deref(), None); + } + #[tokio::test] async fn oversized_response_is_rejected_before_parsing() { let (base, _) = spawn(StatusCode::OK, "x".repeat(MAX_RESPONSE_BYTES + 1)).await; @@ -403,4 +458,28 @@ mod tests { assert!(matches!(response.result(), BrokerResult::Succeeded { .. })); } + + #[tokio::test] + async fn channel_read_limit_one_hundred_has_a_request_sized_response_cap() { + let request = channel_read_request(); + let response_cap = max_response_bytes(&request); + assert_eq!(request.channel_read_limit(), Some(100)); + assert_eq!( + response_cap, + MAX_RESPONSE_BYTES + 100 * MAX_CHANNEL_READ_MESSAGE_BYTES + ); + assert!(response_cap < MAX_CHANNEL_READ_RESPONSE_BYTES); + + let (base, _) = spawn(StatusCode::OK, "x".repeat(response_cap + 1)).await; + let error = HttpBrokerClient::new(base, CRED) + .unwrap() + .execute(&request) + .await + .expect_err("response over the requested page budget"); + assert!(matches!( + error, + BrokerTransportError::NoEnvelope { detail, .. } + if detail.contains(&response_cap.to_string()) + )); + } } diff --git a/crates/buzz-sdk/src/broker/mod.rs b/crates/buzz-sdk/src/broker/mod.rs index ed52fe8696e..d0cfd9da9d0 100644 --- a/crates/buzz-sdk/src/broker/mod.rs +++ b/crates/buzz-sdk/src/broker/mod.rs @@ -330,8 +330,9 @@ impl ValidatedRequest { /// This is what [`BrokerClient::send`] takes, so the retry contract is /// structural: every attempt sends `body` verbatim, and no implementation gets /// the chance to reserialize. The typed request is deliberately not exposed — -/// only the correlation metadata ([`Self::request_id`], [`Self::action`]) an -/// implementation legitimately needs. +/// only the correlation and transport-bound metadata ([`Self::request_id`], +/// [`Self::action`], [`Self::channel_read_limit`]) an implementation +/// legitimately needs. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PreparedRequest { request: BrokerRequest, @@ -356,6 +357,19 @@ impl PreparedRequest { pub fn action(&self) -> Action { self.request.action() } + + /// Effective page size for a `channel.read`, or `None` for other actions. + /// + /// Transports use this validated value to bound response buffering before + /// parsing. An omitted wire limit has already acquired the protocol + /// default through [`ChannelReadArgs::effective_limit`]. + #[must_use] + pub fn channel_read_limit(&self) -> Option { + match &self.request.action { + ActionArgs::ChannelRead(args) => Some(args.effective_limit()), + _ => None, + } + } } /// Machine-readable broker error code.