diff --git a/crates/buzz-sdk/src/broker/actions.rs b/crates/buzz-sdk/src/broker/actions.rs new file mode 100644 index 0000000000..ee51a3a860 --- /dev/null +++ b/crates/buzz-sdk/src/broker/actions.rs @@ -0,0 +1,955 @@ +//! Broker actions — the closed set of operations an agent may ask a host to perform. +//! +//! An action is the unit of policy. `message.post` and `agents.delete` are +//! separate names so a host can permit one without permitting the other, and so +//! a later policy layer has something per-operation to attach to. There is +//! deliberately no `sign` action and no `publish` action: an interface that can +//! sign arbitrary bytes is a signing oracle, and a host holding it could not +//! reason about what it authorized. +//! +//! Two consequences of that choice are visible in the types below. Every args +//! type is `deny_unknown_fields`, so a credential or environment map smuggled +//! into a payload fails to deserialize instead of reaching an executor. Every +//! outcome type can structurally hold only public identifiers — no field of any +//! outcome can transport key material. + +use serde::{Deserialize, Serialize}; + +use crate::SdkError; +use buzz_core::engram::validate_slug; + +/// Maximum characters in a display name or agent name. +pub const MAX_NAME_CHARS: usize = 120; + +/// Maximum characters in a system prompt. +pub const MAX_PROMPT_CHARS: usize = 20_000; + +/// Maximum characters in a short scalar field (runtime, provider, model). +pub const MAX_SCALAR_CHARS: usize = 300; + +/// Maximum characters in a profile `about` blurb. +pub const MAX_ABOUT_CHARS: usize = 2_000; + +/// Maximum bytes of message content, matching the SDK's channel-message cap. +pub const MAX_CONTENT_BYTES: usize = 64 * 1024; + +/// Maximum characters in a reaction payload (emoji or `:shortcode:`). +pub const MAX_EMOJI_CHARS: usize = 66; + +/// Maximum mentions attachable to one message. +pub const MAX_MENTIONS: usize = 50; + +/// Maximum events a single read may return. +pub const MAX_PAGE_LIMIT: u32 = 500; + +/// Inbound author gate modes a requester may ask for. +/// +/// `allowlist` is deliberately absent: it needs a pubkey list this request +/// shape does not carry, and a mode without its list would mint an agent +/// nobody can talk to. +pub const RESPOND_TO_MODES: [&str; 2] = ["owner-only", "anyone"]; + +/// A public key in lowercase hex — the only identity this contract has. +/// +/// #6467 asks for identity to be separable from signing. This type is that +/// separation made structural: it holds 64 hex characters of public key and has +/// no counterpart in this module for the corresponding secret. Nothing here can +/// name, hold, or ask for one. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct PubkeyHex(String); + +impl PubkeyHex { + /// Parse a 64-character hex public key, normalizing to lowercase. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] unless `value` is exactly 64 hex + /// characters. + pub fn parse(value: impl AsRef) -> Result { + let value = value.as_ref().trim(); + if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput( + "pubkey must be 64 hex characters".into(), + )); + } + Ok(Self(value.to_ascii_lowercase())) + } + + /// The hex representation. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for PubkeyHex { + type Error = SdkError; + + fn try_from(value: String) -> Result { + Self::parse(value) + } +} + +impl From for String { + fn from(value: PubkeyHex) -> Self { + value.0 + } +} + +impl std::fmt::Display for PubkeyHex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// An action name the broker can dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Action { + /// Read messages from a channel, thread, or mention feed after a cursor. + ChannelRead, + /// Post a top-level channel message. + MessagePost, + /// Reply to an existing message. + MessageReply, + /// React to an existing message. + ReactionAdd, + /// Publish the requester's own profile metadata. + ProfileSet, + /// Derive the address of one encrypted-memory record. + StorageAddress, + /// Mint a managed agent owned by the requester. + AgentsCreate, + /// Patch a managed agent the requester owns. + AgentsUpdate, + /// Remove a managed agent the requester owns. + AgentsDelete, +} + +impl Action { + /// Every action in this protocol version, in wire-name order. + pub const ALL: [Self; 9] = [ + Self::AgentsCreate, + Self::AgentsDelete, + Self::AgentsUpdate, + Self::ChannelRead, + Self::MessagePost, + Self::MessageReply, + Self::ProfileSet, + Self::ReactionAdd, + Self::StorageAddress, + ]; + + /// Stable wire name. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ChannelRead => "channel.read", + Self::MessagePost => "message.post", + Self::MessageReply => "message.reply", + Self::ReactionAdd => "reaction.add", + Self::ProfileSet => "profile.set", + Self::StorageAddress => "storage.address", + Self::AgentsCreate => "agents.create", + Self::AgentsUpdate => "agents.update", + Self::AgentsDelete => "agents.delete", + } + } + + /// The action contract version this build implements. + #[must_use] + pub fn current_version(self) -> u16 { + 1 + } + + /// Whether a host may refuse this action without harming the agent. + /// + /// #6467 requires non-essential signed housekeeping to be skippable, so + /// that an agent can still run where it is unavailable. For a best-effort + /// action, [`super::BrokerErrorCode::Unsupported`] is a normal answer and a + /// caller must carry on. For every other action, `Unsupported` means the + /// agent cannot do its job on this host and should say so out loud. + #[must_use] + pub fn is_best_effort(self) -> bool { + matches!(self, Self::ReactionAdd) + } + + /// Resolve a wire name. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for an unknown action name. + pub fn parse(name: &str) -> Result { + Self::ALL + .into_iter() + .find(|action| action.as_str() == name) + .ok_or_else(|| SdkError::InvalidInput(format!("unknown broker action \"{name}\""))) + } +} + +// ── Read arguments ────────────────────────────────────────────────────────── + +/// Arguments for `channel.read` — the one read action. +/// +/// Reads are actions like any other, because #6467 requires a host with no +/// relay route of its own to be able to serve them. +/// +/// One action covers three scopes, because they differ only by filter and a +/// separate name per scope would be a separate policy decision for the same +/// underlying permission — *may this agent see this channel*. `rootEventId` +/// narrows to one thread; `mentionsOnly` narrows to messages that mention the +/// requester, which is the wake path. +/// +/// `since` is an inclusive lower bound in Unix seconds, matching the relay's +/// filter granularity. A caller must de-duplicate by event id: second-precision +/// cursors can straddle events, so consecutive pages may overlap. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChannelReadArgs { + /// Channel to read. + pub channel_id: String, + /// Narrow to one thread by its root event. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_event_id: Option, + /// Narrow to messages mentioning the requester. + /// + /// The requester is never named. The host answers for the identity it + /// authenticated; a body that could ask "what mentions *someone else*" + /// would be a body that picks its own subject. + #[serde(default, skip_serializing_if = "is_false")] + pub mentions_only: bool, + /// Inclusive lower bound, Unix seconds. Absent = host's default window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since: Option, + /// Maximum events to return, capped at [`MAX_PAGE_LIMIT`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl ChannelReadArgs { + /// Read a whole channel. + #[must_use] + pub fn channel(channel_id: impl Into) -> Self { + Self { + channel_id: channel_id.into(), + root_event_id: None, + mentions_only: false, + since: None, + limit: None, + } + } + + /// Narrow this read to one thread. + #[must_use] + pub fn in_thread(mut self, root_event_id: impl Into) -> Self { + self.root_event_id = Some(root_event_id.into()); + self + } + + /// Narrow this read to messages mentioning the requester. + #[must_use] + pub fn mentions_only(mut self) -> Self { + self.mentions_only = true; + self + } + + /// Start this read at `since` (inclusive, Unix seconds). + #[must_use] + pub fn since(mut self, since: u64) -> Self { + self.since = Some(since); + self + } + + /// Cap this read at `limit` events. + #[must_use] + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a + /// malformed root event id, or an out-of-range limit. + pub fn validated(&self) -> Result { + Ok(Self { + channel_id: channel(&self.channel_id)?, + root_event_id: self + .root_event_id + .as_deref() + .map(|id| event_id(id, "rootEventId")) + .transpose()?, + mentions_only: self.mentions_only, + since: self.since, + limit: limit(self.limit)?, + }) + } +} + +// ── Write arguments ───────────────────────────────────────────────────────── + +/// Arguments for `message.post`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MessagePostArgs { + /// Channel to post in. + pub channel_id: String, + /// Message body. + pub content: String, + /// Pubkeys to notify. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mentions: Vec, +} + +impl MessagePostArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed channel UUID or empty + /// content, [`SdkError::ContentTooLarge`] for oversized content, and + /// [`SdkError::TooManyMentions`] past [`MAX_MENTIONS`]. + pub fn validated(&self) -> Result { + Ok(Self { + channel_id: channel(&self.channel_id)?, + content: content(&self.content)?, + mentions: mentions(&self.mentions)?, + }) + } +} + +/// Arguments for `message.reply`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MessageReplyArgs { + /// Channel containing the parent. + pub channel_id: String, + /// Event being replied to. + pub reply_to_event_id: String, + /// Reply body. + pub content: String, + /// Pubkeys to notify. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mentions: Vec, +} + +impl MessageReplyArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a + /// malformed event id, or empty content; [`SdkError::ContentTooLarge`] for + /// oversized content; [`SdkError::TooManyMentions`] past [`MAX_MENTIONS`]. + pub fn validated(&self) -> Result { + Ok(Self { + channel_id: channel(&self.channel_id)?, + reply_to_event_id: event_id(&self.reply_to_event_id, "replyToEventId")?, + content: content(&self.content)?, + mentions: mentions(&self.mentions)?, + }) + } +} + +/// Arguments for `reaction.add`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReactionAddArgs { + /// Channel containing the target. + pub channel_id: String, + /// Event being reacted to. + pub target_event_id: String, + /// Reaction payload — an emoji or a `:shortcode:`. + pub reaction: String, +} + +impl ReactionAddArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a + /// malformed event id, or an empty reaction, and [`SdkError::EmojiTooLong`] + /// past [`MAX_EMOJI_CHARS`]. + pub fn validated(&self) -> Result { + let reaction = self.reaction.trim(); + if reaction.is_empty() { + return Err(SdkError::InvalidInput("reaction must not be empty".into())); + } + if reaction.chars().count() > MAX_EMOJI_CHARS { + return Err(SdkError::EmojiTooLong); + } + Ok(Self { + channel_id: channel(&self.channel_id)?, + target_event_id: event_id(&self.target_event_id, "targetEventId")?, + reaction: reaction.to_owned(), + }) + } +} + +/// Arguments for `profile.set`. +/// +/// Only the requester's own profile is addressable, so there is no subject +/// field. Absent fields are left as they are; the host does not clear them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProfileSetArgs { + /// Replacement display name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Replacement bio. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, + /// Replacement avatar URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, +} + +impl ProfileSetArgs { + /// Validate and normalize, requiring at least one field to change. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for an over-long field or a request + /// that changes nothing. + pub fn validated(&self) -> Result { + let normalized = Self { + display_name: optional(self.display_name.as_ref(), "display name", MAX_NAME_CHARS)?, + about: optional(self.about.as_ref(), "about", MAX_ABOUT_CHARS)?, + picture: optional(self.picture.as_ref(), "picture", MAX_SCALAR_CHARS)?, + }; + if normalized.display_name.is_none() + && normalized.about.is_none() + && normalized.picture.is_none() + { + return Err(SdkError::InvalidInput( + "include at least one profile field to set".into(), + )); + } + Ok(normalized) + } +} + +/// Arguments for `storage.address`. +/// +/// Encrypted-memory records are addressed by a tag derived from a conversation +/// key, so deriving one needs the secret this contract exists to avoid holding. +/// #6467 lists that derivation among the operations to route through the +/// interface, which is why it is an action rather than a local computation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StorageAddressArgs { + /// Memory slug — `core` or `mem/…`, per NIP-AE. + pub slug: String, +} + +impl StorageAddressArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] when the slug fails the NIP-AE + /// grammar. + pub fn validated(&self) -> Result { + let slug = required(&self.slug, "slug", 255)?; + validate_slug(&slug).map_err(|e| SdkError::InvalidInput(e.to_string()))?; + Ok(Self { slug }) + } +} + +// ── Agent arguments ───────────────────────────────────────────────────────── + +/// Which agent an update or delete targets. +/// +/// Exactly one selector, because a request naming the target twice is ambiguous +/// and the host would have to guess which wins. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentTarget { + /// Target by agent pubkey. + Pubkey(PubkeyHex), + /// Target by the agent's current name. + Name(String), +} + +impl AgentTarget { + /// Validate and normalize the selector. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for an empty or over-long name. + pub fn validated(&self) -> Result { + match self { + Self::Pubkey(pubkey) => Ok(Self::Pubkey(PubkeyHex::parse(pubkey.as_str())?)), + Self::Name(name) => Ok(Self::Name(required(name, "agent name", MAX_NAME_CHARS)?)), + } + } +} + +/// Arguments for `agents.create`. +/// +/// There is no owner field. The owner of a created agent is whoever the host +/// authenticated for this request — an agent creating an agent owns it, and the +/// chain of ownership ends at a human. A request that could name its own +/// authority would let any caller mint agents under someone else. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentsCreateArgs { + /// Channel the new agent is attached to. + pub channel_id: String, + /// Name for the new agent. + pub display_name: String, + /// Instructions the new agent runs with. + pub system_prompt: String, + /// Preferred harness id; the host refuses a runtime it cannot resolve. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + /// Inference provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Model identifier, interpreted relative to the runtime. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Inbound author gate mode; absent = the host's owner-only default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, +} + +impl AgentsCreateArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, an + /// empty or over-long name or prompt, or an unsupported respond-to mode. + pub fn validated(&self) -> Result { + Ok(Self { + channel_id: channel(&self.channel_id)?, + display_name: required(&self.display_name, "display name", MAX_NAME_CHARS)?, + system_prompt: required(&self.system_prompt, "system prompt", MAX_PROMPT_CHARS)?, + runtime: optional(self.runtime.as_ref(), "runtime", MAX_SCALAR_CHARS)?, + provider: optional(self.provider.as_ref(), "provider", MAX_SCALAR_CHARS)?, + model: optional(self.model.as_ref(), "model", MAX_SCALAR_CHARS)?, + respond_to: respond_to(self.respond_to.as_ref())?, + }) + } +} + +/// Arguments for `agents.update`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentsUpdateArgs { + /// Which agent to patch. + pub target: AgentTarget, + /// Rename the agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Replacement instructions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Harness id to pin. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + /// Inference provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Model identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Inbound author gate mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, +} + +impl AgentsUpdateArgs { + /// Validate and normalize, requiring at least one field to change. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed target, an over-long + /// field, an unsupported respond-to mode, or a request that changes nothing. + pub fn validated(&self) -> Result { + let normalized = Self { + target: self.target.validated()?, + display_name: optional(self.display_name.as_ref(), "display name", MAX_NAME_CHARS)?, + system_prompt: optional( + self.system_prompt.as_ref(), + "system prompt", + MAX_PROMPT_CHARS, + )?, + runtime: optional(self.runtime.as_ref(), "runtime", MAX_SCALAR_CHARS)?, + provider: optional(self.provider.as_ref(), "provider", MAX_SCALAR_CHARS)?, + model: optional(self.model.as_ref(), "model", MAX_SCALAR_CHARS)?, + respond_to: respond_to(self.respond_to.as_ref())?, + }; + let unchanged = normalized.display_name.is_none() + && normalized.system_prompt.is_none() + && normalized.runtime.is_none() + && normalized.provider.is_none() + && normalized.model.is_none() + && normalized.respond_to.is_none(); + if unchanged { + return Err(SdkError::InvalidInput( + "include at least one field to update".into(), + )); + } + Ok(normalized) + } +} + +/// Arguments for `agents.delete`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentsDeleteArgs { + /// Which agent to remove. + pub target: AgentTarget, +} + +impl AgentsDeleteArgs { + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed target selector. + pub fn validated(&self) -> Result { + Ok(Self { + target: self.target.validated()?, + }) + } +} + +// ── Action union ──────────────────────────────────────────────────────────── + +/// An action name paired with its strictly typed arguments. +/// +/// Flattened into [`super::BrokerRequest`], so the wire form is +/// `{ "action": "message.post", "args": { … } }` and an args shape can never be +/// paired with the wrong action name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "action", content = "args")] +pub enum ActionArgs { + /// Read a channel, thread, or mention feed. + #[serde(rename = "channel.read")] + ChannelRead(ChannelReadArgs), + /// Post a message. + #[serde(rename = "message.post")] + MessagePost(MessagePostArgs), + /// Reply to a message. + #[serde(rename = "message.reply")] + MessageReply(MessageReplyArgs), + /// React to a message. + #[serde(rename = "reaction.add")] + ReactionAdd(ReactionAddArgs), + /// Set the requester's profile. + #[serde(rename = "profile.set")] + ProfileSet(ProfileSetArgs), + /// Derive an encrypted-memory address. + #[serde(rename = "storage.address")] + StorageAddress(StorageAddressArgs), + /// Mint a managed agent. + #[serde(rename = "agents.create")] + AgentsCreate(AgentsCreateArgs), + /// Patch a managed agent. + #[serde(rename = "agents.update")] + AgentsUpdate(AgentsUpdateArgs), + /// Remove a managed agent. + #[serde(rename = "agents.delete")] + AgentsDelete(AgentsDeleteArgs), +} + +impl ActionArgs { + /// The action these args belong to. + #[must_use] + pub fn action(&self) -> Action { + match self { + Self::ChannelRead(_) => Action::ChannelRead, + Self::MessagePost(_) => Action::MessagePost, + Self::MessageReply(_) => Action::MessageReply, + Self::ReactionAdd(_) => Action::ReactionAdd, + Self::ProfileSet(_) => Action::ProfileSet, + Self::StorageAddress(_) => Action::StorageAddress, + Self::AgentsCreate(_) => Action::AgentsCreate, + Self::AgentsUpdate(_) => Action::AgentsUpdate, + Self::AgentsDelete(_) => Action::AgentsDelete, + } + } + + /// Validate the arguments in place. + /// + /// # Errors + /// + /// Propagates the per-action validation error. + pub fn validate(&self) -> Result<(), SdkError> { + self.validated().map(|_| ()) + } + + /// Return a normalized copy with every field validated. + /// + /// # Errors + /// + /// Propagates the per-action validation error. + pub fn validated(&self) -> Result { + Ok(match self { + Self::ChannelRead(args) => Self::ChannelRead(args.validated()?), + Self::MessagePost(args) => Self::MessagePost(args.validated()?), + Self::MessageReply(args) => Self::MessageReply(args.validated()?), + Self::ReactionAdd(args) => Self::ReactionAdd(args.validated()?), + Self::ProfileSet(args) => Self::ProfileSet(args.validated()?), + Self::StorageAddress(args) => Self::StorageAddress(args.validated()?), + Self::AgentsCreate(args) => Self::AgentsCreate(args.validated()?), + Self::AgentsUpdate(args) => Self::AgentsUpdate(args.validated()?), + Self::AgentsDelete(args) => Self::AgentsDelete(args.validated()?), + }) + } +} + +// ── Outcomes ──────────────────────────────────────────────────────────────── + +/// One message returned by a read. +/// +/// A projection, not a Nostr event: no signature, no tag array. A caller that +/// needs to verify signatures needs the relay, which is exactly what a +/// broker-only deployment does not have. Trust here is trust in the host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrokerMessage { + /// Event id (hex). + pub event_id: String, + /// Author's pubkey. + pub author_pubkey: PubkeyHex, + /// Event kind. + pub kind: u32, + /// Creation time, Unix seconds. + pub created_at: u64, + /// Message body. + pub content: String, + /// Thread root, when this message is threaded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_event_id: Option, + /// Immediate parent, when this message is a reply. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_event_id: Option, + /// Pubkeys this message mentions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mentions: Vec, +} + +/// Outcome of any read action. +/// +/// `nextCursor` is the value to pass as `since` on the following call. It is +/// absent when the host has nothing further, which is how a caller learns to +/// stop rather than by comparing lengths against a limit it may not have set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MessagePage { + /// Messages in ascending `createdAt` order. + pub messages: Vec, + /// Cursor for the next page, if more may exist. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// Outcome of an action that published one event. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EventPublished { + /// The published event's id (hex). + pub event_id: String, + /// The published event's kind. + pub kind: u32, + /// Creation time the host stamped, Unix seconds. + pub created_at: u64, +} + +/// Outcome of `storage.address`. +/// +/// Addressing material only. A `d` tag is a keyed hash of the slug, so it +/// identifies a record without revealing the slug or the key that derived it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StorageAddress { + /// Author the record is addressed under. + pub author_pubkey: PubkeyHex, + /// Event kind holding the record. + pub kind: u32, + /// Derived `d` tag (64 hex characters). + pub d_tag: String, +} + +/// Outcome of a successful `agents.create`. +/// +/// Carries the new agent's **public** key only. There is no field for the +/// minted secret: it stays on the host, and this type could not transport it +/// even if a handler tried. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentsCreateOutcome { + /// The new agent's pubkey. + pub agent_pubkey: PubkeyHex, + /// The new agent's name as stored. + pub display_name: String, + /// Channel the agent was attached to. + pub channel_id: String, +} + +/// Outcome of a successful `agents.update`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentsUpdateOutcome { + /// The patched agent's pubkey. + pub agent_pubkey: PubkeyHex, + /// The agent's name after the update. + pub display_name: String, + /// Names of the fields the host actually changed, sorted. + pub updated_fields: Vec, +} + +/// Outcome of a successful `agents.delete`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentsDeleteOutcome { + /// The removed agent's pubkey. + pub agent_pubkey: PubkeyHex, + /// The removed agent's name. + pub display_name: String, +} + +/// An action-specific success payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "action", content = "outcome")] +pub enum ActionOutcome { + /// `channel.read` succeeded. + #[serde(rename = "channel.read")] + ChannelRead(MessagePage), + /// `message.post` succeeded. + #[serde(rename = "message.post")] + MessagePost(EventPublished), + /// `message.reply` succeeded. + #[serde(rename = "message.reply")] + MessageReply(EventPublished), + /// `reaction.add` succeeded. + #[serde(rename = "reaction.add")] + ReactionAdd(EventPublished), + /// `profile.set` succeeded. + #[serde(rename = "profile.set")] + ProfileSet(EventPublished), + /// `storage.address` succeeded. + #[serde(rename = "storage.address")] + StorageAddress(StorageAddress), + /// `agents.create` succeeded. + #[serde(rename = "agents.create")] + AgentsCreate(AgentsCreateOutcome), + /// `agents.update` succeeded. + #[serde(rename = "agents.update")] + AgentsUpdate(AgentsUpdateOutcome), + /// `agents.delete` succeeded. + #[serde(rename = "agents.delete")] + AgentsDelete(AgentsDeleteOutcome), +} + +impl ActionOutcome { + /// The action that produced this outcome. + #[must_use] + pub fn action(&self) -> Action { + match self { + Self::ChannelRead(_) => Action::ChannelRead, + Self::MessagePost(_) => Action::MessagePost, + Self::MessageReply(_) => Action::MessageReply, + Self::ReactionAdd(_) => Action::ReactionAdd, + Self::ProfileSet(_) => Action::ProfileSet, + Self::StorageAddress(_) => Action::StorageAddress, + Self::AgentsCreate(_) => Action::AgentsCreate, + Self::AgentsUpdate(_) => Action::AgentsUpdate, + Self::AgentsDelete(_) => Action::AgentsDelete, + } + } +} + +fn is_false(value: &bool) -> bool { + !*value +} + +// ── Shared validators ─────────────────────────────────────────────────────── + +fn required(value: &str, label: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(SdkError::InvalidInput(format!("{label} must not be empty"))); + } + if value.chars().count() > max { + return Err(SdkError::InvalidInput(format!( + "{label} is too long (max {max} characters)" + ))); + } + Ok(value.to_owned()) +} + +fn optional(value: Option<&String>, label: &str, max: usize) -> Result, SdkError> { + value.map(|value| required(value, label, max)).transpose() +} + +fn channel(value: &str) -> Result { + let value = required(value, "channel", 128)?; + uuid::Uuid::parse_str(&value) + .map_err(|_| SdkError::InvalidInput(format!("invalid channel UUID: {value}")))?; + Ok(value) +} + +fn event_id(value: &str, label: &str) -> Result { + let value = required(value, label, 64)?; + if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "{label} must be 64 hex characters" + ))); + } + Ok(value.to_ascii_lowercase()) +} + +fn content(value: &str) -> Result { + if value.trim().is_empty() { + return Err(SdkError::InvalidInput("content must not be empty".into())); + } + if value.len() > MAX_CONTENT_BYTES { + return Err(SdkError::ContentTooLarge { + max: MAX_CONTENT_BYTES, + got: value.len(), + }); + } + Ok(value.to_owned()) +} + +fn mentions(values: &[PubkeyHex]) -> Result, SdkError> { + if values.len() > MAX_MENTIONS { + return Err(SdkError::TooManyMentions); + } + values + .iter() + .map(|pubkey| PubkeyHex::parse(pubkey.as_str())) + .collect() +} + +fn limit(value: Option) -> Result, SdkError> { + match value { + None => Ok(None), + Some(0) => Err(SdkError::InvalidInput("limit must be at least 1".into())), + Some(limit) if limit > MAX_PAGE_LIMIT => Err(SdkError::InvalidInput(format!( + "limit exceeds {MAX_PAGE_LIMIT} (got {limit})" + ))), + Some(limit) => Ok(Some(limit)), + } +} + +fn respond_to(value: Option<&String>) -> Result, SdkError> { + let value = optional(value, "respond-to", MAX_SCALAR_CHARS)?; + if let Some(mode) = value.as_deref() { + if !RESPOND_TO_MODES.contains(&mode) { + return Err(SdkError::InvalidInput(format!( + "respond-to must be one of {}", + RESPOND_TO_MODES.join(", ") + ))); + } + } + Ok(value) +} diff --git a/crates/buzz-sdk/src/broker/client.rs b/crates/buzz-sdk/src/broker/client.rs new file mode 100644 index 0000000000..f161e4d518 --- /dev/null +++ b/crates/buzz-sdk/src/broker/client.rs @@ -0,0 +1,96 @@ +//! Client trait and HTTP binding for the broker contract. +//! +//! # HTTP binding +//! +//! ```text +//! POST /v1/action +//! Authorization: Bearer +//! Content-Type: application/json +//! +//! +//! ``` +//! +//! The response body is a [`BrokerResponse`] as JSON. Every terminal +//! disposition — including [`super::BrokerResult::Failed`] — is a well-formed +//! envelope, so a host that has an answer returns `200` and puts the verdict in +//! `status`. HTTP status codes carry no information the envelope does not, and a +//! client must read the envelope rather than the status line. +//! +//! A non-2xx response is only meaningful when there is *no* envelope: the +//! credential was rejected before dispatch, the path does not exist, or an +//! intermediary answered instead of the host. Those surface as +//! [`BrokerTransportError`], which is not a `Failed` result — it means no answer +//! arrived, so the caller learned nothing about side effects and may retry with +//! identical bytes per the [`BrokerRequest`] retry contract. +//! +//! # The credential +//! +//! The credential is **opaque to this contract**. It is a bearer token the agent +//! received at startup and can only replay; it is not a key, not a signature, +//! and not derived from anything the agent holds. +//! +//! Binding a credential to a specific (agent, conversation) pair — so that a +//! leaked token cannot act as a different agent or outside the conversation it +//! was issued for — **is the host's concern**. This contract documents the +//! expectation and cannot enforce it: the request body carries no requester and +//! no scope, precisely so the host's binding is the only thing that decides +//! authority. A scope field here would be a claim the host must ignore. +//! +//! Since the credential is the whole of the agent's authority, transport +//! confidentiality is not optional. A host serving this over anything but a +//! loopback socket or TLS is publishing its agents' authority. + +use std::future::Future; +use std::pin::Pin; + +use super::{BrokerRequest, BrokerResponse}; + +/// Path of the single broker endpoint. +pub const BROKER_ACTION_PATH: &str = "/v1/action"; + +/// Header carrying the opaque session credential, as `Bearer `. +pub const BROKER_CREDENTIAL_HEADER: &str = "authorization"; + +/// A transport-level failure: no [`BrokerResponse`] was obtained. +/// +/// Distinct from [`super::BrokerResult::Failed`], which is a host's considered +/// verdict. These variants all mean the same thing to a caller — the request's +/// fate is unknown — and differ only in what to tell an operator. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum BrokerTransportError { + /// The host could not be reached, or the connection failed mid-request. + #[error("broker unreachable: {0}")] + Unreachable(String), + /// The credential was rejected before the host produced an envelope. + #[error("broker rejected the session credential")] + CredentialRejected, + /// A response arrived but was not a well-formed [`BrokerResponse`]. + #[error("malformed broker response: {0}")] + MalformedResponse(String), +} + +/// A future returned by [`BrokerClient::execute`]. +/// +/// Spelled as a boxed future rather than `async fn` in the trait because this +/// trait must be object-safe: the harness holds one client and must not know +/// whether it talks to an in-process host or an HTTP one. +pub type BrokerFuture<'a> = + Pin> + Send + 'a>>; + +/// Something that can execute broker requests. +/// +/// One method, because there is one endpoint. Every operation is an +/// [`super::Action`] inside the request, so adding an action never changes this +/// trait and an implementation never needs updating to carry one. +/// +/// Implementations must be usable as `dyn BrokerClient`. +pub trait BrokerClient: Send + Sync { + /// Execute `request` and return the host's terminal response. + /// + /// # Errors + /// + /// Returns [`BrokerTransportError`] when no response could be obtained. + /// A host that answered — even to refuse — returns `Ok` with the verdict in + /// [`BrokerResponse::result`]. + fn execute<'a>(&'a self, request: &'a BrokerRequest) -> BrokerFuture<'a>; +} diff --git a/crates/buzz-sdk/src/broker/mod.rs b/crates/buzz-sdk/src/broker/mod.rs new file mode 100644 index 0000000000..48645de288 --- /dev/null +++ b/crates/buzz-sdk/src/broker/mod.rs @@ -0,0 +1,481 @@ +//! Agent ↔ broker contract — the operations an agent asks a host to perform. +//! +//! This module is a **contract only**. It defines the request envelope, the +//! closed set of [`Action`]s, the result shape, the HTTP binding, and a client +//! trait. It contains no host, no transport, and no signing: the only +//! implementation here is a test double. +//! +//! # Mental model +//! +//! ```text +//! agent → BrokerRequest → (POST /v1/action, bearer credential) → host +//! host: authenticate → authorize → validate → execute → BrokerResponse +//! ``` +//! +//! The agent holds its public key and a session credential. It holds no secret +//! key, has no relay connection of its own, and can reach the relay only by +//! asking. Everything it wants to do — including reading — is an action. +//! +//! # Why an operation enum rather than a "sign this" primitive +//! +//! [#6467](https://github.com/block/buzz/issues/6467) leaves this open. This +//! contract answers it: a closed enum of named business operations. +//! +//! A `sign(bytes)` primitive makes the broker a signing oracle. It can tell +//! *who* is asking but not *what for*, so the only policies it can express are +//! "all" and "none". Named operations invert that: a host can serve +//! `channel.read` while refusing `agents.create`, and a later policy or +//! information-flow layer has a per-operation surface to attach to. The cost is +//! that a new operation needs a new variant — deliberately, since adding one is +//! then a reviewable change to the contract rather than a new use of an existing +//! blank cheque. +//! +//! The same reasoning is why signing, publishing, and credential access are not +//! actions. `message.post` names an intent a host can reason about; +//! `publish(event)` names a mechanism it cannot. +//! +//! # How this satisfies #6467 +//! +//! | #6467 requirement | Where | +//! |---|---| +//! | Identity separable from signing; pubkey-only identity | [`PubkeyHex`] is the only identity type. No secret-key type exists in this module, and no args or outcome field can hold one. | +//! | Secret-dependent operations funnelled through the interface | Event signing is subsumed by the write actions (`message.post`, `message.reply`, `reaction.add`, `profile.set`, `agents.*`) — the agent states intent, the host signs. Encrypted-storage address derivation is [`Action::StorageAddress`]. NIP-42 relay auth, NIP-44 encryption, and NIP-98 request auth are **not** actions: they are mechanisms internal to whoever holds the key, and this contract never asks for them by name. | +//! | Relay auth is a control-flow choice | Nothing here mentions relay authentication. An agent using this contract does not authenticate to a relay, so there is no local auth step to skip. | +//! | Every relay-touching op, reads included, routable | [`Action::ChannelRead`] covers channel, thread, and mention-feed reads. No action assumes the caller can reach a relay. | +//! | Non-essential housekeeping skippable | [`Action::is_best_effort`] marks such actions; a host answers [`BrokerErrorCode::Unsupported`] and the agent carries on. | +//! | No secret leaks to children via env | No args type carries an environment map, and `deny_unknown_fields` means one cannot be smuggled in. Process spawning is a host concern this contract cannot express. | +//! | Lives in the shared client layer | `buzz-sdk`, which the CLI and the harness already depend on. | +//! +//! # Ownership recursion +//! +//! [`Action::AgentsCreate`] has no owner field. The owner of a created agent is +//! whichever identity the host authenticated for the request, so an agent that +//! creates an agent owns it, and following the chain upward always terminates at +//! a human. Bounding the depth of that chain is a host concern: it depends on +//! resources and policy this contract cannot see. A request that could name its +//! own authority would let any caller mint agents under someone else. +//! +//! # Deferred operations +//! +//! `presence.set` and `typing.set` are not in v1. They are housekeeping a host +//! can decline anyway, and the closed enum makes adding them purely additive — +//! a new variant, a new wire name, no change to existing ones. +//! +//! Streaming reads are also deferred. Reads are request/response; waking on a +//! mention is `channel.read` with `mentionsOnly`, polled. +//! +//! # Non-goals +//! +//! - **Hosts.** Authentication, authorization, idempotency storage, execution, +//! and depth caps all live in the host. +//! - **Transports.** [`BrokerClient`] exists so an in-process and an HTTP +//! implementation are interchangeable; neither is here. +//! - **Relay changes.** A host does ordinary relay work as an ordinary client. +//! The relay never learns a broker exists. +//! - **Grants and authorization fields.** There is no `authorization` field. +//! One gets added, as a discriminated object, when a real grant format and +//! verifier exist — not before, so no field looks security-bearing while +//! enforcing nothing. +//! - **Secret-key custody.** How the host holds keys, and whether it refuses to +//! start when a stale local key is present, are host decisions. + +use serde::{Deserialize, Serialize}; + +use crate::SdkError; + +pub mod actions; +pub mod client; + +pub use actions::{ + Action, ActionArgs, ActionOutcome, AgentTarget, AgentsCreateArgs, AgentsCreateOutcome, + AgentsDeleteArgs, AgentsDeleteOutcome, AgentsUpdateArgs, AgentsUpdateOutcome, BrokerMessage, + ChannelReadArgs, EventPublished, MessagePage, MessagePostArgs, MessageReplyArgs, + ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs, +}; +pub use client::{ + BrokerClient, BrokerFuture, BrokerTransportError, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, +}; + +/// Wire `type` discriminator for a broker request payload. +pub const BROKER_REQUEST_TYPE: &str = "broker_request"; + +/// Wire `type` discriminator for a broker response payload. +pub const BROKER_RESULT_TYPE: &str = "broker_result"; + +/// Current broker protocol version. +/// +/// There is no "absent means 1" compatibility rule: the protocol is unshipped, +/// so `protocolVersion` is required and an unknown value is rejected outright. +pub const BROKER_PROTOCOL_VERSION: u16 = 1; + +/// Maximum accepted length of a `requestId`, in bytes. +pub const MAX_REQUEST_ID_LEN: usize = 128; + +/// A request to execute one broker action. +/// +/// # What this envelope deliberately omits +/// +/// There is no requester, owner, or relay field. Those are derived by the host +/// from the authenticated session credential. A body that could name its own +/// subject would let any caller act as anyone. +/// +/// # Retry contract +/// +/// Retrying means **resending the identical serialized request** with the same +/// `requestId`. The host hashes what it receives and compares that digest +/// against the digest recorded under the same idempotency key: +/// +/// - same key, same digest → the recorded outcome is replayed, nothing re-runs +/// - same key, different digest → rejected as a request-ID conflict +/// +/// Callers must therefore not regenerate, re-order, or re-render the payload +/// between attempts. There is no client-computed digest field: idempotency is +/// decided host-side, and a caller-supplied digest would be a claim the host +/// has to recompute anyway. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BrokerRequest { + /// Payload discriminator — must equal [`BROKER_REQUEST_TYPE`]. + pub r#type: String, + /// Protocol version — must equal [`BROKER_PROTOCOL_VERSION`]. + pub protocol_version: u16, + /// Caller-chosen idempotency key, unique per logical operation. + pub request_id: String, + /// Action contract version the caller wrote `args` against. + pub action_version: u16, + /// The action to invoke, with its strictly typed arguments. + #[serde(flatten)] + pub action: ActionArgs, +} + +impl BrokerRequest { + /// Build a request for `action` at the current protocol version. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] if `request_id` is empty, longer than + /// [`MAX_REQUEST_ID_LEN`], or not printable ASCII, or if the action's + /// arguments fail validation. + pub fn new(request_id: impl Into, action: ActionArgs) -> Result { + let request = Self { + r#type: BROKER_REQUEST_TYPE.to_string(), + protocol_version: BROKER_PROTOCOL_VERSION, + request_id: request_id.into(), + action_version: action.action().current_version(), + action, + }; + request.validate()?; + Ok(request) + } + + /// The action this request invokes. + #[must_use] + pub fn action(&self) -> Action { + self.action.action() + } + + /// Validate every field the host must agree on before executing anything. + /// + /// Callers validate before sending; the host revalidates on receipt, + /// because only the host's verdict is authoritative. + /// + /// # Errors + /// + /// Returns [`SdkError`] for a wrong `type`, an unsupported + /// `protocolVersion` or `actionVersion`, a malformed `requestId`, or + /// arguments that fail their own validation. + pub fn validate(&self) -> Result<(), SdkError> { + if self.r#type != BROKER_REQUEST_TYPE { + return Err(SdkError::InvalidInput(format!( + "broker request type must be \"{BROKER_REQUEST_TYPE}\", got \"{}\"", + self.r#type + ))); + } + if self.protocol_version != BROKER_PROTOCOL_VERSION { + return Err(SdkError::InvalidInput(format!( + "unsupported broker protocolVersion {} (expected {BROKER_PROTOCOL_VERSION})", + self.protocol_version + ))); + } + validate_request_id(&self.request_id)?; + let action = self.action(); + if self.action_version != action.current_version() { + return Err(SdkError::InvalidInput(format!( + "unsupported actionVersion {} for {} (expected {})", + self.action_version, + action.as_str(), + action.current_version() + ))); + } + self.action.validate() + } +} + +/// Validate a `requestId`: non-empty, bounded, printable ASCII without spaces. +/// +/// The bound and character set exist because this value becomes part of a +/// durable idempotency key and appears in audit records. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] when the id is empty, exceeds +/// [`MAX_REQUEST_ID_LEN`] bytes, or contains a byte outside `0x21..=0x7e`. +pub fn validate_request_id(request_id: &str) -> Result<(), SdkError> { + if request_id.is_empty() { + return Err(SdkError::InvalidInput("requestId must not be empty".into())); + } + if request_id.len() > MAX_REQUEST_ID_LEN { + return Err(SdkError::InvalidInput(format!( + "requestId exceeds {MAX_REQUEST_ID_LEN} bytes (got {})", + request_id.len() + ))); + } + if let Some(bad) = request_id + .bytes() + .find(|b| !(0x21..=0x7e).contains(b)) + .map(|b| format!("0x{b:02x}")) + { + return Err(SdkError::InvalidInput(format!( + "requestId must be printable ASCII without spaces (found byte {bad})" + ))); + } + Ok(()) +} + +/// Machine-readable broker error code. +/// +/// These name failures the *broker* is responsible for. Failures inside an +/// action arrive as [`BrokerErrorCode::ActionFailed`] with detail in the +/// message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrokerErrorCode { + /// The envelope or action arguments failed validation. + InvalidRequest, + /// The `protocolVersion` is not supported by this host. + UnsupportedProtocolVersion, + /// The action name is unknown to this host. + UnknownAction, + /// The `actionVersion` is not supported for this action. + UnsupportedActionVersion, + /// The host knows this action but does not offer it. + /// + /// For an action where [`Action::is_best_effort`] holds, this is a normal + /// answer and the agent carries on. Otherwise the agent cannot do its job + /// on this host. + Unsupported, + /// The session credential was missing, malformed, or rejected. + Unauthenticated, + /// The requester is authenticated but not permitted this action. + Unauthorized, + /// Reuse of a `requestId` with different request content. + RequestIdConflict, + /// The action ran and reported a domain failure. + ActionFailed, + /// The host could not determine whether side effects occurred. + /// + /// Only ever paired with [`BrokerResult::Indeterminate`]. + OutcomeUnknown, + /// An unexpected host-side fault. + Internal, +} + +impl BrokerErrorCode { + /// Stable wire string for this code. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::UnsupportedProtocolVersion => "unsupported_protocol_version", + Self::UnknownAction => "unknown_action", + Self::UnsupportedActionVersion => "unsupported_action_version", + Self::Unsupported => "unsupported", + Self::Unauthenticated => "unauthenticated", + Self::Unauthorized => "unauthorized", + Self::RequestIdConflict => "request_id_conflict", + Self::ActionFailed => "action_failed", + Self::OutcomeUnknown => "outcome_unknown", + Self::Internal => "internal", + } + } +} + +/// A broker error: a machine-readable code plus a human-readable message. +/// +/// Messages are for operators and must never carry secrets — no nsec, no +/// credentials, no decrypted payloads. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BrokerError { + /// Machine-readable failure code. + pub code: BrokerErrorCode, + /// Operator-facing description. Secret-free. + pub message: String, +} + +impl BrokerError { + /// Construct an error from a code and message. + pub fn new(code: BrokerErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + /// An [`BrokerErrorCode::InvalidRequest`] error. + pub fn invalid_request(message: impl Into) -> Self { + Self::new(BrokerErrorCode::InvalidRequest, message) + } + + /// An [`BrokerErrorCode::Unsupported`] error. + pub fn unsupported(message: impl Into) -> Self { + Self::new(BrokerErrorCode::Unsupported, message) + } + + /// An [`BrokerErrorCode::Unauthorized`] error. + pub fn unauthorized(message: impl Into) -> Self { + Self::new(BrokerErrorCode::Unauthorized, message) + } +} + +/// The terminal disposition of a broker request. +/// +/// A discriminated union, so "succeeded with an error" and "failed with an +/// outcome" are unrepresentable rather than merely discouraged. +/// +/// [`Self::Indeterminate`] is distinct from [`Self::Failed`] on purpose: +/// `Failed` promises no side effects took hold, while `Indeterminate` promises +/// nothing at all and demands reconciliation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum BrokerResult { + /// The action completed and produced this outcome. + Succeeded { + /// Action-specific success payload. + #[serde(flatten)] + outcome: ActionOutcome, + }, + /// The action did not complete; no side effects are expected to persist. + Failed { + /// Why it failed. + error: BrokerError, + }, + /// Whether side effects occurred could not be determined. + Indeterminate { + /// What is unknown, and why. + error: BrokerError, + }, +} + +impl BrokerResult { + /// A successful result carrying `outcome`. + #[must_use] + pub fn succeeded(outcome: ActionOutcome) -> Self { + Self::Succeeded { outcome } + } + + /// A failed result carrying `error`. + #[must_use] + pub fn failed(error: BrokerError) -> Self { + Self::Failed { error } + } + + /// An indeterminate result carrying `error`. + #[must_use] + pub fn indeterminate(error: BrokerError) -> Self { + Self::Indeterminate { error } + } + + /// The outcome, when this is a success. + #[must_use] + pub fn outcome(&self) -> Option<&ActionOutcome> { + match self { + Self::Succeeded { outcome } => Some(outcome), + Self::Failed { .. } | Self::Indeterminate { .. } => None, + } + } + + /// The error, for the two non-success variants. + #[must_use] + pub fn error(&self) -> Option<&BrokerError> { + match self { + Self::Succeeded { .. } => None, + Self::Failed { error } | Self::Indeterminate { error } => Some(error), + } + } +} + +/// A broker result addressed back to the requester. +/// +/// `replayed` is **response metadata**: it describes this delivery, not the +/// domain outcome, and is never persisted as part of the stored result. A +/// replayed response is byte-identical in `result` to the original. +/// +/// Note: this struct cannot use `deny_unknown_fields`, because serde does not +/// support combining it with `#[serde(flatten)]`. Strictness is enforced where +/// it guards execution — on [`BrokerRequest`] and each args type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrokerResponse { + /// Payload discriminator — must equal [`BROKER_RESULT_TYPE`]. + pub r#type: String, + /// Protocol version — must equal [`BROKER_PROTOCOL_VERSION`]. + pub protocol_version: u16, + /// Correlates with the originating [`BrokerRequest::request_id`]. + pub request_id: String, + /// The terminal disposition. + #[serde(flatten)] + pub result: BrokerResult, + /// True when this response replays a previously recorded outcome. + #[serde(default, skip_serializing_if = "is_false")] + pub replayed: bool, +} + +fn is_false(value: &bool) -> bool { + !*value +} + +impl BrokerResponse { + /// Build a fresh (non-replayed) response for `request_id`. + pub fn new(request_id: impl Into, result: BrokerResult) -> Self { + Self { + r#type: BROKER_RESULT_TYPE.to_string(), + protocol_version: BROKER_PROTOCOL_VERSION, + request_id: request_id.into(), + result, + replayed: false, + } + } + + /// Mark this response as replaying a recorded outcome. + #[must_use] + pub fn replayed(mut self) -> Self { + self.replayed = true; + self + } + + /// Validate discriminator, version, and request id. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] on a wrong `type`, an unsupported + /// `protocolVersion`, or a malformed `requestId`. + pub fn validate(&self) -> Result<(), SdkError> { + if self.r#type != BROKER_RESULT_TYPE { + return Err(SdkError::InvalidInput(format!( + "broker result type must be \"{BROKER_RESULT_TYPE}\", got \"{}\"", + self.r#type + ))); + } + if self.protocol_version != BROKER_PROTOCOL_VERSION { + return Err(SdkError::InvalidInput(format!( + "unsupported broker protocolVersion {} (expected {BROKER_PROTOCOL_VERSION})", + self.protocol_version + ))); + } + validate_request_id(&self.request_id) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/broker/tests.rs b/crates/buzz-sdk/src/broker/tests.rs new file mode 100644 index 0000000000..a362cd2da3 --- /dev/null +++ b/crates/buzz-sdk/src/broker/tests.rs @@ -0,0 +1,929 @@ +//! Contract tests for the broker envelope, actions, and client trait. + +use super::*; + +const CHANNEL: &str = "b2c38ca8-9ec3-411e-bab5-f9deab34d52e"; +const PUBKEY: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971"; +const EVENT: &str = "78d47c4f36a2d048f45b57a31d964a3ce239f0fc46162c5d7c90db2b5aa52bc6"; + +fn pubkey() -> PubkeyHex { + PubkeyHex::parse(PUBKEY).expect("fixture pubkey is valid hex") +} + +/// One valid `args` value per action, so table-driven tests cannot silently +/// skip an action: [`action_fixtures_cover_every_action`] pins the coverage. +fn action_fixtures() -> Vec { + vec![ + ActionArgs::ChannelRead( + ChannelReadArgs::channel(CHANNEL) + .in_thread(EVENT) + .mentions_only() + .since(1_764_000_000) + .limit(50), + ), + ActionArgs::MessagePost(MessagePostArgs { + channel_id: CHANNEL.into(), + content: "shipping the contract".into(), + mentions: vec![pubkey()], + }), + ActionArgs::MessageReply(MessageReplyArgs { + channel_id: CHANNEL.into(), + reply_to_event_id: EVENT.into(), + content: "agreed".into(), + mentions: vec![], + }), + ActionArgs::ReactionAdd(ReactionAddArgs { + channel_id: CHANNEL.into(), + target_event_id: EVENT.into(), + reaction: "🎉".into(), + }), + ActionArgs::ProfileSet(ProfileSetArgs { + display_name: Some("ss-dev-00".into()), + about: Some("implementation".into()), + picture: None, + }), + ActionArgs::StorageAddress(StorageAddressArgs { + slug: "mem/broker-foundation".into(), + }), + ActionArgs::AgentsCreate(AgentsCreateArgs { + channel_id: CHANNEL.into(), + display_name: "Research helper".into(), + system_prompt: "Find sources.".into(), + runtime: None, + provider: None, + model: None, + respond_to: Some("owner-only".into()), + }), + ActionArgs::AgentsUpdate(AgentsUpdateArgs { + target: AgentTarget::Pubkey(pubkey()), + display_name: Some("Research helper v2".into()), + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: None, + }), + ActionArgs::AgentsDelete(AgentsDeleteArgs { + target: AgentTarget::Name("Research helper".into()), + }), + ] +} + +/// One outcome per action, matching the fixture order above. +fn outcome_fixtures() -> Vec { + let page = MessagePage { + messages: vec![BrokerMessage { + event_id: EVENT.into(), + author_pubkey: pubkey(), + kind: 9, + created_at: 1_764_000_001, + content: "hello".into(), + root_event_id: Some(EVENT.into()), + parent_event_id: None, + mentions: vec![pubkey()], + }], + next_cursor: Some(1_764_000_002), + }; + let published = EventPublished { + event_id: EVENT.into(), + kind: 9, + created_at: 1_764_000_003, + }; + vec![ + ActionOutcome::ChannelRead(page), + ActionOutcome::MessagePost(published.clone()), + ActionOutcome::MessageReply(published.clone()), + ActionOutcome::ReactionAdd(published.clone()), + ActionOutcome::ProfileSet(published), + ActionOutcome::StorageAddress(StorageAddress { + author_pubkey: pubkey(), + kind: 30174, + d_tag: EVENT.into(), + }), + ActionOutcome::AgentsCreate(AgentsCreateOutcome { + agent_pubkey: pubkey(), + display_name: "Research helper".into(), + channel_id: CHANNEL.into(), + }), + ActionOutcome::AgentsUpdate(AgentsUpdateOutcome { + agent_pubkey: pubkey(), + display_name: "Research helper v2".into(), + updated_fields: vec!["displayName".into()], + }), + ActionOutcome::AgentsDelete(AgentsDeleteOutcome { + agent_pubkey: pubkey(), + display_name: "Research helper".into(), + }), + ] +} + +// ── Coverage ──────────────────────────────────────────────────────────────── + +/// The fixture tables are the input to every table-driven test below, so an +/// action added without a fixture would be silently untested. This is the guard. +#[test] +fn action_fixtures_cover_every_action() { + let mut from_args: Vec<&str> = action_fixtures() + .iter() + .map(|args| args.action().as_str()) + .collect(); + let mut from_outcomes: Vec<&str> = outcome_fixtures() + .iter() + .map(|outcome| outcome.action().as_str()) + .collect(); + let mut declared: Vec<&str> = Action::ALL.iter().map(|a| a.as_str()).collect(); + + from_args.sort_unstable(); + from_outcomes.sort_unstable(); + declared.sort_unstable(); + + assert_eq!(from_args, declared, "every action needs an args fixture"); + assert_eq!( + from_outcomes, declared, + "every action needs an outcome fixture" + ); +} + +#[test] +fn action_names_are_unique_and_round_trip_through_parse() { + let mut names: Vec<&str> = Action::ALL.iter().map(|a| a.as_str()).collect(); + let count = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), count, "action wire names must be unique"); + + for action in Action::ALL { + assert_eq!(Action::parse(action.as_str()).unwrap(), action); + } +} + +// ── Envelope round-trip ───────────────────────────────────────────────────── + +#[test] +fn every_action_round_trips_through_a_request_envelope() { + for args in action_fixtures() { + let action = args.action(); + let request = BrokerRequest::new("req-1", args) + .unwrap_or_else(|e| panic!("{} fixture must validate: {e}", action.as_str())); + + let json = serde_json::to_value(&request).expect("request serializes"); + assert_eq!(json["type"], BROKER_REQUEST_TYPE); + assert_eq!(json["protocolVersion"], 1); + assert_eq!(json["requestId"], "req-1"); + assert_eq!(json["actionVersion"], 1); + assert_eq!( + json["action"], + action.as_str(), + "{} must name itself on the wire", + action.as_str() + ); + assert!( + json.get("args").is_some(), + "{} must carry an args object", + action.as_str() + ); + + let parsed: BrokerRequest = serde_json::from_value(json) + .unwrap_or_else(|e| panic!("{} must deserialize: {e}", action.as_str())); + assert_eq!(parsed, request); + parsed.validate().expect("round-tripped request is valid"); + } +} + +#[test] +fn every_outcome_round_trips_through_a_response_envelope() { + for outcome in outcome_fixtures() { + let action = outcome.action(); + let response = BrokerResponse::new("req-1", BrokerResult::succeeded(outcome.clone())); + response.validate().expect("response is valid"); + + let json = serde_json::to_value(&response).expect("response serializes"); + assert_eq!(json["type"], BROKER_RESULT_TYPE); + assert_eq!(json["status"], "succeeded"); + assert_eq!(json["action"], action.as_str()); + assert!(json.get("error").is_none(), "a success carries no error"); + // `replayed` is delivery metadata and stays off the wire when false. + assert!(json.get("replayed").is_none()); + + let parsed: BrokerResponse = serde_json::from_value(json) + .unwrap_or_else(|e| panic!("{} outcome must deserialize: {e}", action.as_str())); + assert_eq!(parsed, response); + assert_eq!(parsed.result.outcome(), Some(&outcome)); + assert!(parsed.result.error().is_none()); + } +} + +/// Args and outcome share the `action` discriminator, so a payload can never +/// pair one action's name with another's shape. +#[test] +fn an_args_shape_cannot_be_paired_with_another_action_name() { + let json = serde_json::json!({ + "type": BROKER_REQUEST_TYPE, + "protocolVersion": 1, + "requestId": "req-1", + "actionVersion": 1, + "action": "agents.delete", + "args": { "channelId": CHANNEL, "content": "not a delete" }, + }); + assert!(serde_json::from_value::(json).is_err()); +} + +// ── Envelope rejection ────────────────────────────────────────────────────── + +#[test] +fn unknown_action_name_is_rejected() { + for unknown in ["channel.write", "agents.exfiltrate", "", "channel.read "] { + assert!( + Action::parse(unknown).is_err(), + "\"{unknown}\" must not parse as an action" + ); + } + + let json = serde_json::json!({ + "type": BROKER_REQUEST_TYPE, + "protocolVersion": 1, + "requestId": "req-1", + "actionVersion": 1, + "action": "agents.exfiltrate", + "args": {}, + }); + assert!(serde_json::from_value::(json).is_err()); +} + +/// Signing and publishing are mechanisms, not actions. An interface that can +/// sign arbitrary bytes is a signing oracle, so these names must not resolve. +#[test] +fn signing_and_credential_access_are_not_actions() { + for forbidden in [ + "sign", + "sign_event", + "publish", + "nip44.encrypt", + "nip44.decrypt", + "nip42.auth", + "nip98.auth", + "keys.export", + "identity.nsec", + "tool.exec", + "agents.manage", + ] { + assert!( + Action::parse(forbidden).is_err(), + "\"{forbidden}\" must not be an action" + ); + } +} + +#[test] +fn unknown_protocol_version_is_rejected() { + let args = ActionArgs::AgentsDelete(AgentsDeleteArgs { + target: AgentTarget::Pubkey(pubkey()), + }); + + for bad in [0_u16, 2, 999] { + let mut request = BrokerRequest::new("req-1", args.clone()).unwrap(); + request.protocol_version = bad; + let error = request.validate().unwrap_err().to_string(); + assert!(error.contains("protocolVersion"), "unexpected: {error}"); + + let mut response = BrokerResponse::new( + "req-1", + BrokerResult::failed(BrokerError::unsupported("no")), + ); + response.protocol_version = bad; + assert!(response.validate().is_err()); + } +} + +#[test] +fn unknown_action_version_is_rejected() { + let mut request = BrokerRequest::new( + "req-1", + ActionArgs::AgentsDelete(AgentsDeleteArgs { + target: AgentTarget::Pubkey(pubkey()), + }), + ) + .unwrap(); + request.action_version = 7; + let error = request.validate().unwrap_err().to_string(); + assert!(error.contains("actionVersion"), "unexpected: {error}"); +} + +#[test] +fn wrong_type_discriminator_is_rejected() { + let mut request = BrokerRequest::new( + "req-1", + ActionArgs::AgentsDelete(AgentsDeleteArgs { + target: AgentTarget::Pubkey(pubkey()), + }), + ) + .unwrap(); + request.r#type = BROKER_RESULT_TYPE.into(); + assert!(request.validate().is_err()); + + let mut response = BrokerResponse::new( + "req-1", + BrokerResult::failed(BrokerError::unsupported("no")), + ); + response.r#type = BROKER_REQUEST_TYPE.into(); + assert!(response.validate().is_err()); +} + +#[test] +fn request_id_must_be_present_bounded_and_printable() { + let args = || { + ActionArgs::AgentsDelete(AgentsDeleteArgs { + target: AgentTarget::Pubkey(pubkey()), + }) + }; + assert!(BrokerRequest::new("", args()).is_err()); + assert!(BrokerRequest::new("a".repeat(MAX_REQUEST_ID_LEN), args()).is_ok()); + assert!(BrokerRequest::new("a".repeat(MAX_REQUEST_ID_LEN + 1), args()).is_err()); + assert!(BrokerRequest::new("has space", args()).is_err()); + assert!(BrokerRequest::new("has\nnewline", args()).is_err()); + assert!(BrokerRequest::new("has\u{7f}del", args()).is_err()); + assert!(BrokerRequest::new("req/1-a.b:c", args()).is_ok()); +} + +/// The envelope must not carry requester, owner, or scope: those are derived by +/// the host from the credential. A body that could name its own subject would +/// let any caller act as anyone. +#[test] +fn request_carries_no_caller_supplied_authority() { + let json = serde_json::to_value( + BrokerRequest::new( + "req-1", + ActionArgs::ChannelRead(ChannelReadArgs::channel(CHANNEL)), + ) + .unwrap(), + ) + .unwrap(); + let object = json.as_object().unwrap(); + + for forbidden in [ + "ownerPubkey", + "owner", + "requesterPubkey", + "requester", + "agentPubkey", + "authorization", + "credential", + "token", + "scope", + "expiry", + "relayUrl", + "requestDigest", + ] { + assert!( + !object.contains_key(forbidden), + "broker request must not carry \"{forbidden}\"" + ); + } + + let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "action", + "actionVersion", + "args", + "protocolVersion", + "requestId", + "type", + ] + ); +} + +/// `agents.create` must not let a request name its own authority: ownership is +/// the authenticated requester, which is why there is no field for it. +#[test] +fn agents_create_has_no_owner_field() { + let json = serde_json::to_value(AgentsCreateArgs { + channel_id: CHANNEL.into(), + display_name: "A".into(), + system_prompt: "B".into(), + runtime: None, + provider: None, + model: None, + respond_to: None, + }) + .unwrap(); + for forbidden in ["owner", "ownerPubkey", "createdBy", "onBehalfOf"] { + assert!(json.get(forbidden).is_none()); + } + + let with_owner = serde_json::json!({ + "channelId": CHANNEL, + "displayName": "A", + "systemPrompt": "B", + "ownerPubkey": PUBKEY, + }); + assert!(serde_json::from_value::(with_owner).is_err()); +} + +// ── The no-secret invariant ───────────────────────────────────────────────── + +/// No type in this contract may name, hold, or accept secret key material. +/// +/// Scanning the source is the load-bearing half of this invariant: a +/// deserialization test can only reject fields someone thought to add, while +/// this fails the moment a secret-key type appears in a signature at all. +#[test] +fn no_secret_key_type_appears_in_the_contract_source() { + // `tests.rs` is excluded: this file necessarily names the forbidden tokens. + let sources = [ + ("mod.rs", include_str!("mod.rs")), + ("actions.rs", include_str!("actions.rs")), + ("client.rs", include_str!("client.rs")), + ]; + // Substrings, so `Option` and `nostr::Keys` are caught too. + let forbidden = [ + "SecretKey", + "nostr::Keys", + "Keys::", + "secret_key", + "seckey", + "private_key", + "nsec1", + "sign_event", + "ConversationKey", + ]; + + for (name, source) in sources { + for token in forbidden { + for (index, line) in source.lines().enumerate() { + // Doc comments may discuss what is absent and why. + let code = line.trim_start(); + if code.starts_with("//") { + continue; + } + assert!( + !code.contains(token), + "{name}:{} names \"{token}\" in code: {code}", + index + 1 + ); + } + } + } +} + +/// A secret smuggled into args must fail to deserialize rather than reach an +/// executor. +#[test] +fn secret_bearing_args_fail_to_deserialize() { + let smuggled = [ + serde_json::json!({ + "channelId": CHANNEL, + "displayName": "Sneaky", + "systemPrompt": "hi", + "envVars": { "ANTHROPIC_API_KEY": "sk-live" }, + }), + serde_json::json!({ + "channelId": CHANNEL, + "displayName": "Sneaky", + "systemPrompt": "hi", + "secretKey": "nsec1deadbeef", + }), + serde_json::json!({ + "channelId": CHANNEL, + "displayName": "Sneaky", + "systemPrompt": "hi", + "privateKey": "hexsecret", + }), + ]; + for args in smuggled { + assert!( + serde_json::from_value::(args.clone()).is_err(), + "must reject: {args}" + ); + } +} + +/// An outcome must be structurally unable to carry secret material, including +/// the freshly minted key of an agent it just created. +#[test] +fn outcomes_cannot_carry_secrets() { + for extra in [ + "privateKeyNsec", + "nsec", + "secretKey", + "seckey", + "credential", + ] { + let mut outcome = serde_json::json!({ + "agentPubkey": PUBKEY, + "displayName": "A", + "channelId": CHANNEL, + }); + outcome[extra] = serde_json::json!("nsec1deadbeef"); + let json = serde_json::json!({ "action": "agents.create", "outcome": outcome }); + assert!( + serde_json::from_value::(json).is_err(), + "an outcome carrying \"{extra}\" must not deserialize" + ); + } +} + +/// A `storage.address` outcome carries addressing material only — never the key +/// that derived it. +#[test] +fn storage_address_outcome_carries_addressing_only() { + let json = serde_json::to_value(StorageAddress { + author_pubkey: pubkey(), + kind: 30174, + d_tag: EVENT.into(), + }) + .unwrap(); + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, vec!["authorPubkey", "dTag", "kind"]); +} + +#[test] +fn pubkey_hex_rejects_anything_but_a_public_key() { + assert!(PubkeyHex::parse("nothex").is_err()); + assert!(PubkeyHex::parse(&PUBKEY[..40]).is_err()); + assert!(PubkeyHex::parse(format!("{PUBKEY}00")).is_err()); + assert!(PubkeyHex::parse("nsec1deadbeef").is_err()); + // Normalizes case, so two spellings of one key cannot look like two keys. + assert_eq!( + PubkeyHex::parse(PUBKEY.to_ascii_uppercase()).unwrap(), + pubkey() + ); + // And it enforces that through serde, not only through the constructor. + assert!(serde_json::from_value::(serde_json::json!("nothex")).is_err()); +} + +// ── Argument validation ───────────────────────────────────────────────────── + +#[test] +fn every_fixture_validates_and_normalization_is_idempotent() { + for args in action_fixtures() { + let once = args.validated().expect("fixture validates"); + let twice = once.validated().expect("normalized form validates"); + assert_eq!( + once, + twice, + "{} normalization must settle", + args.action().as_str() + ); + } +} + +#[test] +fn reads_reject_a_bad_channel_thread_or_limit() { + assert!(ChannelReadArgs::channel("not-a-uuid").validated().is_err()); + assert!(ChannelReadArgs::channel(CHANNEL) + .in_thread("nothex") + .validated() + .is_err()); + assert!(ChannelReadArgs::channel(CHANNEL) + .limit(0) + .validated() + .is_err()); + assert!(ChannelReadArgs::channel(CHANNEL) + .limit(actions::MAX_PAGE_LIMIT) + .validated() + .is_ok()); + assert!(ChannelReadArgs::channel(CHANNEL) + .limit(actions::MAX_PAGE_LIMIT + 1) + .validated() + .is_err()); +} + +/// A bare channel read must not imply a thread or a mention filter the caller +/// never asked for. +#[test] +fn a_bare_channel_read_carries_no_unset_narrowing() { + let json = serde_json::to_value(ChannelReadArgs::channel(CHANNEL)).unwrap(); + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!(keys, vec!["channelId"]); +} + +#[test] +fn writes_reject_empty_oversized_and_over_mentioned_content() { + let post = |content: String, mentions: Vec| MessagePostArgs { + channel_id: CHANNEL.into(), + content, + mentions, + }; + assert!(post(" ".into(), vec![]).validated().is_err()); + assert!(matches!( + post("x".repeat(actions::MAX_CONTENT_BYTES + 1), vec![]) + .validated() + .unwrap_err(), + SdkError::ContentTooLarge { .. } + )); + assert!(matches!( + post("hi".into(), vec![pubkey(); actions::MAX_MENTIONS + 1]) + .validated() + .unwrap_err(), + SdkError::TooManyMentions + )); + assert!(post("hi".into(), vec![pubkey(); actions::MAX_MENTIONS]) + .validated() + .is_ok()); +} + +#[test] +fn reaction_rejects_empty_and_oversized_payloads() { + let react = |reaction: String| ReactionAddArgs { + channel_id: CHANNEL.into(), + target_event_id: EVENT.into(), + reaction, + }; + assert!(react(" ".into()).validated().is_err()); + assert!(matches!( + react("a".repeat(actions::MAX_EMOJI_CHARS + 1)) + .validated() + .unwrap_err(), + SdkError::EmojiTooLong + )); + assert!(react(":shipit:".into()).validated().is_ok()); +} + +#[test] +fn profile_set_requires_a_change_and_names_no_subject() { + let empty = ProfileSetArgs { + display_name: None, + about: None, + picture: None, + }; + let error = empty.validated().unwrap_err().to_string(); + assert!(error.contains("at least one"), "unexpected: {error}"); + + let with_subject = serde_json::json!({ "displayName": "A", "pubkey": PUBKEY }); + assert!(serde_json::from_value::(with_subject).is_err()); +} + +#[test] +fn storage_address_enforces_the_nip_ae_slug_grammar() { + let slug = |slug: &str| StorageAddressArgs { slug: slug.into() }.validated(); + assert!(slug("core").is_ok()); + assert!(slug("mem/broker-foundation").is_ok()); + assert!(slug("").is_err()); + assert!(slug("Core").is_err()); + assert!(slug("secrets").is_err()); + assert!(slug("mem/Bad Slug").is_err()); +} + +#[test] +fn agent_update_requires_a_change_and_delete_requires_a_target() { + let empty = AgentsUpdateArgs { + target: AgentTarget::Pubkey(pubkey()), + display_name: None, + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: None, + }; + let error = empty.validated().unwrap_err().to_string(); + assert!(error.contains("at least one field"), "unexpected: {error}"); + + assert!(AgentsDeleteArgs { + target: AgentTarget::Name(" ".into()), + } + .validated() + .is_err()); +} + +#[test] +fn agents_create_rejects_an_unsupported_respond_to_mode() { + let args = AgentsCreateArgs { + channel_id: CHANNEL.into(), + display_name: "A".into(), + system_prompt: "B".into(), + runtime: None, + provider: None, + model: None, + respond_to: Some("allowlist".into()), + }; + assert!(args.validated().is_err()); +} + +/// Update and delete carry no channel: channel appears only where the operation +/// needs it, which is the create attachment. +#[test] +fn agent_update_and_delete_carry_no_channel() { + let update = serde_json::to_value(AgentsUpdateArgs { + target: AgentTarget::Pubkey(pubkey()), + display_name: Some("New".into()), + system_prompt: None, + runtime: None, + provider: None, + model: None, + respond_to: None, + }) + .unwrap(); + assert!(update.get("channelId").is_none()); + + let delete = serde_json::to_value(AgentsDeleteArgs { + target: AgentTarget::Name("Gone".into()), + }) + .unwrap(); + assert!(delete.get("channelId").is_none()); +} + +// ── Results ───────────────────────────────────────────────────────────────── + +#[test] +fn failed_and_indeterminate_are_distinct_and_carry_no_outcome() { + let failed = BrokerResult::failed(BrokerError::new( + BrokerErrorCode::ActionFailed, + "runtime not installed", + )); + let failed_json = serde_json::to_value(BrokerResponse::new("r", failed.clone())).unwrap(); + assert_eq!(failed_json["status"], "failed"); + assert_eq!(failed_json["error"]["code"], "action_failed"); + assert!(failed_json.get("outcome").is_none()); + + let indeterminate = BrokerResult::indeterminate(BrokerError::new( + BrokerErrorCode::OutcomeUnknown, + "host restarted mid-execution", + )); + let json = serde_json::to_value(BrokerResponse::new("r", indeterminate.clone())).unwrap(); + assert_eq!(json["status"], "indeterminate"); + assert_eq!(json["error"]["code"], "outcome_unknown"); + assert!(json.get("outcome").is_none()); + + assert_ne!(failed, indeterminate); + assert!(failed.outcome().is_none()); + assert!(indeterminate.outcome().is_none()); +} + +#[test] +fn replay_metadata_rides_the_response_not_the_result() { + let result = BrokerResult::succeeded(ActionOutcome::AgentsDelete(AgentsDeleteOutcome { + agent_pubkey: pubkey(), + display_name: "Gone".into(), + })); + let fresh = BrokerResponse::new("req-9", result.clone()); + let replayed = BrokerResponse::new("req-9", result.clone()).replayed(); + + // The domain outcome is identical; only the delivery metadata differs. + assert_eq!(fresh.result, replayed.result); + assert!(!fresh.replayed); + assert!(replayed.replayed); + assert_eq!( + serde_json::to_value(&replayed).unwrap()["replayed"], + serde_json::json!(true) + ); + + // `replayed` is not part of the stored result encoding. + assert!(serde_json::to_value(&result) + .unwrap() + .get("replayed") + .is_none()); +} + +#[test] +fn every_error_code_has_a_stable_wire_string() { + for (code, expected) in [ + (BrokerErrorCode::InvalidRequest, "invalid_request"), + ( + BrokerErrorCode::UnsupportedProtocolVersion, + "unsupported_protocol_version", + ), + (BrokerErrorCode::UnknownAction, "unknown_action"), + ( + BrokerErrorCode::UnsupportedActionVersion, + "unsupported_action_version", + ), + (BrokerErrorCode::Unsupported, "unsupported"), + (BrokerErrorCode::Unauthenticated, "unauthenticated"), + (BrokerErrorCode::Unauthorized, "unauthorized"), + (BrokerErrorCode::RequestIdConflict, "request_id_conflict"), + (BrokerErrorCode::ActionFailed, "action_failed"), + (BrokerErrorCode::OutcomeUnknown, "outcome_unknown"), + (BrokerErrorCode::Internal, "internal"), + ] { + assert_eq!(code.as_str(), expected); + assert_eq!( + serde_json::to_value(code).unwrap(), + serde_json::json!(expected) + ); + } +} + +/// Only housekeeping is best-effort. A host refusing a read or a write has +/// broken the agent, and the agent must not treat that as routine. +#[test] +fn only_housekeeping_actions_are_best_effort() { + assert!(Action::ReactionAdd.is_best_effort()); + for essential in [ + Action::ChannelRead, + Action::MessagePost, + Action::MessageReply, + Action::ProfileSet, + Action::StorageAddress, + Action::AgentsCreate, + Action::AgentsUpdate, + Action::AgentsDelete, + ] { + assert!( + !essential.is_best_effort(), + "{} must not be best-effort", + essential.as_str() + ); + } +} + +// ── Client trait ──────────────────────────────────────────────────────────── + +/// A test double, and the only implementation in this crate. It exists to prove +/// the trait is object-safe and usable behind `dyn`, which is what lets an +/// in-process host and an HTTP client be interchangeable. +struct DoubleBroker { + response: Result, +} + +impl BrokerClient for DoubleBroker { + fn execute<'a>(&'a self, request: &'a BrokerRequest) -> BrokerFuture<'a> { + let response = self.response.clone().map(|mut response| { + response.request_id = request.request_id.clone(); + response + }); + Box::pin(async move { response }) + } +} + +fn block_on(future: F) -> F::Output { + // A hand-rolled park-free executor: the double's future is always ready, so + // one poll suffices and pulling in a runtime would be the heavier choice. + use std::task::{Context, Poll, Wake, Waker}; + struct NoopWake; + impl Wake for NoopWake { + fn wake(self: std::sync::Arc) {} + } + let waker = Waker::from(std::sync::Arc::new(NoopWake)); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => output, + Poll::Pending => panic!("test double must not park"), + } +} + +#[test] +fn the_client_trait_is_object_safe_and_returns_a_host_verdict() { + let request = BrokerRequest::new( + "req-42", + ActionArgs::ChannelRead(ChannelReadArgs::channel(CHANNEL).mentions_only()), + ) + .unwrap(); + + let succeeded: Box = Box::new(DoubleBroker { + response: Ok(BrokerResponse::new( + "placeholder", + BrokerResult::succeeded(ActionOutcome::ChannelRead(MessagePage { + messages: vec![], + next_cursor: None, + })), + )), + }); + let response = block_on(succeeded.execute(&request)).expect("double answers"); + response.validate().expect("double's response is valid"); + assert_eq!(response.request_id, "req-42"); + assert!(response.result.outcome().is_some()); + + // A refusal is still an answer: `Ok` with the verdict in the envelope. + let refused: Box = Box::new(DoubleBroker { + response: Ok(BrokerResponse::new( + "placeholder", + BrokerResult::failed(BrokerError::unauthorized("not your channel")), + )), + }); + let response = block_on(refused.execute(&request)).expect("a refusal is not a transport error"); + assert_eq!( + response.result.error().map(|e| e.code), + Some(BrokerErrorCode::Unauthorized) + ); + + // No answer at all is a transport error, and says nothing about side effects. + let unreachable: Box = Box::new(DoubleBroker { + response: Err(BrokerTransportError::CredentialRejected), + }); + assert_eq!( + block_on(unreachable.execute(&request)).unwrap_err(), + BrokerTransportError::CredentialRejected + ); +} + +#[test] +fn the_http_binding_is_a_single_path_with_a_bearer_credential() { + assert_eq!(BROKER_ACTION_PATH, "/v1/action"); + assert_eq!(BROKER_CREDENTIAL_HEADER, "authorization"); +} diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c88..845505c56d 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -12,6 +12,7 @@ //! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`. //! No keys are held here. No network calls are made. +pub mod broker; pub mod builders; pub mod mentions; pub mod nip_oa;