diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..4e4f2d53ac 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -23,7 +23,7 @@ clap = { version = "4", features = ["derive", "env"] } reqwest = { workspace = true, features = ["json"] } # Async runtime — tokio macros + multi-thread for reqwest -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } # Serialization — JSON body building and response passthrough serde = { workspace = true } diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..481bcdd4ab 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -569,6 +569,11 @@ impl BuzzClient { &self.relay_url } + /// NIP-OA auth tag for WebSocket AUTH / EVENT signing, if configured. + pub fn auth_tag(&self) -> Option<&nostr::Tag> { + self.auth_tag.as_ref() + } + /// Return the owner pubkey carried by the NIP-OA auth tag, if any. /// /// The auth tag is `["auth", owner_pubkey, conditions, sig]`; the diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2..eba5419072 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -147,6 +147,29 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli Ok(()) } + AgentsCmd::Publish { + agent_pubkey, + content, + } => { + let raw = crate::validate::read_or_stdin(&content)?; + let body = crate::managed_agent_publish::validate_managed_agent_content(&raw)?; + let builder = + crate::managed_agent_publish::build_managed_agent_event(&agent_pubkey, &body)?; + let event = client.sign_event(builder)?; + let event_id = event.id.to_hex(); + client.submit_event(event).await?; + println!( + "{}", + serde_json::json!({ + "ok": true, + "event_id": event_id, + "kind": 30177, + "d": agent_pubkey, + "action": "publish", + }) + ); + Ok(()) + } AgentsCmd::Archived => cmd_archived(client).await, } } diff --git a/crates/buzz-cli/src/commands/listen.rs b/crates/buzz-cli/src/commands/listen.rs new file mode 100644 index 0000000000..52fc1f1a92 --- /dev/null +++ b/crates/buzz-cli/src/commands/listen.rs @@ -0,0 +1,279 @@ +//! `buzz listen` — persistent WebSocket stream of channel events (#2663 Option B). +//! +//! Prints one normalized JSON object per inbound EVENT matching the subscription +//! filters (newline-delimited JSON on stdout). Uses the same NIP-42 / NIP-OA +//! path as `publish_ephemeral_event`. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::json; +use uuid::Uuid; + +use crate::client::{normalize_events, BuzzClient}; +use crate::error::CliError; +use crate::validate::validate_uuid; + +/// Default kinds for channel traffic (matches `messages get` defaults). +const DEFAULT_KINDS: &[u32] = &[9, 40002, 40008, 45001, 45003]; + +fn parse_kinds(raw: Option<&str>) -> Result, CliError> { + match raw { + None => Ok(DEFAULT_KINDS.to_vec()), + Some(s) if s.trim().is_empty() => Ok(DEFAULT_KINDS.to_vec()), + Some(s) => { + let mut out = Vec::new(); + for part in s.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let k: u32 = part + .parse() + .map_err(|_| CliError::Usage(format!("invalid kind in --kinds: {part}")))?; + out.push(k); + } + if out.is_empty() { + return Err(CliError::Usage("--kinds must list at least one kind".into())); + } + Ok(out) + } + } +} + +/// Build subscription filter list for REQ. +pub(crate) fn build_listen_filters( + channels: &[String], + mentions_of_me: bool, + my_pubkey_hex: &str, + kinds: &[u32], +) -> Result, CliError> { + if channels.is_empty() && !mentions_of_me { + return Err(CliError::Usage( + "buzz listen requires --channel and/or --mentions-of-me".into(), + )); + } + for ch in channels { + validate_uuid(ch)?; + } + + let mut filters = Vec::new(); + if !channels.is_empty() { + filters.push(json!({ + "kinds": kinds, + "#h": channels, + })); + } + if mentions_of_me { + let mut f = json!({ + "kinds": kinds, + "#p": [my_pubkey_hex], + }); + if !channels.is_empty() { + f["#h"] = json!(channels); + } + filters.push(f); + } + Ok(filters) +} + +/// Optional POST of each event JSON body to `url` (Option A, best-effort). +async fn post_webhook(client: &reqwest::Client, url: &str, body: &str) { + if let Err(e) = client + .post(url) + .header("content-type", "application/json") + .body(body.to_string()) + .send() + .await + { + eprintln!( + "{}", + json!({"error":"webhook","message": format!("{e}")}) + ); + } +} + +fn http_to_ws(http_url: &str) -> String { + http_url + .replace("https://", "wss://") + .replace("http://", "ws://") +} + +/// Run the listen loop until Ctrl-C or fatal error. +pub async fn cmd_listen( + client: &BuzzClient, + channels: Vec, + mentions_of_me: bool, + kinds_raw: Option, + webhook: Option, + reconnect: bool, +) -> Result<(), CliError> { + let kinds = parse_kinds(kinds_raw.as_deref())?; + let my_pk = client.keys().public_key().to_hex(); + let filters = build_listen_filters(&channels, mentions_of_me, &my_pk, &kinds)?; + let ws_url = http_to_ws(client.relay_url()); + let http = reqwest::Client::new(); + let running = Arc::new(AtomicBool::new(true)); + + // Background Ctrl-C watcher (unix + windows via tokio::signal). + { + let r = running.clone(); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + r.store(false, Ordering::SeqCst); + }); + } + + let mut backoff_ms: u64 = 500; + const MAX_BACKOFF_MS: u64 = 30_000; + + while running.load(Ordering::SeqCst) { + match listen_session( + client, + &ws_url, + &filters, + webhook.as_deref(), + &http, + running.clone(), + ) + .await + { + Ok(()) => { + if !running.load(Ordering::SeqCst) { + break; + } + if !reconnect { + break; + } + } + Err(e) => { + if !running.load(Ordering::SeqCst) { + break; + } + if !reconnect { + return Err(e); + } + eprintln!( + "{}", + json!({ + "error": "listen_reconnect", + "message": e.to_string(), + "backoff_ms": backoff_ms, + }) + ); + } + } + if !reconnect || !running.load(Ordering::SeqCst) { + break; + } + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + backoff_ms = (backoff_ms.saturating_mul(2)).min(MAX_BACKOFF_MS); + } + Ok(()) +} + +async fn listen_session( + client: &BuzzClient, + ws_url: &str, + filters: &[serde_json::Value], + webhook: Option<&str>, + http: &reqwest::Client, + running: Arc, +) -> Result<(), CliError> { + use buzz_ws_client::{NostrWsConnection, RelayMessage}; + + let mut conn = NostrWsConnection::connect_authenticated( + ws_url, + client.keys(), + client.auth_tag(), + ) + .await + .map_err(|e| CliError::Other(format!("websocket: {e}")))?; + + let sub_id = format!("buzz-listen-{}", &Uuid::new_v4().to_string()[..8]); + let mut req = vec![json!("REQ"), json!(sub_id)]; + for f in filters { + req.push(f.clone()); + } + conn.send_raw(&json!(req)) + .await + .map_err(|e| CliError::Other(format!("websocket send: {e}")))?; + + while running.load(Ordering::SeqCst) { + // Short poll so Ctrl-C can exit promptly. + let msg = match conn.next_event(Duration::from_millis(500)).await { + Ok(m) => m, + Err(buzz_ws_client::WsClientError::Timeout) => continue, + Err(e) => return Err(CliError::Other(format!("websocket: {e}"))), + }; + + match msg { + RelayMessage::Event { event, .. } => { + let raw = serde_json::to_value(event.as_ref()) + .map_err(|e| CliError::Other(format!("event serialize: {e}")))?; + let line = normalize_events(std::slice::from_ref(&raw)); + // normalize_events returns a JSON array; unwrap first element for NDJSON stream. + let arr: Vec = + serde_json::from_str(&line).unwrap_or_default(); + let body = arr.first().cloned().unwrap_or(raw); + let text = serde_json::to_string(&body).unwrap_or_default(); + println!("{text}"); + let _ = std::io::stdout().flush(); + if let Some(url) = webhook { + post_webhook(http, url, &text).await; + } + } + RelayMessage::Closed { message, .. } => { + return Err(CliError::Other(format!("subscription closed: {message}"))); + } + RelayMessage::Notice { message } => { + eprintln!("{}", json!({"notice": message})); + } + RelayMessage::Eose { .. } + | RelayMessage::Ok(_) + | RelayMessage::Auth { .. } + | RelayMessage::Count { .. } => {} + } + } + + let _ = conn.send_raw(&json!(["CLOSE", sub_id])).await; + let _ = conn.disconnect().await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requires_channel_or_mentions() { + let err = build_listen_filters(&[], false, &"a".repeat(64), DEFAULT_KINDS).unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn channel_filter_uses_h_tag() { + let ch = "11111111-1111-1111-1111-111111111111".to_string(); + let f = build_listen_filters(&[ch.clone()], false, &"a".repeat(64), DEFAULT_KINDS).unwrap(); + assert_eq!(f.len(), 1); + assert_eq!(f[0]["#h"][0], ch); + } + + #[test] + fn mentions_filter_uses_p_tag() { + let pk = "b".repeat(64); + let f = build_listen_filters(&[], true, &pk, DEFAULT_KINDS).unwrap(); + assert_eq!(f.len(), 1); + assert_eq!(f[0]["#p"][0], pk); + } + + #[test] + fn both_channel_and_mentions_two_filters() { + let ch = "11111111-1111-1111-1111-111111111111".to_string(); + let pk = "c".repeat(64); + let f = build_listen_filters(&[ch], true, &pk, DEFAULT_KINDS).unwrap(); + assert_eq!(f.len(), 2); + } +} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..a91dc4cecf 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -244,13 +244,17 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { crate::OutputFormat::Compact => { let events: Vec = serde_json::from_str(normalized).unwrap_or_default(); + // Compact is for agent scanning (#2663 gap #2): keep pubkey + tags so + // listeners can skip self-messages and detect `p` mention tags. let compact: Vec = events .iter() .map(|e| { serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), + "id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""), + "content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.get("created_at").cloned().unwrap_or(serde_json::json!(0)), + "tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])), }) }) .collect(); @@ -1165,3 +1169,35 @@ mod tests { assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } } + + +#[cfg(test)] +mod compact_format_tests { + use super::format_events; + use crate::OutputFormat; + + #[test] + fn compact_format_keeps_pubkey_and_tags() { + let normalized = r#"[{"id":"aa","pubkey":"bb","kind":9,"content":"hi","created_at":1,"tags":[["p","cc"],["h","ch"]]}]"#; + let out = format_events(normalized, &OutputFormat::Compact); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let row = &v[0]; + assert_eq!(row["id"], "aa"); + assert_eq!(row["pubkey"], "bb"); + assert_eq!(row["content"], "hi"); + assert_eq!(row["created_at"], 1); + assert_eq!(row["tags"][0][0], "p"); + assert_eq!(row["tags"][0][1], "cc"); + assert!(row.get("kind").is_none(), "compact omits kind"); + } + + #[test] + fn compact_format_defaults_missing_tags() { + let normalized = r#"[{"id":"aa","content":"x","created_at":2}]"#; + let out = format_events(normalized, &OutputFormat::Compact); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + // Missing keys become JSON null via Value::default, tags become []. + assert_eq!(v[0]["pubkey"], ""); + assert_eq!(v[0]["tags"], serde_json::json!([])); + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..1735fbd35e 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod dms; pub mod emoji; pub mod feed; pub mod issues; +pub mod listen; pub mod mem; pub mod messages; pub mod moderation; diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..7833300098 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -1,9 +1,34 @@ use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; use crate::validate::validate_hex64; +use nostr::ToBech32; // TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with buzz-sdk builder +/// Build identity JSON for `buzz users me` (unit-testable without a live client). +pub(crate) fn me_identity_json(pubkey_hex: &str, npub_bech32: &str) -> serde_json::Value { + serde_json::json!({ + "pubkey": pubkey_hex, + "npub": npub_bech32, + }) +} + +/// Print the active CLI identity (hex pubkey + npub) from the loaded private key. +/// +/// No relay I/O — agents can resolve "who am I?" before connecting. Refs #2663. +pub async fn cmd_me( + client: &BuzzClient, + _format: &crate::OutputFormat, +) -> Result<(), CliError> { + let pk = client.keys().public_key(); + let hex = pk.to_hex(); + let npub = pk + .to_bech32() + .map_err(|e| CliError::Other(format!("npub encode failed: {e}")))?; + println!("{}", me_identity_json(&hex, &npub)); + Ok(()) +} + /// Get user profiles (kind:0 metadata events). /// /// - 0 pubkeys, no name → query our own profile @@ -311,6 +336,7 @@ pub async fn dispatch( ) -> Result<(), CliError> { use crate::UsersCmd; match cmd { + UsersCmd::Me => cmd_me(client, format).await, UsersCmd::Get { pubkeys, name } => { cmd_get_users(client, &pubkeys, name.as_deref(), format).await } @@ -356,4 +382,26 @@ mod tests { let event = json!({"pubkey": "user", "tags": [["p"]]}); assert_eq!(presence_subject(&event), "user"); } + + #[test] + fn me_identity_json_includes_pubkey_and_npub() { + let hex = "a".repeat(64); + let v = super::me_identity_json( + &hex, + "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq5s0xov", + ); + assert_eq!(v["pubkey"], hex); + assert!(v["npub"].as_str().unwrap().starts_with("npub1")); + } + + #[test] + fn me_identity_from_generated_keys_roundtrips_bech32() { + use nostr::{Keys, ToBech32}; + let keys = Keys::generate(); + let hex = keys.public_key().to_hex(); + let npub = keys.public_key().to_bech32().expect("npub"); + let v = super::me_identity_json(&hex, &npub); + assert_eq!(v["pubkey"].as_str().unwrap().len(), 64); + assert_eq!(v["npub"].as_str().unwrap(), npub); + } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..9dd075651b 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod managed_agent_publish; mod validate; use clap::{Parser, Subcommand}; @@ -173,6 +174,33 @@ pub enum OutputFormat { #[derive(Subcommand)] enum Cmd { + /// Stream channel events over WebSocket (NDJSON on stdout) — external agents (#2663) + #[command( + after_help = "Requires BUZZ_PRIVATE_KEY and BUZZ_RELAY_URL. NIP-42 AUTH over WS. + +Filters (need at least one): --channel UUID (repeatable), --mentions-of-me. +Optional: --kinds, --webhook URL, --no-reconnect. + +OpenClaw: buzz listen --channel $CHANNEL --mentions-of-me | jq -c . +Docs: docs/cli-external-agents.md (30177 schema + compact fields)." + )] + Listen { + /// Channel UUID to subscribe (repeatable) + #[arg(long = "channel")] + channels: Vec, + /// Subscribe to events that p-tag this identity + #[arg(long, default_value_t = false)] + mentions_of_me: bool, + /// Comma-separated Nostr kinds (default: messages get defaults) + #[arg(long)] + kinds: Option, + /// Optional webhook URL — POST each event JSON body + #[arg(long)] + webhook: Option, + /// Disable automatic reconnect with exponential backoff + #[arg(long, default_value_t = false)] + no_reconnect: bool, + }, /// Draft owner-reviewed agent creation and updates #[command(subcommand)] Agents(AgentsCmd), @@ -242,6 +270,8 @@ enum Cmd { pub enum RespondToArg { #[value(name = "owner-only")] OwnerOnly, + #[value(name = "allowlist")] + Allowlist, #[value(name = "anyone")] Anyone, } @@ -250,6 +280,7 @@ impl RespondToArg { fn to_wire(self) -> String { match self { Self::OwnerOnly => "owner-only", + Self::Allowlist => "allowlist", Self::Anyone => "anyone", } .to_string() @@ -332,6 +363,19 @@ buzz agents unarchive --reason returned")] #[arg(long, default_value = "")] content: String, }, + /// Headless publish kind:30177 managed-agent definition (directory registration) + #[command( + name = "publish", + after_help = "Signs and POSTs a NIP-33 parameterized replaceable kind:30177 as the CLI\nidentity (owner). d-tag = agent pubkey. Content MUST include name, parallelism,\nand respond_to (owner-only|allowlist|anyone). Relay does not validate content —\nthis command does client-side so the desktop directory can parse the entry.\n\nExample:\n buzz agents publish --agent-pubkey <64hex> --content '{\"name\":\"OpenClaw\",\"parallelism\":1,\"respond_to\":\"owner-only\"}'\n echo '{...}' | buzz agents publish --agent-pubkey <64hex> --content -\n\nSchema: docs/cli-external-agents.md" + )] + Publish { + /// Agent identity pubkey (64-hex) — d-tag of the 30177 + #[arg(long)] + agent_pubkey: String, + /// JSON content body or '-' for stdin + #[arg(long)] + content: String, + }, /// Read the relay's current NIP-IA archive snapshot (kind 13535) #[command( after_help = "Verifies the snapshot's NIP-11 `self` authorship, event id, signature, \ @@ -800,6 +844,12 @@ pub enum DmsCmd { #[derive(Subcommand)] pub enum UsersCmd { + /// Print the CLI identity derived from BUZZ_PRIVATE_KEY (no relay round-trip) + /// + /// External agents (OpenClaw, custom bridges) hold a private key but often + /// need their own pubkey without embedding secp256k1. Refs #2663 gap #1. + #[command(name = "me", alias = "whoami")] + Me, /// Look up user profiles by pubkey or name Get { /// User pubkey(s) to look up (64-char hex). Omit for your own profile @@ -1768,6 +1818,23 @@ async fn run(cli: Cli) -> Result<(), CliError> { let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; match cli.command { + Cmd::Listen { + channels, + mentions_of_me, + kinds, + webhook, + no_reconnect, + } => { + commands::listen::cmd_listen( + &client, + channels, + mentions_of_me, + kinds, + webhook, + !no_reconnect, + ) + .await + } Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, @@ -1813,6 +1880,7 @@ mod tests { "emoji", "feed", "issues", + "listen", "media", "mem", "messages", @@ -1875,6 +1943,7 @@ mod tests { "archived", "draft-create", "draft-update", + "publish", "unarchive" ] ); @@ -1924,7 +1993,7 @@ mod tests { ); assert_eq!( names(&cmd, "users"), - vec!["get", "presence", "set-presence", "set-profile"] + vec!["get", "me", "presence", "set-presence", "set-profile"] ); assert_eq!( names(&cmd, "workflows"), @@ -1995,7 +2064,7 @@ mod tests { #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ - ("agents", 5), + ("agents", 6), ("canvas", 2), ("channels", 16), ("dms", 4), @@ -2011,7 +2080,7 @@ mod tests { ("repos", 4), ("social", 7), ("upload", 1), - ("users", 4), + ("users", 5), ("workflows", 8), ]; diff --git a/crates/buzz-cli/src/managed_agent_publish.rs b/crates/buzz-cli/src/managed_agent_publish.rs new file mode 100644 index 0000000000..7a1d1fdca2 --- /dev/null +++ b/crates/buzz-cli/src/managed_agent_publish.rs @@ -0,0 +1,219 @@ +//! Headless kind:30177 managed-agent directory publish (#2663 gaps #3–#4). +//! +//! Content schema mirrors desktop `ManagedAgentEventContent` (opt-in public +//! fields only). Sign as the **owner**; `d` tag = agent pubkey. + +use nostr::{EventBuilder, Kind, PublicKey, Tag}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::CliError; +use crate::validate::validate_hex64; +use buzz_core::kind::KIND_MANAGED_AGENT; + +/// Wire form of kind:30177 content (required: name, parallelism, respond_to). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManagedAgentPublishContent { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_source_version: Option, + pub parallelism: u32, + /// kebab-case: owner-only | allowlist | anyone + pub respond_to: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, +} + +/// Validate and normalize pasteable JSON for kind:30177 content. +pub fn validate_managed_agent_content(raw: &str) -> Result { + let v: Value = serde_json::from_str(raw) + .map_err(|e| CliError::Usage(format!("invalid 30177 JSON: {e}")))?; + let obj = v + .as_object() + .ok_or_else(|| CliError::Usage("30177 content must be a JSON object".into()))?; + + // Reject known-secret / local-only keys so operators don't leak keys. + const FORBIDDEN: &[&str] = &[ + "private_key_nsec", + "private_key", + "auth_tag", + "env_vars", + "backend", + "nsec", + ]; + for k in FORBIDDEN { + if obj.contains_key(*k) { + return Err(CliError::Usage(format!( + "30177 content must not include '{k}' (secrets/local fields stay off-wire)" + ))); + } + } + + let name = obj + .get("name") + .and_then(|x| x.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + CliError::Usage( + "30177 content requires non-empty string field `name` (not display_name)".into(), + ) + })? + .to_string(); + + let parallelism = obj + .get("parallelism") + .and_then(|x| x.as_u64()) + .ok_or_else(|| { + CliError::Usage("30177 content requires unsigned integer field `parallelism`".into()) + })?; + if parallelism == 0 || parallelism > 1024 { + return Err(CliError::Usage( + "30177 `parallelism` must be between 1 and 1024".into(), + )); + } + + let respond_to = obj + .get("respond_to") + .and_then(|x| x.as_str()) + .ok_or_else(|| { + CliError::Usage( + "30177 content requires `respond_to` (owner-only|allowlist|anyone)".into(), + ) + })?; + let respond_to = match respond_to { + "owner-only" | "allowlist" | "anyone" => respond_to.to_string(), + other => { + return Err(CliError::Usage(format!( + "invalid respond_to '{other}' (expected owner-only|allowlist|anyone)" + ))) + } + }; + + let mut allowlist = Vec::new(); + if let Some(arr) = obj.get("respond_to_allowlist") { + let Some(list) = arr.as_array() else { + return Err(CliError::Usage( + "`respond_to_allowlist` must be an array of hex pubkeys".into(), + )); + }; + for entry in list { + let s = entry + .as_str() + .ok_or_else(|| CliError::Usage("allowlist entries must be strings".into()))?; + validate_hex64(s)?; + allowlist.push(s.to_ascii_lowercase()); + } + } + if respond_to == "allowlist" && allowlist.is_empty() { + return Err(CliError::Usage( + "respond_to=allowlist requires non-empty respond_to_allowlist".into(), + )); + } + + let opt_str = |key: &str| -> Result, CliError> { + match obj.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(_) => Err(CliError::Usage(format!("`{key}` must be a string"))), + } + }; + + Ok(ManagedAgentPublishContent { + name, + persona_id: opt_str("persona_id")?, + system_prompt: opt_str("system_prompt")?, + model: opt_str("model")?, + provider: opt_str("provider")?, + persona_source_version: opt_str("persona_source_version")?, + parallelism: parallelism as u32, + respond_to, + respond_to_allowlist: allowlist, + }) +} + +pub fn build_managed_agent_event( + agent_pubkey_hex: &str, + content: &ManagedAgentPublishContent, +) -> Result { + validate_hex64(agent_pubkey_hex)?; + // Ensure pubkey parses. + PublicKey::parse(agent_pubkey_hex) + .map_err(|e| CliError::Usage(format!("invalid agent pubkey: {e}")))?; + let body = serde_json::to_string(content) + .map_err(|e| CliError::Other(format!("serialize 30177: {e}")))?; + let d = Tag::parse(["d", agent_pubkey_hex]) + .map_err(|e| CliError::Other(format!("d-tag: {e}")))?; + Ok(EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), body).tags([d])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_ok() -> &'static str { + r#"{"name":"OpenClaw","parallelism":1,"respond_to":"owner-only"}"# + } + + #[test] + fn accepts_minimal_schema() { + let c = validate_managed_agent_content(minimal_ok()).unwrap(); + assert_eq!(c.name, "OpenClaw"); + assert_eq!(c.parallelism, 1); + assert_eq!(c.respond_to, "owner-only"); + } + + #[test] + fn rejects_display_name_only() { + let err = validate_managed_agent_content( + r#"{"display_name":"x","parallelism":1,"respond_to":"anyone"}"#, + ) + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn rejects_bad_respond_to() { + let err = validate_managed_agent_content( + r#"{"name":"x","parallelism":1,"respond_to":"everyone"}"#, + ) + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn rejects_secrets() { + let err = validate_managed_agent_content( + r#"{"name":"x","parallelism":1,"respond_to":"anyone","private_key_nsec":"nsec1x"}"#, + ) + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn allowlist_requires_entries() { + let err = validate_managed_agent_content( + r#"{"name":"x","parallelism":1,"respond_to":"allowlist"}"#, + ) + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_))); + } + + #[test] + fn builds_event_with_d_tag() { + let agent = "a".repeat(64); + let c = validate_managed_agent_content(minimal_ok()).unwrap(); + let b = build_managed_agent_event(&agent, &c).unwrap(); + // Builder existence is enough; full sign needs keys in integration. + let _ = b; + } +} diff --git a/docs/cli-external-agents.md b/docs/cli-external-agents.md new file mode 100644 index 0000000000..28c1e49e3c --- /dev/null +++ b/docs/cli-external-agents.md @@ -0,0 +1,106 @@ +# External agents (CLI) — OpenClaw & non-ACP bridges + +Issue: [#2663](https://github.com/block/buzz/issues/2663) + +This note documents the **headless CLI path** so an external agent (OpenClaw, custom +daemon, etc.) can participate in realtime Buzz without Desktop draft forms or ACP. + +## Identity — `buzz users me` + +```bash +export BUZZ_PRIVATE_KEY=nsec1… # or 64-hex +buzz users me +# {"pubkey":"<64hex>","npub":"npub1…"} +``` + +No relay round-trip. Use the hex `pubkey` to skip self-messages and set `#p` filters. + +Sibling PR hardens this as a thin slice (#2933); it is also included on this branch +so E2E docs stand alone. + +## Realtime receive — `buzz listen` (Option B) + +Persistent WebSocket (NIP-42 AUTH, optional `BUZZ_AUTH_TAG`) streaming **newline-delimited +JSON** events to stdout. + +```bash +export BUZZ_RELAY_URL=https://your.relay +export BUZZ_PRIVATE_KEY=… + +ME=$(buzz users me | jq -r .pubkey) +CHANNEL=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + +buzz listen --channel "$CHANNEL" --mentions-of-me | while IFS= read -r line; do + pk=$(jq -r .pubkey <<<"$line") + [[ "$pk" == "$ME" ]] && continue + # optional: require swagger p-tag mention + # jq -e --arg me "$ME" '.tags[]? | select(.[0]=="p" and .[1]==$me)' <<<"$line" >/dev/null || continue + content=$(jq -r .content <<<"$line") + id=$(jq -r .id <<<"$line") + # hand to OpenClaw / your brain, then: + # buzz messages send --channel "$CHANNEL" --content "$reply" --reply-to "$id" +done +``` + +| Flag | Meaning | +|------|---------| +| `--channel UUID` | Repeatable. REQ filter `#h` = channel | +| `--mentions-of-me` | REQ filter `#p` = CLI pubkey (optionally AND `#h` when channels set) | +| `--kinds a,b,…` | Override default kinds (same as `messages get`) | +| `--webhook URL` | Option A (optional): POST each event JSON body | +| `--no-reconnect` | Exit on drop instead of exponential backoff | + +Graceful stop: Ctrl-C closes the subscription. + +## Poll path field gap — `messages get --format compact` + +Compact now **retains `pubkey` and `tags`** (plus `id`, `content`, `created_at`) so +agents can skip self and detect `p` tags without full JSON. + +## Directory registration — `buzz agents publish` (kind:30177) + +Owner-signed NIP-33 parameterized replaceable event. **d-tag = agent pubkey.** +The relay does **not** schema-validate content; the CLI does, so bad bodies never +render silently empty in the desktop directory. + +### Required content fields + +| Field | Type | Notes | +|-------|------|--------| +| `name` | string | **Not** `display_name` | +| `parallelism` | u32 | 1–1024 | +| `respond_to` | string | kebab-case: `owner-only` \| `allowlist` \| `anyone` | + +### Optional / conditional + +| Field | Notes | +|-------|--------| +| `respond_to_allowlist` | string[] of 64-hex pubkeys; **required non-empty** when `respond_to` is `allowlist` | +| `system_prompt`, `model`, `provider` | Definition-less instances | +| `persona_id`, `persona_source_version` | Definition-linked / drift | + +### Forbidden on the wire + +`private_key_nsec`, `auth_tag`, `env_vars`, `backend`, and other secrets — rejected +client-side if present in the JSON blob. + +```bash +# Sign as the *owner* private key: +buzz agents publish --agent-pubkey "$AGENT_HEX" --content '{"name":"OpenClaw","parallelism":1,"respond_to":"owner-only"}' +``` + +## Residuals (#2663 gap #5) + +@-mention **selector** eligibility for pure external agents (membership + kind:0 ⇒ +mentionable in Desktop) may still require Desktop work coordinated with open mention +PRs (#2603 family). Relay membership + headless 30177 + realtime listen are the +primary product path shipped here. Option C (generic ACP `--agent-command`) is owned +by BYOH harness work (e.g. #2773) — not duplicated in this PR. + +## Env summary + +| Var | Role | +|-----|------| +| `BUZZ_PRIVATE_KEY` | Identity (required for relay cmds) | +| `BUZZ_RELAY_URL` | HTTP(S) base; listens converts to ws/wss | +| `BUZZ_AUTH_TAG` | Optional NIP-OA JSON for agent-attested identity |