mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user