From 25b6f45c8a00bb010a7cf5ac90a9fb0332979183 Mon Sep 17 00:00:00 2001 From: dexsynccom Date: Tue, 4 Aug 2026 13:46:40 +0100 Subject: [PATCH 1/2] fix(desktop): let non-owners @-mention channel-member agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isAgentIdentityInManagedList (#2149) dropped every agent-flagged mention candidate not present in the local managed-agents list. Only the agent's owner has it there, so for everyone else the candidate vanished from both the autocomplete picker and the send-time p-tag extraction: typed @mentions went out as plain text, the harness's require_mention #p subscription never matched, and the agent silently ignored the message. Members could reach agents from mobile (which has no such gate) but not from desktop. Scope the gate to what #2149 actually wanted to hide — stale incarnations of the current user's own agents — by comparing the candidate's verified NIP-OA ownerPubkey with the current user. Foreign- or unknown-owned agents now fall through to shouldHideAgentFromMentions, whose member/directory policy applies again: member agents with unknown invocability are shown; non-member, non-invocable agents stay hidden. Moving formatSearchUserDisplayName, formatSearchUserSecondaryLabel and appendUniqueName into mentionCandidates.ts keeps useMentions.ts under the 1000-line ratchet. Co-Authored-By: Claude Fable 5 Signed-off-by: dexsynccom --- .../lib/agentAutocompleteEligibility.test.mjs | 58 ++++++++++++++++++- .../lib/agentAutocompleteEligibility.ts | 19 ++++-- .../features/channels/ui/MembersSidebar.tsx | 6 +- .../messages/lib/mentionCandidates.ts | 28 ++++++++- .../src/features/messages/lib/useMentions.ts | 30 ++++------ 5 files changed, 113 insertions(+), 28 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..2e5d3911b9 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -136,13 +136,14 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); -test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { +test("isAgentIdentityInManagedList: keeps people and current managed agent identities", () => { const managedAgentPubkeys = new Set([PUB_A]); assert.equal( isAgentIdentityInManagedList( { isAgent: false, pubkey: PUB_B }, managedAgentPubkeys, + CURRENT_PUBKEY, ), true, ); @@ -150,13 +151,68 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent isAgentIdentityInManagedList( { isAgent: true, pubkey: PUB_A.toUpperCase() }, managedAgentPubkeys, + CURRENT_PUBKEY, ), true, ); +}); + +test("isAgentIdentityInManagedList: keeps agents owned by others or with unknown owner", () => { + const managedAgentPubkeys = new Set([PUB_A]); + + // Foreign-owned agent (e.g. a channel-member agent someone else manages) — + // not ours to prune; directory policy decides visibility downstream. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B, ownerPubkey: OTHER_OWNER_PUBKEY }, + managedAgentPubkeys, + CURRENT_PUBKEY, + ), + true, + ); + // Unknown ownership (no verified NIP-OA tag) — keep. assert.equal( isAgentIdentityInManagedList( { isAgent: true, pubkey: PUB_B }, managedAgentPubkeys, + CURRENT_PUBKEY, + ), + true, + ); + // Unknown current user — cannot attribute, keep. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B, ownerPubkey: CURRENT_PUBKEY }, + managedAgentPubkeys, + null, + ), + true, + ); +}); + +test("isAgentIdentityInManagedList: drops stale self-owned agent identities", () => { + const managedAgentPubkeys = new Set([PUB_A]); + + // Proves our ownership but is not a current managed identity — a stale + // incarnation of one of our own recreated agents. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B, ownerPubkey: CURRENT_PUBKEY }, + managedAgentPubkeys, + CURRENT_PUBKEY, + ), + false, + ); + // Owner comparison is case-insensitive. + assert.equal( + isAgentIdentityInManagedList( + { + isAgent: true, + pubkey: PUB_B, + ownerPubkey: CURRENT_PUBKEY.toUpperCase(), + }, + managedAgentPubkeys, + CURRENT_PUBKEY, ), false, ); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..a1299b8d67 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -55,13 +55,22 @@ export function getMentionableAgentPubkeys({ } export function isAgentIdentityInManagedList( - candidate: { isAgent?: boolean; pubkey: string }, + candidate: { isAgent?: boolean; pubkey: string; ownerPubkey?: string | null }, managedAgentPubkeys: ReadonlySet, + currentPubkey?: string | null, ) { - return ( - candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) - ); + if (candidate.isAgent !== true) return true; + if (managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))) return true; + // Only gate agents the current user owns: an agent identity that proves our + // ownership (verified NIP-OA tag) but is absent from our managed list is a + // stale incarnation of one of our own agents — hide it. Agents owned by + // someone else (or with unknown ownership) are not ours to prune; they fall + // through to shouldHideAgentFromMentions, which applies directory policy. + const owner = candidate.ownerPubkey + ? normalizePubkey(candidate.ownerPubkey) + : null; + const current = currentPubkey ? normalizePubkey(currentPubkey) : null; + return owner === null || current === null || owner !== current; } export function shouldHideAgentFromMentions({ diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a2..ae725d15a9 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -282,7 +282,11 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInManagedList( + candidate, + managedAgentPubkeys, + currentPubkey, + ) ) { return; } diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 0498bef9ae..d4af4d584a 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -1,5 +1,10 @@ import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; -import type { AgentPersona, AgentTeam, ChannelRole } from "@/shared/api/types"; +import type { + AgentPersona, + AgentTeam, + ChannelRole, + UserSearchResult, +} from "@/shared/api/types"; import { truncatePubkey } from "@/shared/lib/pubkey"; export type TeamMentionMember = { @@ -130,3 +135,24 @@ export function formatTeamMention( ) { return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `; } + +export function formatSearchUserDisplayName(user: UserSearchResult) { + return user.displayName?.trim() || user.nip05Handle?.trim() || null; +} + +export function formatSearchUserSecondaryLabel(user: UserSearchResult) { + const displayName = user.displayName?.trim(); + const nip05Handle = user.nip05Handle?.trim(); + if (displayName && nip05Handle) { + return nip05Handle; + } + return null; +} + +export function appendUniqueName(current: string[], name: string): string[] { + return current.some( + (candidate) => candidate.toLowerCase() === name.toLowerCase(), + ) + ? current + : [...current, name]; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..950c1bd8c5 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -29,7 +29,6 @@ import type { AgentPersona, ChannelMember, ChannelType, - UserSearchResult, } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; @@ -41,7 +40,10 @@ import { useDraftMentionRouting } from "./useDraftMentionRouting"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { + appendUniqueName, buildTeamMentionCandidates, + formatSearchUserDisplayName, + formatSearchUserSecondaryLabel, formatTeamMention, globalSearchIdentityKey, type MentionCandidate, @@ -56,24 +58,6 @@ export type PersonaMentionTarget = { type UseMentionsOptions = { channelType?: ChannelType | null; }; -function formatSearchUserDisplayName(user: UserSearchResult) { - return user.displayName?.trim() || user.nip05Handle?.trim() || null; -} -function formatSearchUserSecondaryLabel(user: UserSearchResult) { - const displayName = user.displayName?.trim(); - const nip05Handle = user.nip05Handle?.trim(); - if (displayName && nip05Handle) { - return nip05Handle; - } - return null; -} -function appendUniqueName(current: string[], name: string): string[] { - return current.some( - (candidate) => candidate.toLowerCase() === name.toLowerCase(), - ) - ? current - : [...current, name]; -} export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -246,7 +230,13 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if ( + !isAgentIdentityInManagedList( + candidate, + managedAgentPubkeys, + currentPubkey, + ) + ) { return; } if ( From 9e249b08eef8fbad88282c7d5f7ebada29865ae5 Mon Sep 17 00:00:00 2001 From: dexsynccom Date: Tue, 4 Aug 2026 13:46:49 +0100 Subject: [PATCH 2/2] feat(acp): publish kind:10100 agent directory profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients already consume kind:10100 for mention eligibility (desktop list_relay_agents, mobile agentDirectoryProvider) and the relay ingests it with a users-table side effect, but nothing ever published it — so an agent's respond_to/allowlist policy and channel set were invisible to other users' clients, and respond_to=anyone had no effect on who could see the agent in mention autocomplete. Publish the profile at the presence-online readiness boundary and republish whenever a membership notification changes the subscribed channel set. Content carries the cross-client contract fields: respond_to, respond_to_allowlist, channel_ids, channel_add_policy. Discovery metadata only — the author gate (author_allowed) remains the enforcement point. Co-Authored-By: Claude Fable 5 Signed-off-by: dexsynccom --- crates/buzz-acp/src/lib.rs | 141 +++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4ac..33a229ea81 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -90,6 +90,97 @@ async fn publish_presence( Ok(()) } +/// Build the kind:10100 agent-profile content JSON. +/// +/// Field names are the cross-client contract: desktop reads them in +/// `agents_from_events` and mobile in `AgentDirectoryEntry.fromEvent`, and the +/// relay's ingest side effect requires `channel_add_policy`. Collections are +/// normalized (lowercased pubkeys) and sorted so republishes are +/// deterministic. +fn agent_profile_content( + respond_to: &RespondTo, + allowlist: &HashSet, + channel_ids: &HashSet, +) -> String { + let mut allow: Vec = allowlist.iter().map(|pk| pk.to_ascii_lowercase()).collect(); + allow.sort(); + let mut channels: Vec = channel_ids.iter().map(Uuid::to_string).collect(); + channels.sort(); + serde_json::json!({ + "respond_to": respond_to.to_string(), + "respond_to_allowlist": allow, + "channel_ids": channels, + "channel_add_policy": "anyone", + }) + .to_string() +} + +/// Publish/refresh the agent's kind:10100 directory profile (replaceable). +/// +/// Self-attested discovery metadata for client UX — mention pickers, +/// "managed by" labels, and respond_to-aware eligibility. Without it, +/// clients cannot see that `respond_to=anyone` and hide the agent from +/// non-owners' mention autocomplete. Enforcement stays in `author_allowed`; +/// clients must treat this as advisory. +async fn publish_agent_profile( + publisher: &relay::RelayEventPublisher, + keys: &nostr::Keys, + respond_to: &RespondTo, + allowlist: &HashSet, + channel_ids: &HashSet, +) -> Result<(), relay::RelayError> { + use buzz_core::kind::KIND_AGENT_PROFILE; + use nostr::{EventBuilder, Kind}; + + let content = agent_profile_content(respond_to, allowlist, channel_ids); + let event = EventBuilder::new(Kind::Custom(KIND_AGENT_PROFILE as u16), content) + .tags([]) + .sign_with_keys(keys) + .map_err(|e| relay::RelayError::Http(format!("agent profile sign error: {e}")))?; + publisher.publish_event(event).await?; + Ok(()) +} + +#[cfg(test)] +mod agent_profile_content_tests { + use super::*; + + #[test] + fn content_matches_cross_client_contract() { + let mut allowlist = HashSet::new(); + allowlist.insert("B".repeat(64)); + allowlist.insert("a".repeat(64)); + let mut channels = HashSet::new(); + let ch = Uuid::parse_str("af804d33-a3c1-4415-858e-3747c296a613").unwrap(); + channels.insert(ch); + + let content = agent_profile_content(&RespondTo::Allowlist, &allowlist, &channels); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + + assert_eq!(parsed["respond_to"], "allowlist"); + assert_eq!( + parsed["respond_to_allowlist"], + serde_json::json!(["a".repeat(64), "b".repeat(64)]), + ); + assert_eq!(parsed["channel_ids"], serde_json::json!([ch.to_string()])); + assert_eq!(parsed["channel_add_policy"], "anyone"); + } + + #[test] + fn respond_to_strings_match_client_comparisons() { + for (mode, expected) in [ + (RespondTo::Anyone, "anyone"), + (RespondTo::OwnerOnly, "owner-only"), + (RespondTo::Allowlist, "allowlist"), + (RespondTo::Nobody, "nobody"), + ] { + let content = agent_profile_content(&mode, &HashSet::new(), &HashSet::new()); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed["respond_to"], expected); + } + } +} + fn emit_runtime_lifecycle( observer: Option<&observer::ObserverHandle>, start_nonce: &str, @@ -1566,6 +1657,29 @@ async fn tokio_main() -> Result<()> { } } + // Advertise the agent in the relay directory (kind:10100) so other + // clients can see its respond_to policy and offer it in mention + // autocomplete. Published after channel subscriptions so channel_ids + // reflects the live set. Best-effort: a miss self-heals on the next + // restart or membership change. + if let Err(e) = publish_agent_profile( + &presence_publisher, + &presence_keys, + &config.respond_to, + &config.respond_to_allowlist, + &subscribed_channel_ids, + ) + .await + { + tracing::warn!("failed to publish agent profile (kind:10100): {e}"); + } else { + tracing::info!( + channels = subscribed_channel_ids.len(), + respond_to = %config.respond_to, + "agent profile (kind:10100) published" + ); + } + if config.lazy_pool { emit_runtime_lifecycle( observer.as_ref(), @@ -2044,6 +2158,20 @@ async fn tokio_main() -> Result<()> { tracing::warn!("failed to subscribe to new channel {ch}: {e}"); } else { subscribed_channel_ids.insert(ch); + // Keep the directory profile's channel_ids + // fresh so the new channel's members can + // mention this agent without an agent restart. + if let Err(e) = publish_agent_profile( + &presence_publisher, + &presence_keys, + &config.respond_to, + &config.respond_to_allowlist, + &subscribed_channel_ids, + ) + .await + { + tracing::warn!("failed to refresh agent profile (kind:10100): {e}"); + } } } else { tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping"); @@ -2054,6 +2182,19 @@ async fn tokio_main() -> Result<()> { if let Err(e) = relay.unsubscribe_channel(ch).await { tracing::warn!("failed to unsubscribe from channel {ch}: {e}"); } + // Drop the channel from the directory profile so + // clients stop offering this agent there. + if let Err(e) = publish_agent_profile( + &presence_publisher, + &presence_keys, + &config.respond_to, + &config.respond_to_allowlist, + &subscribed_channel_ids, + ) + .await + { + tracing::warn!("failed to refresh agent profile (kind:10100): {e}"); + } // Drain queued events and invalidate sessions for the // removed channel. Events already in-flight will // complete normally (the relay may reject actions if