diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 27b9000b7bb..ebb0b3b1f07 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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, @@ -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, + created_at: nostr::Timestamp, +) -> Result { + let mut channel_ids: Vec = 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) -> 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, @@ -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() { @@ -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"); @@ -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 @@ -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::*; diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 5cc745d7b94..e6391e73bad 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -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" => {} @@ -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). @@ -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([]); diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913d..cac2eff7627 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -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). /// @@ -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, @@ -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 diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91e..7a9d1aaa9ea 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -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; @@ -374,7 +374,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), + | KIND_AGENT_PROFILE + | KIND_AGENT_CHANNEL_ADD_POLICY => Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION | KIND_GIFT_WRAP @@ -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 @@ -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, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 0dc6cbd5039..69016c6c1c5 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -7,11 +7,11 @@ use tracing::{info, warn}; use uuid::Uuid; use buzz_core::kind::{ - event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_DM_VISIBILITY, - KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, - KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + event_kind_u32, is_parameterized_replaceable, KIND_AGENT_CHANNEL_ADD_POLICY, + KIND_DM_VISIBILITY, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, + KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, + KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -33,7 +33,7 @@ pub fn is_admin_kind(kind: u32) -> bool { /// handled in `ingest_event()` before storage so we can short-circuit on /// duplicates without storing the event at all. pub fn is_side_effect_kind(kind: u32) -> bool { - matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_PROFILE | 41001..=41003 | 40099) + matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | KIND_AGENT_CHANNEL_ADD_POLICY | 41001..=41003 | 40099) } async fn evict_live_channel_subscriptions( @@ -211,7 +211,9 @@ pub async fn handle_side_effects( 9022 => handle_leave_request(tenant, event, state).await, // NIP-34: Git repo announcement → reserve name + seed manifest pointer. KIND_GIT_REPO_ANNOUNCEMENT => handle_git_repo_announcement(tenant, event, state).await, - KIND_AGENT_PROFILE => handle_agent_profile(tenant, event, state).await, + KIND_AGENT_CHANNEL_ADD_POLICY => { + handle_agent_channel_add_policy(tenant, event, state).await + } // kind:7 (reaction) handled inline in ingest_event() before storage. _ => Ok(()), } @@ -1163,18 +1165,20 @@ pub async fn emit_group_discovery_events( Ok(()) } -async fn handle_agent_profile( +async fn handle_agent_channel_add_policy( tenant: &TenantContext, event: &Event, state: &Arc, ) -> anyhow::Result<()> { let content: serde_json::Value = serde_json::from_str(&event.content) - .map_err(|e| anyhow::anyhow!("kind:10100 content parse error: {e}"))?; + .map_err(|e| anyhow::anyhow!("channel-add-policy content parse error: {e}"))?; let policy = content .get("channel_add_policy") .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("kind:10100 missing channel_add_policy field"))?; + .ok_or_else(|| { + anyhow::anyhow!("channel-add-policy event missing channel_add_policy field") + })?; let pubkey_bytes = event.pubkey.to_bytes().to_vec(); if state @@ -1193,7 +1197,7 @@ async fn handle_agent_profile( .set_channel_add_policy(tenant.community(), &pubkey_bytes, policy) .await?; - info!(pubkey = %hex::encode(&pubkey_bytes), policy, "kind:10100 channel_add_policy updated"); + info!(pubkey = %hex::encode(&pubkey_bytes), policy, "channel_add_policy updated"); Ok(()) } @@ -3378,6 +3382,15 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { mod tests { use super::*; + #[test] + fn routing_profiles_do_not_trigger_channel_add_policy_side_effects() { + assert!(is_side_effect_kind(KIND_AGENT_CHANNEL_ADD_POLICY)); + assert!( + !is_side_effect_kind(buzz_core::kind::KIND_AGENT_PROFILE), + "a runtime routing profile must never be parsed as a channel-add policy" + ); + } + #[test] fn group_members_snapshot_keeps_members_past_one_thousand() { let channel_id = Uuid::new_v4(); diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 5d5ad8916c3..fdce60d0f3c 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -1258,14 +1258,14 @@ async fn test_nip29_put_user_nobody_blocks() { let agent_keys = Keys::generate(); let agent_pubkey_hex = agent_keys.public_key().to_hex(); - // Set agent's channel_add_policy to "nobody" via kind:10100 event. + // Set agent's channel_add_policy to "nobody" via its dedicated event. let http_client = reqwest::Client::new(); let policy_event = EventBuilder::new( - Kind::Custom(10100), + Kind::Custom(buzz_core::kind::KIND_AGENT_CHANNEL_ADD_POLICY as u16), serde_json::json!({ "channel_add_policy": "nobody" }).to_string(), ) .sign_with_keys(&agent_keys) - .expect("sign kind:10100"); + .expect("sign channel-add-policy event"); let resp = http_client .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &agent_pubkey_hex) @@ -1320,14 +1320,14 @@ async fn test_nip29_put_user_self_add_bypasses_policy() { let agent_keys = Keys::generate(); let agent_pubkey_hex = agent_keys.public_key().to_hex(); - // Set agent's channel_add_policy to "nobody" via kind:10100 event. + // Set agent's channel_add_policy to "nobody" via its dedicated event. let http_client = reqwest::Client::new(); let policy_event = EventBuilder::new( - Kind::Custom(10100), + Kind::Custom(buzz_core::kind::KIND_AGENT_CHANNEL_ADD_POLICY as u16), serde_json::json!({ "channel_add_policy": "nobody" }).to_string(), ) .sign_with_keys(&agent_keys) - .expect("sign kind:10100"); + .expect("sign channel-add-policy event"); let resp = http_client .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &agent_pubkey_hex) @@ -1380,14 +1380,14 @@ async fn test_nip29_put_user_owner_only_blocks() { let agent_keys = Keys::generate(); let agent_pubkey_hex = agent_keys.public_key().to_hex(); - // Set agent's channel_add_policy to "owner_only" via kind:10100 event. + // Set agent's channel_add_policy to "owner_only" via its dedicated event. let http_client = reqwest::Client::new(); let policy_event = EventBuilder::new( - Kind::Custom(10100), + Kind::Custom(buzz_core::kind::KIND_AGENT_CHANNEL_ADD_POLICY as u16), serde_json::json!({ "channel_add_policy": "owner_only" }).to_string(), ) .sign_with_keys(&agent_keys) - .expect("sign kind:10100"); + .expect("sign channel-add-policy event"); let resp = http_client .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &agent_pubkey_hex) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index d7a6e759635..6c1f9975cb3 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -83,7 +83,7 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha ); }); -test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => { +test("relayAgentIsSharedWithUser: keeps allowlist agents private", () => { const sharedChannelIds = new Set(["general"]); assert.equal( @@ -94,9 +94,8 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" channelIds: ["other"], }, sharedChannelIds, - CURRENT_PUBKEY, ), - true, + false, ); assert.equal( relayAgentIsSharedWithUser( @@ -106,29 +105,24 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" channelIds: ["general"], }, sharedChannelIds, - CURRENT_PUBKEY, ), false, ); }); -test("relayAgentCanRespondInChannel: requires exact channel membership and viewer access", () => { +test("relayAgentCanRespondInChannel: requires exact channel membership and public access", () => { const agent = { - respondTo: "allowlist", - respondToAllowlist: [CURRENT_PUBKEY], + respondTo: "anyone", + respondToAllowlist: [], channelIds: ["general"], }; assert.equal( - relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY), + relayAgentCanRespondInChannel(agent, "general"), true, ); assert.equal( - relayAgentCanRespondInChannel(agent, "other", CURRENT_PUBKEY), - false, - ); - assert.equal( - relayAgentCanRespondInChannel(agent, "general", OTHER_OWNER_PUBKEY), + relayAgentCanRespondInChannel(agent, "other"), false, ); }); @@ -161,7 +155,7 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", sharedChannelIds: new Set(["general"]), }); - assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); + assert.deepEqual(result, new Set([PUB_A, PUB_B])); }); test("getMentionableAgentPubkeys: scopes channel composers and fails closed without context", () => { @@ -185,7 +179,7 @@ test("getMentionableAgentPubkeys: scopes channel composers and fails closed with ...base, eligibilityScope: { type: "channel", channelId: "general" }, }), - new Set([PUB_A, PUB_B]), + new Set([PUB_A]), ); assert.deepEqual( getMentionableAgentPubkeys({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index a4b235fa04c..4c9a7617593 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -12,18 +12,10 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) { export function relayAgentIsSharedWithUser( agent: Pick, sharedChannelIds: ReadonlySet, - currentPubkey?: string | null, ) { - const normalizedCurrentPubkey = currentPubkey - ? normalizePubkey(currentPubkey) - : null; - - if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { - return agent.respondToAllowlist - .map((pubkey) => normalizePubkey(pubkey)) - .includes(normalizedCurrentPubkey); - } - + // A remote agent's allowlist is private policy. The public runtime profile + // only proves that an `anyone` agent shares this channel; other modes must + // not become mentionable based on a community-visible recipient list. return ( agent.respondTo === "anyone" && agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)) @@ -33,11 +25,10 @@ export function relayAgentIsSharedWithUser( export function relayAgentCanRespondInChannel( agent: Pick, channelId: string, - currentPubkey?: string | null, ) { return ( agent.channelIds.includes(channelId) && - relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey) + relayAgentIsSharedWithUser(agent, new Set([channelId])) ); } @@ -47,7 +38,6 @@ export type AgentEligibilityScope = | { type: "managed-only" }; export function getMentionableAgentPubkeys({ - currentPubkey, eligibilityScope, managedAgentPubkeys, relayAgents, @@ -68,11 +58,10 @@ export function getMentionableAgentPubkeys({ eligibilityScope.type === "managed-only" ? false : eligibilityScope.type === "community" - ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) + ? relayAgentIsSharedWithUser(agent, sharedChannelIds) : relayAgentCanRespondInChannel( agent, eligibilityScope.channelId, - currentPubkey, ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey));