diff --git a/crates/buzz-sdk/src/broker/actions.rs b/crates/buzz-sdk/src/broker/actions.rs deleted file mode 100644 index d440f7a40b..0000000000 --- a/crates/buzz-sdk/src/broker/actions.rs +++ /dev/null @@ -1,1028 +0,0 @@ -//! Broker actions — the closed set of operations an agent may ask a host to perform. -//! -//! An action is the unit of policy; see the [module docs](super) for why this is -//! an operation enum rather than a signing primitive. -//! -//! Two consequences of that choice are visible in the types below. Every args -//! and outcome type is `deny_unknown_fields`, so a credential or environment map -//! smuggled into a payload fails to deserialize instead of reaching an executor. -//! And the wire key set of every type is pinned by test, so a secret-bearing -//! field cannot be added without a test failing. - -use serde::{Deserialize, Serialize}; - -use crate::SdkError; -use buzz_core::engram::validate_slug; -use nostr::Event; - -/// 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; - -/// Maximum accepted length of a read cursor, in bytes. -pub const MAX_CURSOR_LEN: usize = 256; - -/// 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. -#[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 an - /// agent can still run where it is unavailable. See - /// [`super::BrokerErrorCode::Unsupported`] for how a caller reacts. - #[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 channel, thread, and mention-feed scope, because they -/// differ only by filter and a name per scope would split one permission — -/// *may this agent see this channel* — across three policy decisions. -#[derive(Debug, Clone, Default, 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 wake path. - /// - /// The requester is never named; see [`super::BrokerRequest`] on why no body - /// names its own subject. - #[serde(default, skip_serializing_if = "is_false")] - pub mentions_only: bool, - /// Opaque position to resume from, as returned in [`MessagePage::next_cursor`]. - /// - /// Absent on a first read, which starts at the host's default window. A - /// cursor is **opaque**: callers must round-trip it verbatim and must not - /// parse, compare, or synthesize one. That is deliberate — a timestamp - /// cursor cannot page safely when more events than `limit` share one - /// second, and it would commit every future host to one relay ordering - /// strategy. The host defines ordering and cursor stability, including - /// whether a cursor stays valid across restarts. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cursor: 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 from the host's default window. - #[must_use] - pub fn channel(channel_id: impl Into) -> Self { - Self { - channel_id: channel_id.into(), - ..Self::default() - } - } - - /// Validate and normalize. - /// - /// # Errors - /// - /// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a - /// malformed root event id, an over-long or non-printable cursor, 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, - cursor: self.cursor.as_deref().map(cursor).transpose()?, - 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`. -/// -/// Deriving a record's address needs the secret this contract exists to avoid -/// holding, which is why #6467 lists it among the operations to route through -/// the interface rather than compute locally. -#[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, so a host -/// never has to guess which of two names 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 is whoever the host authenticated, so an -/// agent creating an agent owns it. See the [module docs](super) on ownership -/// recursion, and [`super::BrokerRequest`] on why no body names its own subject. -#[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: the signed Nostr event, verbatim. -/// -/// The event is authoritative. It is carried whole — signature and tags -/// included — rather than reduced to a projection, because Schnorr verification -/// is local and needs no relay: see [`Self::verify`]. A keyless agent therefore -/// still gets independently verifiable authorship and content, and only has to -/// trust the host for *completeness* (that no message was withheld) and for -/// authorization. Reducing to a projection would have widened that trust to -/// cover authorship too, and dropped the tags later consumers need. -/// -/// The wire form is the event's own JSON, so this adds no envelope of its own. -/// Ancestry and mentions are exposed as derived accessors rather than sibling -/// fields, so there is nothing that can disagree with the signed bytes. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct BrokerMessage(pub Event); - -impl BrokerMessage { - /// The signed event. - #[must_use] - pub fn event(&self) -> &Event { - &self.0 - } - - /// Verify the event's id and Schnorr signature. - /// - /// This is the caller's provenance check and it is entirely local. A host - /// that fabricated or altered a message fails here regardless of what it - /// claims. It is deliberately *not* called by - /// [`super::BrokerResponse::validate_for`]: whether to pay for verification, - /// and what to do when it fails, is the caller's policy. - /// - /// # Errors - /// - /// Returns [`SdkError::InvalidInput`] when the id does not match the - /// content or the signature does not match the author. - pub fn verify(&self) -> Result<(), SdkError> { - self.0.verify().map_err(|e| { - SdkError::InvalidInput(format!("broker returned an unverifiable event: {e}")) - }) - } - - /// The author's pubkey, in this contract's identity type. - /// - /// # Errors - /// - /// Returns [`SdkError::InvalidInput`] if the event's author is not - /// expressible as 64 hex characters. - pub fn author(&self) -> Result { - PubkeyHex::parse(self.0.pubkey.to_hex()) - } - - /// NIP-10 `root`/`reply` ancestry, parsed from the signed tags. - #[must_use] - pub fn thread(&self) -> buzz_core::nip10::ThreadMarkers { - buzz_core::nip10::parse_thread_markers(&self.0.tags) - } - - /// Pubkeys this message mentions, from the signed `p` tags. - #[must_use] - pub fn mentions(&self) -> Vec { - self.0 - .tags - .public_keys() - .map(nostr::PublicKey::to_hex) - .collect() - } -} - -/// Outcome of any read action. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct MessagePage { - /// Messages in the host's declared order. - pub messages: Vec, - /// Opaque cursor to pass as [`ChannelReadArgs::cursor`] on the next call. - /// - /// 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. - #[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: pubkey, name, channel, and -/// nothing else. There is no field for the minted secret — it stays on the -/// host — and `deny_unknown_fields` plus a test pinning this exact key set is -/// what enforces that rather than a comment. -#[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, - } - } - - /// Validate the identifiers and cursors this outcome asserts. - /// - /// A well-typed outcome can still carry a malformed id or an unusable - /// cursor, and a caller that trusted either would fail later and further - /// away. Signature verification is deliberately *not* here — that is - /// [`BrokerMessage::verify`], and whether to pay for it is the caller's - /// choice. - /// - /// # Errors - /// - /// Returns [`SdkError::InvalidInput`] for a malformed event id, `d` tag, - /// channel UUID, or cursor, an empty name, or an over-long page. - pub fn validate(&self) -> Result<(), SdkError> { - match self { - Self::ChannelRead(page) => { - if page.messages.len() > MAX_PAGE_LIMIT as usize { - return Err(SdkError::InvalidInput(format!( - "page holds {} messages, over the {MAX_PAGE_LIMIT} cap", - page.messages.len() - ))); - } - page.next_cursor.as_deref().map(cursor).transpose()?; - } - Self::MessagePost(published) - | Self::MessageReply(published) - | Self::ReactionAdd(published) - | Self::ProfileSet(published) => { - event_id(&published.event_id, "eventId")?; - } - Self::StorageAddress(address) => { - event_id(&address.d_tag, "dTag")?; - } - Self::AgentsCreate(outcome) => { - channel(&outcome.channel_id)?; - required(&outcome.display_name, "display name", MAX_NAME_CHARS)?; - } - Self::AgentsUpdate(AgentsUpdateOutcome { display_name, .. }) - | Self::AgentsDelete(AgentsDeleteOutcome { display_name, .. }) => { - required(display_name, "display name", MAX_NAME_CHARS)?; - } - } - Ok(()) - } -} - -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)), - } -} - -/// Validate an opaque read cursor: printable ASCII, bounded, never parsed. -/// -/// The bound exists so a host cannot be made to store an unbounded token; the -/// character set keeps it safe to log. Nothing here interprets the value. -fn cursor(value: &str) -> Result { - if value.is_empty() { - return Err(SdkError::InvalidInput( - "cursor must not be empty (omit it to start from the host's default window)".into(), - )); - } - if value.len() > MAX_CURSOR_LEN { - return Err(SdkError::InvalidInput(format!( - "cursor exceeds {MAX_CURSOR_LEN} bytes (got {})", - value.len() - ))); - } - if !value.bytes().all(|b| (0x21..=0x7e).contains(&b)) { - return Err(SdkError::InvalidInput( - "cursor must be printable ASCII without spaces".into(), - )); - } - Ok(value.to_owned()) -} - -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/actions/args.rs b/crates/buzz-sdk/src/broker/actions/args.rs new file mode 100644 index 0000000000..ce90f3c21f --- /dev/null +++ b/crates/buzz-sdk/src/broker/actions/args.rs @@ -0,0 +1,499 @@ +//! Argument types — one per [`Action`], plus the tagged union that pairs a +//! wire action name with its arguments. +//! +//! Every type here is `deny_unknown_fields`, so a credential or environment map +//! smuggled into a payload fails to deserialize rather than reaching an +//! executor. Each carries a `validated()` returning a normalized copy; the +//! shared validators live in the [parent module](super). + +use serde::{Deserialize, Serialize}; + +use super::{ + channel, content, cursor, event_id, is_false, limit, mentions, optional, required, respond_to, + validate_slug, Action, PubkeyHex, MAX_ABOUT_CHARS, MAX_EMOJI_CHARS, MAX_NAME_CHARS, + MAX_PROMPT_CHARS, MAX_SCALAR_CHARS, +}; +use crate::SdkError; + +/// 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 channel, thread, and mention-feed scope, because they +/// differ only by filter and a name per scope would split one permission — +/// *may this agent see this channel* — across three policy decisions. +#[derive(Debug, Clone, Default, 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 wake path. + /// + /// The requester is never named; see [`crate::broker::BrokerRequest`] on why no body + /// names its own subject. + #[serde(default, skip_serializing_if = "is_false")] + pub mentions_only: bool, + /// Opaque position to resume from, as returned in [`super::outcomes::MessagePage::next_cursor`]. + /// + /// Absent on a first read, which starts at the host's default window. A + /// cursor is **opaque**: callers must round-trip it verbatim and must not + /// parse, compare, or synthesize one. That is deliberate — a timestamp + /// cursor cannot page safely when more events than `limit` share one + /// second, and it would commit every future host to one relay ordering + /// strategy. The host defines ordering and cursor stability, including + /// whether a cursor stays valid across restarts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Maximum events to return, capped at [`super::MAX_PAGE_LIMIT`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +impl ChannelReadArgs { + /// Read a whole channel from the host's default window. + #[must_use] + pub fn channel(channel_id: impl Into) -> Self { + Self { + channel_id: channel_id.into(), + ..Self::default() + } + } + + /// Validate and normalize. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed channel UUID, a + /// malformed root event id, an over-long or non-printable cursor, 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, + cursor: self.cursor.as_deref().map(cursor).transpose()?, + 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 [`super::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 [`super::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`. +/// +/// Deriving a record's address needs the secret this contract exists to avoid +/// holding, which is why #6467 lists it among the operations to route through +/// the interface rather than compute locally. +#[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, so a host +/// never has to guess which of two names 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 is whoever the host authenticated, so an +/// agent creating an agent owns it. See the [module docs](super) on ownership +/// recursion, and [`crate::broker::BrokerRequest`] on why no body names its own subject. +#[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 [`crate::broker::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()?), + }) + } +} diff --git a/crates/buzz-sdk/src/broker/actions/mod.rs b/crates/buzz-sdk/src/broker/actions/mod.rs new file mode 100644 index 0000000000..89dd651491 --- /dev/null +++ b/crates/buzz-sdk/src/broker/actions/mod.rs @@ -0,0 +1,310 @@ +//! Broker actions — the closed set of operations an agent may ask a host to perform. +//! +//! An action is the unit of policy; see the [module docs](super) for why this is +//! an operation enum rather than a signing primitive. +//! +//! Two consequences of that choice are visible in the types below. Every args +//! and outcome type is `deny_unknown_fields`, so a credential or environment map +//! smuggled into a payload fails to deserialize instead of reaching an executor. +//! And the wire key set of every type is pinned by test, so a secret-bearing +//! field cannot be added without a test failing. +//! +//! [`Action`] and the shared validators live here; the payload types are split +//! into [`args`] and [`outcomes`] so each side of a call reviews on its own. + +use serde::{Deserialize, Serialize}; + +use crate::SdkError; +use buzz_core::engram::validate_slug; + +pub mod args; +pub mod outcomes; + +pub use args::{ + ActionArgs, AgentTarget, AgentsCreateArgs, AgentsDeleteArgs, AgentsUpdateArgs, ChannelReadArgs, + MessagePostArgs, MessageReplyArgs, ProfileSetArgs, ReactionAddArgs, StorageAddressArgs, +}; +pub use outcomes::{ + ActionOutcome, AgentsCreateOutcome, AgentsDeleteOutcome, AgentsUpdateOutcome, BrokerMessage, + EventPublished, MessagePage, StorageAddress, +}; + +/// 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; + +/// Maximum accepted length of a read cursor, in bytes. +pub const MAX_CURSOR_LEN: usize = 256; + +/// 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. +#[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 an + /// agent can still run where it is unavailable. See + /// [`super::BrokerErrorCode::Unsupported`] for how a caller reacts. + #[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}\""))) + } +} + +// ── Shared validators ─────────────────────────────────────────────────────── + +fn is_false(value: &bool) -> bool { + !*value +} + +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)), + } +} + +/// Validate an opaque read cursor: printable ASCII, bounded, never parsed. +/// +/// The bound exists so a host cannot be made to store an unbounded token; the +/// character set keeps it safe to log. Nothing here interprets the value. +fn cursor(value: &str) -> Result { + if value.is_empty() { + return Err(SdkError::InvalidInput( + "cursor must not be empty (omit it to start from the host's default window)".into(), + )); + } + if value.len() > MAX_CURSOR_LEN { + return Err(SdkError::InvalidInput(format!( + "cursor exceeds {MAX_CURSOR_LEN} bytes (got {})", + value.len() + ))); + } + if !value.bytes().all(|b| (0x21..=0x7e).contains(&b)) { + return Err(SdkError::InvalidInput( + "cursor must be printable ASCII without spaces".into(), + )); + } + Ok(value.to_owned()) +} + +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/actions/outcomes.rs b/crates/buzz-sdk/src/broker/actions/outcomes.rs new file mode 100644 index 0000000000..3585885a47 --- /dev/null +++ b/crates/buzz-sdk/src/broker/actions/outcomes.rs @@ -0,0 +1,260 @@ +//! Outcome types — the success payload of each [`Action`], and the tagged union +//! that pairs a wire action name with its outcome. +//! +//! Outcomes are shared where actions agree on what success means: the four +//! event-publishing actions all return [`EventPublished`]. Every type is +//! `deny_unknown_fields` with its exact wire key set pinned by test, which is +//! what enforces the no-secret rule — see the [contract docs](crate::broker). + +use serde::{Deserialize, Serialize}; + +use super::{ + channel, cursor, event_id, required, Action, PubkeyHex, MAX_NAME_CHARS, MAX_PAGE_LIMIT, +}; +use crate::SdkError; +use nostr::Event; + +/// One message returned by a read: the signed Nostr event, verbatim. +/// +/// The event is authoritative. It is carried whole — signature and tags +/// included — rather than reduced to a projection, because Schnorr verification +/// is local and needs no relay: see [`Self::verify`]. A keyless agent therefore +/// still gets independently verifiable authorship and content, and only has to +/// trust the host for *completeness* (that no message was withheld) and for +/// authorization. Reducing to a projection would have widened that trust to +/// cover authorship too, and dropped the tags later consumers need. +/// +/// The wire form is the event's own JSON, so this adds no envelope of its own. +/// Ancestry and mentions are exposed as derived accessors rather than sibling +/// fields, so there is nothing that can disagree with the signed bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct BrokerMessage(pub Event); + +impl BrokerMessage { + /// The signed event. + #[must_use] + pub fn event(&self) -> &Event { + &self.0 + } + + /// Verify the event's id and Schnorr signature. + /// + /// This is the caller's provenance check and it is entirely local. A host + /// that fabricated or altered a message fails here regardless of what it + /// claims. It is deliberately *not* called by + /// [`crate::broker::BrokerResponse::validate_for`]: whether to pay for verification, + /// and what to do when it fails, is the caller's policy. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] when the id does not match the + /// content or the signature does not match the author. + pub fn verify(&self) -> Result<(), SdkError> { + self.0.verify().map_err(|e| { + SdkError::InvalidInput(format!("broker returned an unverifiable event: {e}")) + }) + } + + /// The author's pubkey, in this contract's identity type. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] if the event's author is not + /// expressible as 64 hex characters. + pub fn author(&self) -> Result { + PubkeyHex::parse(self.0.pubkey.to_hex()) + } + + /// NIP-10 `root`/`reply` ancestry, parsed from the signed tags. + #[must_use] + pub fn thread(&self) -> buzz_core::nip10::ThreadMarkers { + buzz_core::nip10::parse_thread_markers(&self.0.tags) + } + + /// Pubkeys this message mentions, from the signed `p` tags. + #[must_use] + pub fn mentions(&self) -> Vec { + self.0 + .tags + .public_keys() + .map(nostr::PublicKey::to_hex) + .collect() + } +} + +/// Outcome of any read action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MessagePage { + /// Messages in the host's declared order. + pub messages: Vec, + /// Opaque cursor to pass as [`super::args::ChannelReadArgs::cursor`] on the next call. + /// + /// 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. + #[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: pubkey, name, channel, and +/// nothing else. There is no field for the minted secret — it stays on the +/// host — and `deny_unknown_fields` plus a test pinning this exact key set is +/// what enforces that rather than a comment. +#[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, + } + } + + /// Validate the identifiers and cursors this outcome asserts. + /// + /// A well-typed outcome can still carry a malformed id or an unusable + /// cursor, and a caller that trusted either would fail later and further + /// away. Signature verification is deliberately *not* here — that is + /// [`BrokerMessage::verify`], and whether to pay for it is the caller's + /// choice. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a malformed event id, `d` tag, + /// channel UUID, or cursor, an empty name, or an over-long page. + pub fn validate(&self) -> Result<(), SdkError> { + match self { + Self::ChannelRead(page) => { + if page.messages.len() > MAX_PAGE_LIMIT as usize { + return Err(SdkError::InvalidInput(format!( + "page holds {} messages, over the {MAX_PAGE_LIMIT} cap", + page.messages.len() + ))); + } + page.next_cursor.as_deref().map(cursor).transpose()?; + } + Self::MessagePost(published) + | Self::MessageReply(published) + | Self::ReactionAdd(published) + | Self::ProfileSet(published) => { + event_id(&published.event_id, "eventId")?; + } + Self::StorageAddress(address) => { + event_id(&address.d_tag, "dTag")?; + } + Self::AgentsCreate(outcome) => { + channel(&outcome.channel_id)?; + required(&outcome.display_name, "display name", MAX_NAME_CHARS)?; + } + Self::AgentsUpdate(AgentsUpdateOutcome { display_name, .. }) + | Self::AgentsDelete(AgentsDeleteOutcome { display_name, .. }) => { + required(display_name, "display name", MAX_NAME_CHARS)?; + } + } + Ok(()) + } +} diff --git a/crates/buzz-sdk/src/broker/client.rs b/crates/buzz-sdk/src/broker/client.rs index 3793e43672..992acd28ea 100644 --- a/crates/buzz-sdk/src/broker/client.rs +++ b/crates/buzz-sdk/src/broker/client.rs @@ -49,7 +49,7 @@ use std::future::Future; use std::pin::Pin; -use super::{BrokerResponse, PreparedRequest}; +use super::{BrokerResponse, BrokerResult, PreparedRequest}; /// Path of the single broker endpoint. pub const BROKER_ACTION_PATH: &str = "/v1/action"; @@ -88,12 +88,15 @@ pub enum BrokerTransportError { /// An envelope arrived but did not validate against the request that was /// sent — wrong `requestId`, wrong action, or a malformed outcome. /// - /// See [`BrokerResponse::validate_for`]. + /// Produced by [`ValidatedResponse::validate`], which every + /// [`BrokerClientExt::execute`] call runs. A host that answers something + /// other than what was asked has given no verdict at all, which is why this + /// is a transport failure rather than a `Failed` result. #[error("malformed broker response: {0}")] MalformedResponse(String), } -/// A future returned by [`BrokerClient::execute`]. +/// A future returned by [`BrokerClient::send`]. /// /// 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 @@ -101,28 +104,150 @@ pub enum BrokerTransportError { pub type BrokerFuture<'a> = Pin> + Send + 'a>>; +/// A future returned by [`BrokerClientExt::execute`]. +pub type ValidatedFuture<'a> = + Pin> + Send + 'a>>; + +/// A host response that has been checked against the request it answers. +/// +/// The only way to obtain one is [`ValidatedResponse::validate`], which is what +/// [`BrokerClientExt::execute`] calls. That is the point: correlation is not +/// advice an implementation may skip, because a caller using `execute` cannot be +/// handed anything else, and an implementation cannot produce this type without +/// passing the request it is answering. +/// +/// [`BrokerResponse::validate_for`] remains public for a host validating its own +/// output, but a client never has to remember to call it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedResponse(BrokerResponse); + +impl ValidatedResponse { + /// Check `response` against the request it claims to answer. + /// + /// # Errors + /// + /// Returns [`BrokerTransportError::MalformedResponse`] when the response + /// does not correlate, carries an outcome for a different action, or asserts + /// a malformed identifier. A response that fails here is not a host verdict + /// — nothing can be concluded about side effects from it. + pub fn validate( + response: BrokerResponse, + request: &PreparedRequest, + ) -> Result { + response + .validate_for(request) + .map_err(|e| BrokerTransportError::MalformedResponse(e.to_string()))?; + Ok(Self(response)) + } + + /// The terminal disposition the host reached. + #[must_use] + pub fn result(&self) -> &BrokerResult { + &self.0.result + } + + /// The correlated `requestId`. + #[must_use] + pub fn request_id(&self) -> &str { + &self.0.request_id + } + + /// Whether the host replayed a previously recorded outcome. + #[must_use] + pub fn replayed(&self) -> bool { + self.0.replayed + } + + /// The underlying envelope, for logging or re-serialization. + #[must_use] + pub fn envelope(&self) -> &BrokerResponse { + &self.0 + } + + /// Consume this wrapper, yielding the validated envelope. + #[must_use] + pub fn into_envelope(self) -> BrokerResponse { + self.0 + } +} + +/// Permission to call [`BrokerClient::send`], which only +/// [`BrokerClientExt::execute`] can mint. +/// +/// This is what makes validation *structurally* the only door rather than the +/// recommended one. `send` must be public — an out-of-crate implementation has +/// to define it — but a caller must not be able to invoke it and receive an +/// uncorrelated envelope. A token with a private field satisfies both: any crate +/// can accept one in a signature, only this module can construct one, and +/// `execute` constructs it on the caller's behalf. +/// +/// An implementation should ignore its value; it carries no data. +/// +/// What this does not do: an implementation, having legitimately received a +/// token, could stash the envelope it saw in its own state and expose it. That +/// is not a bypass worth designing against — code that does it has deliberately +/// implemented a transport in order to exfiltrate its own input, which is a +/// different thing from a caller forgetting to correlate a response. Closing the +/// accidental path is the goal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Dispatch(()); + /// 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. /// -/// The argument is a [`PreparedRequest`] rather than a typed request so that an -/// implementation cannot reserialize between attempts; see the -/// [`super::BrokerRequest`] retry contract. +/// This trait is the **transport primitive**: frozen bytes out, one envelope +/// back. It is not the caller's interface. Callers use +/// [`BrokerClientExt::execute`], which is where response correlation happens, +/// which no implementation can replace, and which holds the only [`Dispatch`] +/// token `send` will accept. /// /// Implementations must be usable as `dyn BrokerClient`. pub trait BrokerClient: Send + Sync { - /// Send `request`'s frozen bytes and return the host's terminal response. + /// Send `request`'s frozen bytes and return whatever envelope came back. /// - /// Implementations should call [`BrokerResponse::validate_for`] before - /// returning `Ok`, so a caller never has to consider a response that does - /// not answer its request. + /// An implementation's whole job is transport: send [`PreparedRequest::body`] + /// verbatim, parse an envelope regardless of HTTP status, and return it + /// unjudged. It must not attempt to interpret the verdict, and it need not + /// correlate the response — [`BrokerClientExt::execute`] does that for every + /// implementation. + /// + /// The [`Dispatch`] argument is why this cannot be called directly by a + /// caller outside this module; that is deliberate. /// /// # Errors /// - /// Returns [`BrokerTransportError`] when no usable response could be - /// obtained. A host that answered — even to refuse — returns `Ok` with the + /// Returns [`BrokerTransportError`] when no envelope could be obtained or + /// parsed. A host that answered — even to refuse — returns `Ok`, with the /// verdict in [`BrokerResponse::result`]. - fn execute<'a>(&'a self, request: &'a PreparedRequest) -> BrokerFuture<'a>; + fn send<'a>(&'a self, request: &'a PreparedRequest, dispatch: Dispatch) -> BrokerFuture<'a>; +} + +/// The caller-facing half of [`BrokerClient`]: send, then validate. +/// +/// Blanket-implemented for every [`BrokerClient`], including `dyn BrokerClient`, +/// and **not overridable** — coherence forbids a second implementation, so there +/// is exactly one definition of what validating a response means and no client +/// can weaken it. +pub trait BrokerClientExt: BrokerClient { + /// Send `request` and return a response already checked against it. + /// + /// # Errors + /// + /// Returns [`BrokerTransportError`] when no envelope arrived, or + /// [`BrokerTransportError::MalformedResponse`] when the envelope that + /// arrived does not answer `request`. Both mean the same thing to a caller: + /// no verdict, so nothing is known about side effects. + fn execute<'a>(&'a self, request: &'a PreparedRequest) -> ValidatedFuture<'a>; +} + +impl BrokerClientExt for C { + fn execute<'a>(&'a self, request: &'a PreparedRequest) -> ValidatedFuture<'a> { + Box::pin(async move { + let response = self.send(request, Dispatch(())).await?; + ValidatedResponse::validate(response, request) + }) + } } diff --git a/crates/buzz-sdk/src/broker/mod.rs b/crates/buzz-sdk/src/broker/mod.rs index 925b26fc51..5d69f2dfc9 100644 --- a/crates/buzz-sdk/src/broker/mod.rs +++ b/crates/buzz-sdk/src/broker/mod.rs @@ -107,7 +107,8 @@ pub use actions::{ ProfileSetArgs, PubkeyHex, ReactionAddArgs, StorageAddress, StorageAddressArgs, }; pub use client::{ - BrokerClient, BrokerFuture, BrokerTransportError, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, + BrokerClient, BrokerClientExt, BrokerFuture, BrokerTransportError, Dispatch, ValidatedFuture, + ValidatedResponse, BROKER_ACTION_PATH, BROKER_CREDENTIAL_HEADER, }; /// Wire `type` discriminator for a broker request payload. @@ -140,7 +141,7 @@ pub const MAX_REQUEST_ID_LEN: usize = 128; /// Retrying means resending the identical serialized request with the same /// `requestId`, which is why a client never sends this type directly: call /// [`Self::prepare`] to freeze it into a [`PreparedRequest`] and hand *that* to -/// [`BrokerClient::execute`]. The host hashes the bytes it receives and compares +/// [`BrokerClientExt::execute`]. The host hashes the bytes 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 @@ -282,13 +283,16 @@ pub fn validate_request_id(request_id: &str) -> Result<(), SdkError> { /// A validated request together with the exact bytes to send. /// -/// This is what [`BrokerClient::execute`] takes, so the retry contract is +/// This is what [`BrokerClient::send`] takes, so the retry contract is /// structural rather than documented: the first attempt and every retry send /// `body` verbatim, and no implementation gets the chance to reserialize. /// /// Construct one with [`BrokerRequest::prepare`]. The typed [`BrokerRequest`] is -/// kept alongside so a client can correlate the response without reparsing — -/// see [`BrokerResponse::validate_for`]. +/// retained privately and **deliberately not exposed**: an implementation that +/// could reach the typed value could serialize it again, which is exactly the +/// possibility freezing the bytes removes. What an implementation legitimately +/// needs is correlation metadata, so that — and only that — is public: +/// [`Self::request_id`] and [`Self::action`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PreparedRequest { request: BrokerRequest, @@ -302,12 +306,6 @@ impl PreparedRequest { &self.body } - /// The typed request these bytes encode. - #[must_use] - pub fn request(&self) -> &BrokerRequest { - &self.request - } - /// The idempotency key the host keys replay on. #[must_use] pub fn request_id(&self) -> &str { @@ -584,10 +582,12 @@ impl BrokerResponse { /// host (or a confused proxy) could return a `message.post` success to a /// `channel.read`, and a caller matching on the outcome enum would quietly /// take the wrong branch. This is the check that makes such a response - /// unusable instead of merely surprising, which is why - /// [`BrokerClient::execute`] implementations are expected to run it before - /// returning `Ok` and to report a failure as - /// [`BrokerTransportError::MalformedResponse`]. + /// unusable instead of merely surprising. + /// + /// A client does not call this directly and cannot forget to: + /// [`BrokerClientExt::execute`] runs it for every implementation and returns + /// a [`ValidatedResponse`], which is the only response type a caller can + /// obtain. This method stays public for a host validating its own output. /// /// Signature verification of read results is deliberately not included; see /// [`BrokerMessage::verify`]. diff --git a/crates/buzz-sdk/src/broker/tests.rs b/crates/buzz-sdk/src/broker/tests.rs index 05aef211cd..a0df0ced9b 100644 --- a/crates/buzz-sdk/src/broker/tests.rs +++ b/crates/buzz-sdk/src/broker/tests.rs @@ -921,7 +921,9 @@ fn preparing_a_request_freezes_the_bytes_every_attempt_sends() { prepared.body(), "body is frozen, not re-rendered" ); - assert_eq!(prepared.request(), &request); + // Correlation metadata is all a transport gets. There is deliberately no + // accessor for the typed request: one would let an implementation serialize + // the value a second time, which is the possibility freezing removes. assert_eq!(prepared.request_id(), "req-idem"); assert_eq!(prepared.action(), Action::MessagePost); @@ -977,39 +979,29 @@ request_id_conflict,action_failed,outcome_unknown,internal" ); } -/// 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() { - for action in Action::ALL { - assert_eq!( - action.is_best_effort(), - action == Action::ReactionAdd, - "{} best-effort classification", - action.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. +/// +/// Note what it does *not* do: it never calls `validate_for`. It cannot — it has +/// no way to build a [`ValidatedResponse`] except through the blanket +/// [`BrokerClientExt::execute`], which is the whole point of splitting the +/// trait. A deliberately hostile implementation is still forced through the +/// same check. struct DoubleBroker { response: Result, } impl BrokerClient for DoubleBroker { - fn execute<'a>(&'a self, request: &'a PreparedRequest) -> BrokerFuture<'a> { + fn send<'a>(&'a self, request: &'a PreparedRequest, _: Dispatch) -> BrokerFuture<'a> { // A real implementation sends `request.body()` verbatim. The double - // stands in for a host that answers the request it was given. - let response = self.response.clone().and_then(|mut response| { + // stands in for a host that answers under the id it was asked with, and + // returns the envelope unjudged. + let response = self.response.clone().map(|mut response| { response.request_id = request.request_id().to_string(); response - .validate_for(request) - .map_err(|e| BrokerTransportError::MalformedResponse(e.to_string()))?; - Ok(response) }); Box::pin(async move { response }) } @@ -1033,7 +1025,7 @@ fn block_on(future: F) -> F::Output { } #[test] -fn the_client_trait_is_object_safe_and_returns_a_host_verdict() { +fn the_client_trait_is_object_safe_and_returns_a_validated_host_verdict() { let request = prepared(ActionArgs::ChannelRead(ChannelReadArgs { channel_id: CHANNEL.into(), mentions_only: true, @@ -1049,12 +1041,12 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() { })), )), }); + // `execute` is available on `dyn BrokerClient` and is the only way to a + // `ValidatedResponse` — the caller does no correlation of its own. let response = block_on(succeeded.execute(&request)).expect("double answers"); - response - .validate_for(&request) - .expect("double's response answers the request"); - assert_eq!(response.request_id, "req-1"); - assert!(response.result.outcome().is_some()); + assert_eq!(response.request_id(), "req-1"); + assert!(response.result().outcome().is_some()); + assert!(!response.replayed()); // A refusal — including a rejected credential — is still an answer: `Ok` // with the verdict in the envelope. @@ -1070,7 +1062,7 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() { }); 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(code)); + assert_eq!(response.result().error().map(|e| e.code), Some(code)); } // No usable answer at all is a transport error, and says nothing about side @@ -1088,8 +1080,16 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() { }); assert_eq!(block_on(broken.execute(&request)).unwrap_err(), error); } +} - // A host answering the wrong action is rejected by the client, not passed on. +/// The double returns whatever it is given, unvalidated — a hostile client +/// cannot do otherwise. `execute` is still the only door, so the mismatch +/// surfaces as a transport failure and never reaches a caller as `Ok`. +#[test] +fn a_client_cannot_hand_back_a_response_that_answers_a_different_request() { + let request = prepared(ActionArgs::ChannelRead(ChannelReadArgs::channel(CHANNEL))); + + // Wrong action for this request. let confused: Box = Box::new(DoubleBroker { response: Ok(BrokerResponse::new( "placeholder", @@ -1099,8 +1099,27 @@ fn the_client_trait_is_object_safe_and_returns_a_host_verdict() { })), )), }); + // The envelope is well-formed in isolation — that is exactly why `send` + // cannot be the caller's door. `execute` is the only reachable one (a + // `Dispatch` token cannot be built outside the client module), and it + // rejects the mismatch rather than passing it on. assert!(matches!( block_on(confused.execute(&request)).unwrap_err(), BrokerTransportError::MalformedResponse(_) )); + + // Malformed identifiers inside an otherwise well-shaped outcome, too. + let bad_cursor: Box = Box::new(DoubleBroker { + response: Ok(BrokerResponse::new( + "placeholder", + BrokerResult::succeeded(ActionOutcome::ChannelRead(MessagePage { + messages: vec![], + next_cursor: Some("not a cursor".into()), + })), + )), + }); + assert!(matches!( + block_on(bad_cursor.execute(&request)).unwrap_err(), + BrokerTransportError::MalformedResponse(_) + )); }