Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 192 additions & 2 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use std::time::Duration;
use acp::{AcpClient, EnvVar, McpServer};
use anyhow::Result;
use buzz_core::kind::{
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_AGENT_PROFILE, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
};
use buzz_core::observer::{
decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY,
Expand Down Expand Up @@ -90,6 +90,86 @@ async fn publish_presence(
Ok(())
}

fn build_agent_profile_event(
keys: &nostr::Keys,
name: &str,
respond_to: &RespondTo,
channel_ids: &HashSet<Uuid>,
created_at: nostr::Timestamp,
) -> Result<nostr::Event, relay::RelayError> {
let mut channel_ids: Vec<String> = channel_ids.iter().map(Uuid::to_string).collect();
channel_ids.sort();
let content = serde_json::json!({
"name": name,
"agent_type": "agent",
"channel_ids": channel_ids,
"respond_to": respond_to.to_string(),
"status": "online",
})
.to_string();

nostr::EventBuilder::new(nostr::Kind::Custom(KIND_AGENT_PROFILE as u16), content)
.tags([])
.custom_created_at(created_at)
.sign_with_keys(keys)
.map_err(|error| relay::RelayError::Http(format!("agent profile sign error: {error}")))
}

/// Serializes durable agent-profile writes from the harness event loop.
///
/// Membership notifications are processed in order by that loop. Keeping the
/// publisher there (rather than spawning snapshots) means a later remove cannot
/// be overtaken by a delayed earlier add. The clock also makes same-second
/// replaceable events deterministic at the relay.
struct AgentProfilePublisher {
rest_client: relay::RestClient,
keys: nostr::Keys,
name: String,
respond_to: RespondTo,
last_created_at: u64,
}

fn next_profile_created_at(now: u64, prior_created_at: u64) -> u64 {
now.max(prior_created_at.saturating_add(1))
}

impl AgentProfilePublisher {
fn new(rest_client: relay::RestClient, keys: nostr::Keys, respond_to: RespondTo) -> Self {
let name = std::env::var("BUZZ_ACP_AGENT_NAME")
.or_else(|_| std::env::var("AGENT_NAME"))
.unwrap_or_else(|_| keys.public_key().to_hex());
Self {
rest_client,
keys,
name,
respond_to,
last_created_at: 0,
}
}

fn next_created_at(&mut self, now: u64) -> nostr::Timestamp {
let created_at = next_profile_created_at(now, self.last_created_at);
self.last_created_at = created_at;
nostr::Timestamp::from(created_at)
}

async fn publish(&mut self, channel_ids: &HashSet<Uuid>) -> Result<(), relay::RelayError> {
let created_at = self.next_created_at(nostr::Timestamp::now().as_secs());
let event = build_agent_profile_event(
&self.keys,
&self.name,
&self.respond_to,
channel_ids,
created_at,
)?;

// Agent profiles are durable replaceable events. The HTTP bridge retries
// their submission, unlike the socket publisher which is tuned for
// disposable presence and typing updates.
self.rest_client.submit_event(&event).await.map(|_| ())
}
}

fn emit_runtime_lifecycle(
observer: Option<&observer::ObserverHandle>,
start_nonce: &str,
Expand Down Expand Up @@ -1969,6 +2049,25 @@ async fn tokio_main() -> Result<()> {
}
}

let mut agent_profile_publisher = AgentProfilePublisher::new(
relay.rest_client(),
config.keys.clone(),
config.respond_to.clone(),
);
match agent_profile_publisher
.publish(&subscribed_channel_ids)
.await
{
Ok(()) => tracing::info!(
channel_count = subscribed_channel_ids.len(),
"published agent profile"
),
Err(error) => tracing::warn!(
channel_count = subscribed_channel_ids.len(),
"failed to publish agent profile: {error}"
),
}

if let Some((observer, publisher, keys, agent_pubkey, owner_pubkey, owner)) =
relay_observer_publisher.take()
{
Expand Down Expand Up @@ -2507,6 +2606,15 @@ async fn tokio_main() -> Result<()> {
tracing::warn!("failed to subscribe to new channel {ch}: {e}");
} else {
subscribed_channel_ids.insert(ch);
if let Err(error) = agent_profile_publisher
.publish(&subscribed_channel_ids)
.await
{
tracing::warn!(
channel_count = subscribed_channel_ids.len(),
"failed to refresh agent profile after membership change: {error}"
);
}
}
} else {
tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping");
Expand All @@ -2517,6 +2625,15 @@ async fn tokio_main() -> Result<()> {
if let Err(e) = relay.unsubscribe_channel(ch).await {
tracing::warn!("failed to unsubscribe from channel {ch}: {e}");
}
if let Err(error) = agent_profile_publisher
.publish(&subscribed_channel_ids)
.await
{
tracing::warn!(
channel_count = subscribed_channel_ids.len(),
"failed to refresh agent profile after membership change: {error}"
);
}
// Drain queued events and invalidate sessions for the
// removed channel. Events already in-flight will
// complete normally (the relay may reject actions if
Expand Down Expand Up @@ -5048,6 +5165,79 @@ mod owner_control_command_tests {
}
}

#[cfg(test)]
mod agent_profile_tests {
use super::*;

#[test]
fn agent_profile_advertises_the_current_routing_policy() {
let keys = nostr::Keys::generate();
let channel_a = Uuid::new_v4();
let channel_b = Uuid::new_v4();
let event = build_agent_profile_event(
&keys,
"Monty",
&RespondTo::Allowlist,
&HashSet::from([channel_b, channel_a]),
nostr::Timestamp::from(1_000),
)
.expect("profile should sign");
let content: serde_json::Value =
serde_json::from_str(&event.content).expect("profile content should be JSON");

assert_eq!(event.kind.as_u16(), KIND_AGENT_PROFILE as u16);
assert_eq!(content["name"], "Monty");
assert_eq!(content["respond_to"], "allowlist");
assert!(
content.get("respond_to_allowlist").is_none(),
"the public runtime profile must not disclose private allowlist pubkeys"
);
let mut expected_channel_ids = vec![channel_a.to_string(), channel_b.to_string()];
expected_channel_ids.sort();
assert_eq!(
content["channel_ids"],
serde_json::json!(expected_channel_ids)
);
}

#[test]
fn newest_membership_profile_wins_even_if_the_older_publish_arrives_last() {
let keys = nostr::Keys::generate();
let channel = Uuid::new_v4();
let add_created_at = next_profile_created_at(1_000, 0);
let remove_created_at = next_profile_created_at(1_000, add_created_at);
let add = build_agent_profile_event(
&keys,
"Monty",
&RespondTo::Anyone,
&HashSet::from([channel]),
nostr::Timestamp::from(add_created_at),
)
.expect("add profile should sign");
let remove = build_agent_profile_event(
&keys,
"Monty",
&RespondTo::Anyone,
&HashSet::new(),
nostr::Timestamp::from(remove_created_at),
)
.expect("remove profile should sign");

// Deliver the remove before the delayed add. NIP replaceable-event
// selection retains the higher timestamp, so the retained head still
// reflects the final membership state.
assert!(remove.created_at > add.created_at);
let retained = if remove.created_at > add.created_at {
&remove
} else {
&add
};
let content: serde_json::Value =
serde_json::from_str(&retained.content).expect("profile content should be JSON");
assert_eq!(content["channel_ids"], serde_json::json!([]));
}
}

#[cfg(test)]
mod owner_cache_tests {
use super::*;
Expand Down
6 changes: 3 additions & 3 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,7 @@ pub async fn cmd_remove_channel_member(
Ok(())
}

/// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event.
/// Set the channel addition policy without replacing the agent routing profile.
pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), CliError> {
match policy {
"anyone" | "owner_only" | "nobody" => {}
Expand All @@ -1027,7 +1027,7 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(),

// Check if this policy is allowed by the deployment.
// NOTE: This gate covers only the `buzz channels set-add-policy` CLI path.
// A client that submits a kind:10100 event directly to the relay bypasses
// A client that submits a policy event directly to the relay bypasses
// this check. Full enforcement requires relay-side validation, which is
// intentionally out of scope for this change (see team decision: no
// relay-side enforcement of client behavior).
Expand All @@ -1048,7 +1048,7 @@ pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(),
let content = serde_json::json!({ "channel_add_policy": policy }).to_string();
use nostr::{EventBuilder, Kind};
let builder = EventBuilder::new(
Kind::Custom(buzz_sdk::kind::KIND_AGENT_PROFILE as u16),
Kind::Custom(buzz_sdk::kind::KIND_AGENT_CHANNEL_ADD_POLICY as u16),
&content,
)
.tags([]);
Expand Down
14 changes: 13 additions & 1 deletion crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,18 @@ pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243;
pub const KIND_HTTP_AUTH: u32 = 27235;

// NEW: Buzz command kinds (Pure Nostr plan)
/// Agent metadata + owner reference (replaceable, agent-authored).
/// Public runtime routing advertisement (replaceable, agent-authored).
///
/// This event intentionally contains only information that is safe to expose
/// community-wide: the agent's name, response mode, and subscribed channels.
/// Private response allowlists belong in the shared-gated persona definition,
/// never in this event.
pub const KIND_AGENT_PROFILE: u32 = 10100;
/// Channel-add policy command (replaceable, agent-authored).
///
/// Kept separate from [`KIND_AGENT_PROFILE`] so a policy change cannot replace
/// an agent's public runtime routing advertisement.
pub const KIND_AGENT_CHANNEL_ADD_POLICY: u32 = 10101;

/// NIP-AE: Agent Engram (parameterized replaceable, agent-authored).
///
Expand Down Expand Up @@ -650,6 +660,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_GIFT_WRAP,
KIND_FILE_METADATA,
KIND_AGENT_PROFILE,
KIND_AGENT_CHANNEL_ADD_POLICY,
KIND_AGENT_ENGRAM,
KIND_EVENT_REMINDER,
KIND_PERSONA,
Expand Down Expand Up @@ -853,6 +864,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {

// Compile-time: new kinds are in the expected ranges.
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999
const _: () = assert!(is_replaceable(KIND_AGENT_CHANNEL_ADD_POLICY)); // 10101 ∈ 10000–19999
const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999
Expand Down
54 changes: 29 additions & 25 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,29 @@ use uuid::Uuid;
use buzz_auth::Scope;
use buzz_core::kind::{
event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable,
is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC,
KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET,
KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN,
KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT,
KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST,
KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT,
KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST,
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE,
KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION,
KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM,
KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER,
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
is_relay_admin_kind, KIND_AGENT_CHANNEL_ADD_POLICY, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE,
KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST,
KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER,
KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER,
KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED,
KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN,
KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN,
KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT,
KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST,
KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST,
KIND_PRESENCE_UPDATE, KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE,
KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS,
KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
Expand Down Expand Up @@ -374,7 +374,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
// palette is the client-side union of every member's own set.
| KIND_EMOJI_SET
| KIND_EMOJI_LIST
| KIND_AGENT_PROFILE => Ok(Scope::UsersWrite),
| KIND_AGENT_PROFILE
| KIND_AGENT_CHANNEL_ADD_POLICY => Ok(Scope::UsersWrite),
KIND_DELETION
| KIND_REACTION
| KIND_GIFT_WRAP
Expand Down Expand Up @@ -552,8 +553,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_AGENT_ENGRAM
// NIP-ER event reminders are addressed by (pubkey, kind, d_tag); never channel-scoped.
| KIND_EVENT_REMINDER
// Agent profile (10100): user-owned replaceable, keyed by pubkey.
// Agent profile and channel-add policy: user-owned replaceable,
// keyed by pubkey.
| KIND_AGENT_PROFILE
| KIND_AGENT_CHANNEL_ADD_POLICY
// NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag).
| KIND_PERSONA
// NIP-AP: team (30176) + managed-agent (30177) definitions and the
Expand Down Expand Up @@ -3619,6 +3622,7 @@ mod tests {
KIND_EMOJI_LIST,
KIND_AGENT_ENGRAM,
KIND_AGENT_PROFILE,
KIND_AGENT_CHANNEL_ADD_POLICY,
KIND_PERSONA,
KIND_TEAM,
KIND_MANAGED_AGENT,
Expand Down
Loading