From 9563c27e99d44312f5f2d16ae66166b6ea73b700 Mon Sep 17 00:00:00 2001 From: ss-dev-00 Date: Mon, 24 Aug 2026 18:01:27 -0700 Subject: [PATCH] broker: define the agent-to-broker action contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent that holds no secret key still has to work: read its channels, answer a mention, react, keep its profile, address its memory, and create the agents it needs. This is the contract for asking a host to do those things — the interface half of #6467, with no host, no transport, and no signing. The open question in that RFC is per-operation versus a single "sign this" primitive. This answers per-operation. A sign-anything primitive tells the broker who is asking but not what for, so the only policies it can express are "all" and "none" — it is a signing oracle with a nicer name. Named operations let a host serve channel.read while refusing agents.create, and give a later policy or IFC layer a per-operation surface to attach to. The cost is that a new operation needs a new variant, which is the point: adding one becomes a reviewable change to the contract instead of another use of an existing blank cheque. For the same reason signing, publishing, and NIP-42/44/98 are not actions. They are mechanisms internal to whoever holds the key, and this contract never names them. Identity is a pubkey and nothing else. `PubkeyHex` has no counterpart here for the corresponding secret, every args type is `deny_unknown_fields`, and no outcome can structurally hold key material — agents.create returns a pubkey and a handle, never the minted nsec. A test scans the module's own source for secret-key tokens, because a deserialization test can only reject fields someone thought to add, while the scanner fails the moment such a type appears in a signature at all. Both halves were checked against deliberate mutations rather than assumed. The envelope carries no requester, owner, or scope. Those come from the session credential the host authenticated, so authority is never something a body can assert: a request that could name its own subject would let any caller act as anyone. Ownership of a created agent follows the same rule — the owner is the requester, implicitly, which is what makes an agent able to own the agents it creates while the chain still ends at a human. Bounding that chain's depth needs resources and policy this contract cannot see, so it stays a host concern. Reads are actions too, because #6467 asks for a deployment where the host is the only route to the relay. One `channel.read` covers channel, thread, and mention-feed scopes: they differ by filter, not by permission, and three names for one decision would be three policy knobs pretending to be independent. Its outcome is a projection rather than a signed event, since a caller with no relay cannot verify signatures anyway — trust here is trust in the host, stated rather than implied. `presence.set` and `typing.set` are left out of v1. They are housekeeping a host may decline regardless, and a closed enum makes adding them purely additive. Carried over from #6543: the envelope, requestId validation, the three-way Succeeded/Failed/Indeterminate result, the error codes, the retry-is-identical- bytes contract, and the agents.* argument shapes. Changed deliberately: pubkeys are a validated `PubkeyHex` instead of `String`, `capability` is renamed `action`, `CapabilityFailed` becomes `ActionFailed`, and `Unsupported` is new so a host can decline a best-effort operation without it reading as a fault. #6543 also lands in this module path; whichever merges second needs a deliberate reconcile, which I own. Addresses the interface half of #6467. Signed-off-by: ss-dev-00 Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- crates/buzz-sdk/src/broker/actions.rs | 955 ++++++++++++++++++++++++++ crates/buzz-sdk/src/broker/client.rs | 96 +++ crates/buzz-sdk/src/broker/mod.rs | 481 +++++++++++++ crates/buzz-sdk/src/broker/tests.rs | 929 +++++++++++++++++++++++++ crates/buzz-sdk/src/lib.rs | 1 + 5 files changed, 2462 insertions(+) create mode 100644 crates/buzz-sdk/src/broker/actions.rs create mode 100644 crates/buzz-sdk/src/broker/client.rs create mode 100644 crates/buzz-sdk/src/broker/mod.rs create mode 100644 crates/buzz-sdk/src/broker/tests.rs 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;