From ee233d722365207f13a36c4d198c7db625a1fe38 Mon Sep 17 00:00:00 2001 From: Bryce Del Rio Date: Thu, 30 Jul 2026 02:34:05 +0000 Subject: [PATCH 1/2] fix(desktop): mention eligibility for external agents from relay proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-attested external agents (kind:0 with verified NIP-OA attestation + kind:10100 directory entry) could not be @-mentioned: the candidate gate dropped every agent-classified identity not present in the local managed store, before the directory eligibility logic could run — making that shipped logic unreachable. Externally-run agents were mentionable only while UNattested (misclassified as people); registering them correctly made them unreachable. Rewrite the mention gate as one predicate deciding "will this agent receive the message and does its policy admit responding to you", from the same proofs other agent surfaces already trust: - a local managed record (the native path — unchanged, including the non-member auto-add flow), or - channel membership (candidates originate from the member list) plus the agent's own kind:10100 respond-to declaration: `anyone`, an allowlist naming the viewer, or `owner-only` when the viewer IS the agent's NIP-OA-verified owner (the same verified ownerPubkey the "managed by" header renders). Liveness is deliberately not part of the predicate: buzz-acp replays missed mentions on reconnect, and stopped managed agents are offered today. Agent-classified candidates with no local record and no directory declaration stay hidden, preserving the no-void-mentions guarantee the old gate encoded. Offering non-member external agents (auto-add honoring the directory's channel_add_policy) is an explicit follow-up. Replaces the stacked isAgentIdentityInManagedList + directory-set checks in useMentions with the single predicate; isAgentIdentityInManagedList remains for its members-sidebar caller. Unit tests cover the full policy matrix (anyone / allowlist in+out / owner-only as owner, non-owner, unverified / no declaration / non-member / managed member+non-member / unknown viewer / pubkey normalization). Co-Authored-By: Claude Fable 5 Signed-off-by: Bryce Del Rio --- .../lib/agentAutocompleteEligibility.test.mjs | 175 +++++++++++++----- .../lib/agentAutocompleteEligibility.ts | 106 ++++++++--- .../src/features/messages/lib/useMentions.ts | 33 ++-- 3 files changed, 223 insertions(+), 91 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..6a8ac4990f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -162,84 +162,161 @@ 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: hides external agents without a directory declaration", () => { + assert.equal(shouldHideAgentFromMentions(hideArgs()), 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({ - isAgent: true, - isMember: true, - pubkey: PUB_A, - mentionableAgentPubkeys: new Set(), - directoryAgentPubkeys: new Set(), - }), - false, + 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..e616d23036 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -64,37 +64,87 @@ 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 policy = relayAgentPolicies.get(pubkey); + if (!policy) return true; + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + switch (policy.respondTo) { + case "anyone": + return false; + case "allowlist": + return ( + normalizedCurrentPubkey === null || + !policy.respondToAllowlist + .map((entry) => normalizePubkey(entry)) + .includes(normalizedCurrentPubkey) + ); + case "owner-only": { + const ownerPubkey = candidate.ownerPubkey + ? normalizePubkey(candidate.ownerPubkey) + : null; + 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, ]); From 6b591523d9fc7220c4b18f102d314857ae306c26 Mon Sep 17 00:00:00 2001 From: Bryce Del Rio Date: Thu, 30 Jul 2026 07:00:35 +0000 Subject: [PATCH 2/2] fix(desktop): offer declaration-less attested agents to their verified owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Community feedback on #2987 surfaced two subcases the predicate left cold: an attested external agent with no kind:10100 at all (never published, or deleted), and — after the #2508 thread's cross-machine reports — the agent's own owner on a device that doesn't manage it (#2349, #3277). Both reduce to the same case: a member agent with a verified NIP-OA attestation and no directory declaration. Offer that candidate to its verified owner only. This is proof-sound: 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 needs no directory entry, which matters because nothing publishes entries for native agents yet. Strangers still see nothing: attribution without a declaration is not evidence the agent would answer them. Stale same-name identities an owner might now see fold into the live one via the existing same-label/same-owner coalescing, and NIP-IA archived identities are peeled before candidates are built — the two #2149-era layers this change leans on, both untouched. Tests: declaration-less agent hidden from strangers and unverified attributions, offered to the verified owner. Co-Authored-By: Claude Fable 5 Signed-off-by: Bryce Del Rio --- .../lib/agentAutocompleteEligibility.test.mjs | 20 ++++++++++++- .../lib/agentAutocompleteEligibility.ts | 28 ++++++++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 6a8ac4990f..0c7d4baa57 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -267,8 +267,26 @@ test("shouldHideAgentFromMentions: owner-only admits the verified owner", () => ); }); -test("shouldHideAgentFromMentions: hides external agents without a directory declaration", () => { +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( + 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", () => { diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e616d23036..c6580a7d06 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -117,11 +117,29 @@ export function shouldHideAgentFromMentions({ // 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 policy = relayAgentPolicies.get(pubkey); - if (!policy) 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; @@ -132,16 +150,12 @@ export function shouldHideAgentFromMentions({ .map((entry) => normalizePubkey(entry)) .includes(normalizedCurrentPubkey) ); - case "owner-only": { - const ownerPubkey = candidate.ownerPubkey - ? normalizePubkey(candidate.ownerPubkey) - : null; + case "owner-only": return ( normalizedCurrentPubkey === null || ownerPubkey === null || ownerPubkey !== normalizedCurrentPubkey ); - } default: return true; }