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 <noreply@anthropic.com>
Signed-off-by: Joel Robotham <jrobotham@squareup.com>
This commit is contained in:
Joel Robotham
2026-08-27 10:29:04 +10:00
parent 6de8baef0b
commit 1f064cb0ad
2 changed files with 203 additions and 1 deletions

View File

@@ -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<i64>,
since: Option<i64>,
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<Vec<PubkeyHex>, 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

View File

@@ -92,6 +92,22 @@ struct Cli {
#[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)]
auth_tag: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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::*;