diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..0c7d4baa57 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -162,84 +162,179 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent ); }); +function hideArgs(overrides = {}) { + const { + candidate = {}, + currentPubkey = CURRENT_PUBKEY, + managedAgentPubkeys = new Set(), + relayAgentPolicies = new Map(), + } = overrides; + return { + candidate: { + isAgent: true, + isMember: true, + ownerPubkey: null, + pubkey: PUB_A, + ...candidate, + }, + currentPubkey, + managedAgentPubkeys, + relayAgentPolicies, + }; +} + +function policy(respondTo, respondToAllowlist = []) { + return new Map([[PUB_A, { respondTo, respondToAllowlist }]]); +} + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( - shouldHideAgentFromMentions({ - isAgent: false, - isMember: false, - pubkey: PUB_A, - mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set([PUB_A]), - }), + shouldHideAgentFromMentions( + hideArgs({ candidate: { isAgent: false, isMember: false } }), + ), false, ); }); -test("shouldHideAgentFromMentions: shows invocable agents even when non-member", () => { +test("shouldHideAgentFromMentions: always offers managed agents, member or not", () => { + // The local record is the desktop's own proof the agent will answer; it + // also covers the non-member auto-add flow for managed agents. + for (const isMember of [true, false]) { + assert.equal( + shouldHideAgentFromMentions( + hideArgs({ + candidate: { isMember }, + managedAgentPubkeys: new Set([PUB_A]), + }), + ), + false, + ); + } +}); + +test("shouldHideAgentFromMentions: offers external member agents declaring respond-to anyone", () => { assert.equal( - shouldHideAgentFromMentions({ - isAgent: true, - isMember: false, - pubkey: PUB_A, - mentionableAgentPubkeys: new Set([PUB_A]), - directoryAgentPubkeys: new Set([PUB_A]), - }), + shouldHideAgentFromMentions( + hideArgs({ relayAgentPolicies: policy("anyone") }), + ), false, ); }); -test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => { +test("shouldHideAgentFromMentions: allowlist admits only listed users", () => { assert.equal( - shouldHideAgentFromMentions({ - isAgent: true, - isMember: false, - pubkey: PUB_A, - mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set(), - }), + shouldHideAgentFromMentions( + hideArgs({ relayAgentPolicies: policy("allowlist", [CURRENT_PUBKEY]) }), + ), + false, + ); + assert.equal( + shouldHideAgentFromMentions( + hideArgs({ relayAgentPolicies: policy("allowlist", [OWNER_PUBKEY]) }), + ), true, ); }); -test("shouldHideAgentFromMentions: hides member agents with an explicit not-invocable directory entry (Fizz)", () => { +test("shouldHideAgentFromMentions: owner-only admits the verified owner", () => { + // ownerPubkey is the NIP-OA-verified value the "managed by" surface + // renders — external agents owned by the current user mention like + // managed ones. assert.equal( - shouldHideAgentFromMentions({ - isAgent: true, - isMember: true, - pubkey: PUB_A, - mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set([PUB_A]), - }), + shouldHideAgentFromMentions( + hideArgs({ + candidate: { ownerPubkey: CURRENT_PUBKEY }, + relayAgentPolicies: policy("owner-only"), + }), + ), + false, + ); + assert.equal( + shouldHideAgentFromMentions( + hideArgs({ + candidate: { ownerPubkey: OTHER_OWNER_PUBKEY }, + relayAgentPolicies: policy("owner-only"), + }), + ), + true, + ); + // Unverified ownership is no proof at all. + assert.equal( + shouldHideAgentFromMentions( + hideArgs({ relayAgentPolicies: policy("owner-only") }), + ), true, ); }); -test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => { +test("shouldHideAgentFromMentions: declaration-less agents are offered only to their verified owner", () => { + // No entry at all (stranger's view) → hidden. + assert.equal(shouldHideAgentFromMentions(hideArgs()), true); + // Same agent, viewed by its NIP-OA-verified owner → offered. The harness + // author gate admits the owner under every respond-to mode, so this is + // never a void chip; it covers owner-on-another-device (#2349, #3277). assert.equal( - shouldHideAgentFromMentions({ - isAgent: true, - isMember: true, - pubkey: PUB_A, - mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set(), - }), + shouldHideAgentFromMentions( + hideArgs({ candidate: { ownerPubkey: CURRENT_PUBKEY } }), + ), false, ); + // Unverified ownership stays hidden — attribution without proof is not + // enough. + assert.equal( + shouldHideAgentFromMentions( + hideArgs({ candidate: { ownerPubkey: OTHER_OWNER_PUBKEY } }), + ), + true, + ); +}); + +test("shouldHideAgentFromMentions: hides non-member external agents regardless of policy", () => { + // Offering these would need the add-via-mention flow to honor the + // directory's channel_add_policy — an explicit follow-up. + assert.equal( + shouldHideAgentFromMentions( + hideArgs({ + candidate: { isMember: false }, + relayAgentPolicies: policy("anyone"), + }), + ), + true, + ); }); -test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => { +test("shouldHideAgentFromMentions: hides agents when the viewer is unknown", () => { + for (const respondTo of ["allowlist", "owner-only"]) { + assert.equal( + shouldHideAgentFromMentions( + hideArgs({ + candidate: { ownerPubkey: OWNER_PUBKEY }, + currentPubkey: null, + relayAgentPolicies: policy(respondTo, [CURRENT_PUBKEY]), + }), + ), + true, + ); + } +}); + +test("shouldHideAgentFromMentions: normalizes pubkeys before every comparison", () => { const mixedCase = "Ab".repeat(32); const normalized = mixedCase.toLowerCase(); assert.equal( - shouldHideAgentFromMentions({ - isAgent: true, - isMember: true, - pubkey: mixedCase, - mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set([normalized]), - }), - true, + shouldHideAgentFromMentions( + hideArgs({ + candidate: { + ownerPubkey: CURRENT_PUBKEY.toUpperCase(), + pubkey: mixedCase, + }, + relayAgentPolicies: new Map([ + [normalized, { respondTo: "owner-only", respondToAllowlist: [] }], + ]), + }), + ), + false, ); }); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..c6580a7d06 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -64,37 +64,101 @@ export function isAgentIdentityInManagedList( ); } +/** The respond-to declaration from an agent's kind:10100 directory entry. */ +export type RelayAgentMentionPolicy = Pick< + RelayAgent, + "respondTo" | "respondToAllowlist" +>; + +/** + * Hide an agent-classified mention candidate unless there is evidence the + * mention will be received and answered. + * + * Offering a mention asserts "this agent will receive the message and its + * policy admits responding to you". That is decided from the same proofs + * other agent surfaces already trust, never from the local store alone: + * + * 1. A local managed record — this desktop runs the agent, so it can vouch + * directly (the native path, unchanged). + * 2. Channel membership (relay-authoritative — candidates originate from the + * member list) plus the agent's own kind:10100 respond-to declaration: + * `anyone`, an allowlist naming the current user, or `owner-only` when + * the current user IS the agent's owner. Ownership uses + * `candidate.ownerPubkey` — the NIP-OA-verified value the "managed by" + * surface renders — so external agents owned by the current user are + * mentionable exactly like managed ones. + * + * Liveness is deliberately not required: buzz-acp replays missed mentions on + * reconnect (`since` filter), and stopped managed agents are offered today. + * An agent-classified candidate with no local record and no directory + * declaration stays hidden — with no proof it will answer, a mention chip + * would fire into the void. + */ export function shouldHideAgentFromMentions({ - isAgent, - isMember, - pubkey, - mentionableAgentPubkeys, - directoryAgentPubkeys, + candidate, + currentPubkey, + managedAgentPubkeys, + relayAgentPolicies, }: { - isAgent: boolean; - isMember: boolean; - pubkey: string; - mentionableAgentPubkeys: ReadonlySet; - directoryAgentPubkeys: ReadonlySet; + candidate: { + isAgent?: boolean; + isMember?: boolean; + ownerPubkey?: string | null; + pubkey: string; + }; + currentPubkey: string | null | undefined; + managedAgentPubkeys: ReadonlySet; + relayAgentPolicies: ReadonlyMap; }) { - if (!isAgent) return false; - const normalized = normalizePubkey(pubkey); - // Invocable => always show. - if (mentionableAgentPubkeys.has(normalized)) return false; - // Non-member, non-invocable => hide (preserves prior behavior). - if (!isMember) return true; - // Member (Option B): hide only when we have an explicit not-invocable - // signal — a relay directory (kind:10100) entry that excludes us. - // Unknown invocability (not in directory) => show. - // - // NOTE: this assumes `directoryAgentPubkeys` and `mentionableAgentPubkeys` - // share the same source query (`relayAgentsQuery.data`), so directory - // presence without membership in `mentionableAgentPubkeys` is a real - // explicit-exclusion signal. If a future change sources the directory set - // from a different query, an agent that's directory-present but whose - // mentionability is still loading could be hidden prematurely — keep the - // two sets derived from the same query. - return directoryAgentPubkeys.has(normalized); + if (candidate.isAgent !== true) return false; + const pubkey = normalizePubkey(candidate.pubkey); + if (managedAgentPubkeys.has(pubkey)) return false; + // Offering a non-member external agent would need the add-via-mention flow + // to honor the agent's `channel_add_policy` declaration — a follow-up, not + // this change. Members only for now. + if (candidate.isMember !== true) return true; + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const ownerPubkey = candidate.ownerPubkey + ? normalizePubkey(candidate.ownerPubkey) + : null; + const policy = relayAgentPolicies.get(pubkey); + if (!policy) { + // No declaration: offer only to the verified owner. Sound because the + // harness's inbound author gate admits the owner under every respond-to + // mode, so an owner's mention is never a void chip — and it covers the + // owner-on-another-device reports (#2349, #3277) without requiring a + // directory entry that nothing publishes for native agents yet. For + // anyone else there is still no proof the agent will answer. Stale + // same-name identities an owner might now see fold into the live one + // via the same-label/same-owner coalescing (and NIP-IA archived + // identities are peeled before candidates are built). + return ( + normalizedCurrentPubkey === null || + ownerPubkey === null || + ownerPubkey !== normalizedCurrentPubkey + ); + } + switch (policy.respondTo) { + case "anyone": + return false; + case "allowlist": + return ( + normalizedCurrentPubkey === null || + !policy.respondToAllowlist + .map((entry) => normalizePubkey(entry)) + .includes(normalizedCurrentPubkey) + ); + case "owner-only": + return ( + normalizedCurrentPubkey === null || + ownerPubkey === null || + ownerPubkey !== normalizedCurrentPubkey + ); + default: + return true; + } } type AgentAutocompleteCandidate = { diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..a270a06eb3 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,7 +16,6 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -179,12 +178,16 @@ export function useMentions( ), [relayAgentsQuery.data], ); - const directoryAgentPubkeys = React.useMemo( + const relayAgentPolicies = React.useMemo( () => - new Set( - (relayAgentsQuery.data ?? []).map((agent) => + new Map( + (relayAgentsQuery.data ?? []).map((agent) => [ normalizePubkey(agent.pubkey), - ), + { + respondTo: agent.respondTo, + respondToAllowlist: agent.respondToAllowlist, + }, + ]), ), [relayAgentsQuery.data], ); @@ -246,16 +249,18 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { - return; - } if ( shouldHideAgentFromMentions({ - isAgent: candidate.isAgent === true, - isMember: candidate.isMember === true, - pubkey, - mentionableAgentPubkeys, - directoryAgentPubkeys, + candidate: { + isAgent: candidate.isAgent === true, + isMember: candidate.isMember === true, + ownerPubkey: + candidate.ownerPubkey ?? profiles?.[pubkey]?.ownerPubkey ?? null, + pubkey, + }, + currentPubkey, + managedAgentPubkeys, + relayAgentPolicies, }) ) { return; @@ -415,7 +420,6 @@ export function useMentions( userSearchResults, canSearchGlobalUsers, currentPubkey, - directoryAgentPubkeys, isArchivedDiscovery, managedAgentNamesByPubkey, managedAgentPersonaIds, @@ -428,6 +432,7 @@ export function useMentions( personaNameByPubkey, profiles, relayAgentNamesByPubkey, + relayAgentPolicies, relayAgentsQuery.data, ]);