diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 9f41fbf751..f4558683c9 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 604dbd3a70..74a2ead843 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::*;