diff --git a/crates/buzz-sdk/src/broker/capabilities.rs b/crates/buzz-sdk/src/broker/capabilities.rs new file mode 100644 index 0000000000..5fce6073ba --- /dev/null +++ b/crates/buzz-sdk/src/broker/capabilities.rs @@ -0,0 +1,406 @@ +//! Broker capabilities — the named business operations a host will perform. +//! +//! A capability is the unit of policy: `agents.create`, `agents.update`, and +//! `agents.delete` are separate names so a host can permit one without +//! permitting the others. There is deliberately no `agents.manage` action +//! union, which would collapse three policy decisions into one. +//! +//! Every args type is `deny_unknown_fields`, so an api key, env-var map, or +//! nsec smuggled into a payload fails to deserialize instead of reaching a +//! mutation. Every outcome type can structurally hold only public identifiers — +//! it cannot carry the owner nsec or a freshly minted agent secret. + +use serde::{Deserialize, Serialize}; + +use crate::SdkError; + +/// 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; + +/// Inbound author gate modes a requester may ask for. +/// +/// `allowlist` is deliberately absent: it needs a pubkey list this narrow +/// 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 capability name the broker can dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Capability { + /// Mint a managed agent and attach it to a channel. + AgentsCreate, + /// Patch one managed agent belonging to the owner. + AgentsUpdate, + /// Remove one managed agent belonging to the owner. + AgentsDelete, +} + +impl Capability { + /// Stable wire name. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::AgentsCreate => "agents.create", + Self::AgentsUpdate => "agents.update", + Self::AgentsDelete => "agents.delete", + } + } + + /// The capability contract version this build implements. + #[must_use] + pub fn current_version(self) -> u16 { + match self { + Self::AgentsCreate | Self::AgentsUpdate | Self::AgentsDelete => 1, + } + } + + /// Resolve a wire name. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for an unknown capability name. + pub fn parse(name: &str) -> Result { + match name { + "agents.create" => Ok(Self::AgentsCreate), + "agents.update" => Ok(Self::AgentsUpdate), + "agents.delete" => Ok(Self::AgentsDelete), + other => Err(SdkError::InvalidInput(format!( + "unknown broker capability \"{other}\"" + ))), + } + } +} + +/// 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 (64 lowercase hex characters). + Pubkey(String), + /// Target by the agent's current name. + Name(String), +} + +impl AgentTarget { + /// Validate and normalize the selector. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for an empty value, an over-long + /// name, or a pubkey that is not 64 hex characters. + pub fn validated(&self) -> Result { + match self { + Self::Pubkey(pubkey) => { + let pubkey = required(pubkey, "agent pubkey", 64)?; + if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput( + "agent pubkey must be 64 hex characters".into(), + )); + } + Ok(Self::Pubkey(pubkey.to_ascii_lowercase())) + } + Self::Name(name) => Ok(Self::Name(required(name, "agent name", MAX_NAME_CHARS)?)), + } + } +} + +/// Arguments for `agents.create`. +/// +/// `channelId` is present because creation genuinely needs it: the new agent is +/// attached to that channel. Update and delete carry no channel, since the +/// target is identified by the agent itself. +#[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()?, + }) + } +} + +/// A capability name paired with its strictly typed arguments. +/// +/// Flattened into [`super::BrokerRequest`], so the wire form is +/// `{ "capability": "agents.create", "args": { … } }` and an args shape can +/// never be paired with the wrong capability name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "capability", content = "args")] +pub enum CapabilityArgs { + /// 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 CapabilityArgs { + /// The capability these args belong to. + #[must_use] + pub fn capability(&self) -> Capability { + match self { + Self::AgentsCreate(_) => Capability::AgentsCreate, + Self::AgentsUpdate(_) => Capability::AgentsUpdate, + Self::AgentsDelete(_) => Capability::AgentsDelete, + } + } + + /// Validate the arguments in place. + /// + /// # Errors + /// + /// Propagates the per-capability validation error. + pub fn validate(&self) -> Result<(), SdkError> { + match self { + Self::AgentsCreate(args) => args.validated().map(|_| ()), + Self::AgentsUpdate(args) => args.validated().map(|_| ()), + Self::AgentsDelete(args) => args.validated().map(|_| ()), + } + } + + /// Return a normalized copy with every field validated. + /// + /// # Errors + /// + /// Propagates the per-capability validation error. + pub fn validated(&self) -> Result { + Ok(match self { + Self::AgentsCreate(args) => Self::AgentsCreate(args.validated()?), + Self::AgentsUpdate(args) => Self::AgentsUpdate(args.validated()?), + Self::AgentsDelete(args) => Self::AgentsDelete(args.validated()?), + }) + } +} + +/// Outcome of a successful `agents.create`. +/// +/// Carries the new agent's **public** key only. There is no field for the +/// minted secret: the nsec 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 (hex). + pub agent_pubkey: String, + /// 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 (hex). + pub agent_pubkey: String, + /// 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 (hex). + pub agent_pubkey: String, + /// The removed agent's name. + pub display_name: String, +} + +/// A capability-specific success payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "capability", content = "outcome")] +pub enum CapabilityOutcome { + /// `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 CapabilityOutcome { + /// The capability that produced this outcome. + #[must_use] + pub fn capability(&self) -> Capability { + match self { + Self::AgentsCreate(_) => Capability::AgentsCreate, + Self::AgentsUpdate(_) => Capability::AgentsUpdate, + Self::AgentsDelete(_) => Capability::AgentsDelete, + } + } +} + +// ── 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 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/mod.rs b/crates/buzz-sdk/src/broker/mod.rs new file mode 100644 index 0000000000..157c08cc1c --- /dev/null +++ b/crates/buzz-sdk/src/broker/mod.rs @@ -0,0 +1,401 @@ +//! Buzz trusted-operation broker — wire protocol. +//! +//! The broker lets a requester ask an owner's host to perform a small set of +//! named business operations ("capabilities") that require the owner's +//! credentials. It exposes business capabilities only: never signing, +//! publishing, credential access, or arbitrary tool execution. +//! +//! # Mental model +//! +//! ```text +//! requester → BrokerRequest → (signed, encrypted frame) → host broker +//! host: verify → authorize → validate → claim → dispatch → BrokerResult +//! ``` +//! +//! # What is *not* in this envelope +//! +//! Owner, requester, and relay scope are **transport-derived** on the host from +//! the verified frame. They are deliberately absent here: a request body that +//! could name its own owner would let any signer act on another owner's agents. +//! +//! There is no `authorization` field yet. One gets added, as a discriminated +//! object, when a real grant format and verifier exist — not before, so that no +//! field looks security-bearing while enforcing nothing. +//! +//! There is no client-computed digest. Idempotency is decided host-side; see +//! [`BrokerRequest`] for the retry contract. + +use serde::{Deserialize, Serialize}; + +use crate::SdkError; + +pub mod capabilities; + +pub use capabilities::{ + AgentTarget, AgentsCreateArgs, AgentsCreateOutcome, AgentsDeleteArgs, AgentsDeleteOutcome, + AgentsUpdateArgs, AgentsUpdateOutcome, Capability, CapabilityArgs, CapabilityOutcome, +}; + +/// Wire `type` discriminator for a broker request payload. +pub const BROKER_REQUEST_TYPE: &str = "broker_request"; + +/// Wire `type` discriminator for a broker result 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 capability. +/// +/// # Retry contract +/// +/// Retrying an operation 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 therefore must not regenerate, re-order, or re-render the payload +/// between attempts. +#[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, + /// Capability contract version the caller wrote `args` against. + pub capability_version: u16, + /// The capability to invoke, with its strictly typed arguments. + #[serde(flatten)] + pub capability: CapabilityArgs, +} + +impl BrokerRequest { + /// Build a request for `capability` with the current protocol version. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] if `request_id` is empty, longer than + /// [`MAX_REQUEST_ID_LEN`], or contains non-printable-ASCII bytes. + pub fn new( + request_id: impl Into, + capability: CapabilityArgs, + ) -> Result { + let request_id = request_id.into(); + let request = Self { + r#type: BROKER_REQUEST_TYPE.to_string(), + protocol_version: BROKER_PROTOCOL_VERSION, + request_id, + capability_version: capability.capability().current_version(), + capability, + }; + request.validate()?; + Ok(request) + } + + /// The capability this request invokes. + #[must_use] + pub fn capability(&self) -> Capability { + self.capability.capability() + } + + /// Validate every field the host must agree on before executing anything. + /// + /// Callers validate before sealing a frame; the host revalidates after + /// decrypting, because only the host's verdict is authoritative. + /// + /// # Errors + /// + /// Returns [`SdkError::InvalidInput`] for a wrong `type`, an unsupported + /// `protocolVersion` or `capabilityVersion`, a malformed `requestId`, or + /// capability 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 capability = self.capability(); + if self.capability_version != capability.current_version() { + return Err(SdkError::InvalidInput(format!( + "unsupported capabilityVersion {} for {} (expected {})", + self.capability_version, + capability.as_str(), + capability.current_version() + ))); + } + self.capability.validate() + } +} + +/// Validate a `requestId`: non-empty, bounded, printable ASCII. +/// +/// The bound and character set exist because this value becomes part of a +/// durable primary 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. Capability-specific +/// failures arrive as [`BrokerErrorCode::CapabilityFailed`] with detail in the +/// accompanying message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BrokerErrorCode { + /// The envelope or capability arguments failed validation. + InvalidRequest, + /// The `protocolVersion` is not supported by this host. + UnsupportedProtocolVersion, + /// The capability name is unknown to this host. + UnknownCapability, + /// The `capabilityVersion` is not supported for this capability. + UnsupportedCapabilityVersion, + /// The requester's signature or frame could not be verified. + Unauthenticated, + /// The requester is authenticated but not permitted this operation. + Unauthorized, + /// Reuse of a `requestId` with different request content. + RequestIdConflict, + /// The capability handler ran and reported a domain failure. + CapabilityFailed, + /// 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::UnknownCapability => "unknown_capability", + Self::UnsupportedCapabilityVersion => "unsupported_capability_version", + Self::Unauthenticated => "unauthenticated", + Self::Unauthorized => "unauthorized", + Self::RequestIdConflict => "request_id_conflict", + Self::CapabilityFailed => "capability_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::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 operator or capability-level reconciliation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum BrokerResult { + /// The capability completed and produced this outcome. + Succeeded { + /// Capability-specific success payload. + #[serde(flatten)] + outcome: CapabilityOutcome, + }, + /// The capability 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: CapabilityOutcome) -> 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 } + } + + /// Whether this is a terminal success. + #[must_use] + pub fn is_succeeded(&self) -> bool { + matches!(self, Self::Succeeded { .. }) + } + + /// 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 capability 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..3a3c2df1d6 --- /dev/null +++ b/crates/buzz-sdk/src/broker/tests.rs @@ -0,0 +1,382 @@ +//! Wire-contract tests for the broker envelope. + +use super::*; + +const CHANNEL: &str = "b2c38ca8-9ec3-411e-bab5-f9deab34d52e"; +const PUBKEY: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971"; + +fn create_args() -> CapabilityArgs { + CapabilityArgs::AgentsCreate(AgentsCreateArgs { + channel_id: CHANNEL.into(), + display_name: "Research helper".into(), + system_prompt: "Find sources.".into(), + runtime: None, + provider: None, + model: None, + respond_to: None, + }) +} + +#[test] +fn request_serializes_capability_beside_args() { + let request = BrokerRequest::new("req-1", create_args()).unwrap(); + let json = serde_json::to_value(&request).unwrap(); + + assert_eq!(json["type"], BROKER_REQUEST_TYPE); + assert_eq!(json["protocolVersion"], 1); + assert_eq!(json["requestId"], "req-1"); + assert_eq!(json["capability"], "agents.create"); + assert_eq!(json["capabilityVersion"], 1); + assert_eq!(json["args"]["displayName"], "Research helper"); + + // Unset optionals stay off the wire, so the payload cannot imply a + // runtime/provider/model the caller never chose. + assert!(json["args"].get("runtime").is_none()); + assert!(json["args"].get("respondTo").is_none()); + + let parsed: BrokerRequest = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, request); +} + +/// The envelope must not carry owner/requester/relay identity: those are +/// transport-derived. A body that could name its own owner would let any +/// signer act on another owner's agents. +#[test] +fn request_has_no_caller_supplied_identity_or_context() { + let json = serde_json::to_value(BrokerRequest::new("req-1", create_args()).unwrap()).unwrap(); + let object = json.as_object().unwrap(); + + for forbidden in [ + "ownerPubkey", + "owner", + "requesterPubkey", + "requester", + "relayUrl", + "relayScope", + "context", + "authorizer", + "scope", + "expiry", + "authorization", + "requestDigest", + "digest", + ] { + assert!( + !object.contains_key(forbidden), + "broker request must not carry \"{forbidden}\"" + ); + } + + let mut keys = object.keys().map(String::as_str).collect::>(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "args", + "capability", + "capabilityVersion", + "protocolVersion", + "requestId", + "type", + ] + ); +} + +#[test] +fn unknown_request_field_is_rejected() { + let mut json = + serde_json::to_value(BrokerRequest::new("req-1", create_args()).unwrap()).unwrap(); + json.as_object_mut() + .unwrap() + .insert("ownerPubkey".into(), serde_json::json!(PUBKEY)); + + let error = serde_json::from_value::(json).unwrap_err(); + assert!( + error.to_string().contains("ownerPubkey"), + "unexpected error: {error}" + ); +} + +/// A secret smuggled into args must fail to deserialize rather than reach a +/// mutation. +#[test] +fn secret_bearing_args_fail_to_deserialize() { + let json = serde_json::json!({ + "type": BROKER_REQUEST_TYPE, + "protocolVersion": 1, + "requestId": "req-1", + "capability": "agents.create", + "capabilityVersion": 1, + "args": { + "channelId": CHANNEL, + "displayName": "Sneaky", + "systemPrompt": "hi", + "envVars": { "ANTHROPIC_API_KEY": "sk-live" }, + }, + }); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn wrong_protocol_version_is_refused() { + let mut request = BrokerRequest::new("req-1", create_args()).unwrap(); + request.protocol_version = 2; + let error = request.validate().unwrap_err().to_string(); + assert!(error.contains("protocolVersion"), "unexpected: {error}"); +} + +#[test] +fn wrong_capability_version_is_refused() { + let mut request = BrokerRequest::new("req-1", create_args()).unwrap(); + request.capability_version = 7; + let error = request.validate().unwrap_err().to_string(); + assert!(error.contains("capabilityVersion"), "unexpected: {error}"); +} + +#[test] +fn unknown_capability_name_is_refused() { + let json = serde_json::json!({ + "type": BROKER_REQUEST_TYPE, + "protocolVersion": 1, + "requestId": "req-1", + "capability": "agents.exfiltrate", + "capabilityVersion": 1, + "args": {}, + }); + assert!(serde_json::from_value::(json).is_err()); + assert!(Capability::parse("agents.exfiltrate").is_err()); +} + +/// Signing, publishing, and credential access are not capabilities. Only +/// business operations are addressable. +#[test] +fn only_business_capabilities_are_addressable() { + for forbidden in [ + "sign", + "publish", + "keys.export", + "identity.nsec", + "tool.exec", + "agents.manage", + "agents.start", + ] { + assert!( + Capability::parse(forbidden).is_err(), + "\"{forbidden}\" must not be a capability" + ); + } + for allowed in ["agents.create", "agents.update", "agents.delete"] { + assert_eq!(Capability::parse(allowed).unwrap().as_str(), allowed); + } +} + +#[test] +fn request_id_must_be_present_bounded_and_printable() { + assert!(BrokerRequest::new("", create_args()).is_err()); + assert!(BrokerRequest::new("a".repeat(MAX_REQUEST_ID_LEN), create_args()).is_ok()); + assert!(BrokerRequest::new("a".repeat(MAX_REQUEST_ID_LEN + 1), create_args()).is_err()); + assert!(BrokerRequest::new("has space", create_args()).is_err()); + assert!(BrokerRequest::new("has\nnewline", create_args()).is_err()); +} + +#[test] +fn update_requires_at_least_one_change() { + let empty = AgentsUpdateArgs { + target: AgentTarget::Pubkey(PUBKEY.into()), + 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}"); +} + +#[test] +fn target_pubkey_must_be_hex_and_normalizes_case() { + assert!(AgentTarget::Pubkey("nothex".into()).validated().is_err()); + assert!(AgentTarget::Pubkey(PUBKEY[..40].into()) + .validated() + .is_err()); + let upper = AgentTarget::Pubkey(PUBKEY.to_ascii_uppercase()); + assert_eq!( + upper.validated().unwrap(), + AgentTarget::Pubkey(PUBKEY.into()) + ); +} + +#[test] +fn create_rejects_a_malformed_channel_uuid() { + let args = AgentsCreateArgs { + channel_id: "not-a-uuid".into(), + display_name: "A".into(), + system_prompt: "B".into(), + runtime: None, + provider: None, + model: None, + respond_to: None, + }; + let error = args.validated().unwrap_err().to_string(); + assert!(error.contains("channel"), "unexpected: {error}"); +} + +#[test] +fn 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: per the approved design, channel +/// appears only where the operation needs it (create attachment). +#[test] +fn update_and_delete_carry_no_channel() { + let update = serde_json::to_value(AgentsUpdateArgs { + target: AgentTarget::Pubkey(PUBKEY.into()), + 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::Pubkey(PUBKEY.into()), + }) + .unwrap(); + assert!(delete.get("channelId").is_none()); +} + +// ── Results ───────────────────────────────────────────────────────────────── + +#[test] +fn succeeded_result_carries_outcome_and_no_error() { + let response = BrokerResponse::new( + "req-1", + BrokerResult::succeeded(CapabilityOutcome::AgentsCreate(AgentsCreateOutcome { + agent_pubkey: PUBKEY.into(), + display_name: "Research helper".into(), + channel_id: CHANNEL.into(), + })), + ); + response.validate().unwrap(); + + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["type"], BROKER_RESULT_TYPE); + assert_eq!(json["status"], "succeeded"); + assert_eq!(json["capability"], "agents.create"); + assert_eq!(json["outcome"]["agentPubkey"], PUBKEY); + assert!(json.get("error").is_none()); + // `replayed` is response metadata and stays off the wire when false. + assert!(json.get("replayed").is_none()); + + let parsed: BrokerResponse = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, response); + assert!(parsed.result.is_succeeded()); + assert!(parsed.result.error().is_none()); +} + +#[test] +fn failed_and_indeterminate_are_distinct_and_carry_no_outcome() { + let failed = BrokerResult::failed(BrokerError::new( + BrokerErrorCode::CapabilityFailed, + "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"], "capability_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!(!indeterminate.is_succeeded()); +} + +#[test] +fn replay_metadata_rides_the_response_not_the_result() { + let result = BrokerResult::succeeded(CapabilityOutcome::AgentsDelete(AgentsDeleteOutcome { + agent_pubkey: PUBKEY.into(), + 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. + let stored = serde_json::to_value(&result).unwrap(); + assert!(stored.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::UnknownCapability, "unknown_capability"), + ( + BrokerErrorCode::UnsupportedCapabilityVersion, + "unsupported_capability_version", + ), + (BrokerErrorCode::Unauthenticated, "unauthenticated"), + (BrokerErrorCode::Unauthorized, "unauthorized"), + (BrokerErrorCode::RequestIdConflict, "request_id_conflict"), + (BrokerErrorCode::CapabilityFailed, "capability_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) + ); + } +} + +/// An outcome type must be structurally unable to carry secret material. +#[test] +fn outcomes_cannot_carry_secrets() { + let json = serde_json::json!({ + "capability": "agents.create", + "outcome": { + "agentPubkey": PUBKEY, + "displayName": "A", + "channelId": CHANNEL, + "privateKeyNsec": "nsec1deadbeef", + }, + }); + assert!( + serde_json::from_value::(json).is_err(), + "an outcome carrying a secret must not deserialize" + ); +} 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;