diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 2cb2068d51..a687560bf1 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -110,6 +110,73 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" ); }); +test("relayAgentIsSharedWithUser: an owner-only agent is invocable by its owner", () => { + const agent = { + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + }; + const sharedChannelIds = new Set(["general"]); + + assert.equal( + relayAgentIsSharedWithUser(agent, sharedChannelIds, CURRENT_PUBKEY, true), + true, + ); + // Someone else's owner-only agent stays out of reach. + assert.equal( + relayAgentIsSharedWithUser(agent, sharedChannelIds, CURRENT_PUBKEY, false), + false, + ); + // Ownership is matched, never assumed: no viewer identity, no admission. + assert.equal( + relayAgentIsSharedWithUser(agent, sharedChannelIds, null, true), + false, + ); +}); + +test("getMentionableAgentPubkeys: admits an owner-only relay agent for its owner", () => { + const relayAgents = [ + { + pubkey: PUB_A, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + }, + { + pubkey: PUB_B, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + }, + ]; + const call = (overrides) => + getMentionableAgentPubkeys({ + currentPubkey: CURRENT_PUBKEY, + eligibilityScope: { type: "channel", channelId: "general" }, + managedAgentPubkeys: [], + relayAgents, + ...overrides, + }); + const profiles = { + [PUB_A]: { ownerPubkey: CURRENT_PUBKEY.toUpperCase() }, + [PUB_B]: { ownerPubkey: OTHER_OWNER_PUBKEY }, + }; + + assert.deepEqual([...call({ profiles })], [PUB_A]); + // The owner still only reaches the agent in channels it actually joined. + assert.deepEqual( + [ + ...call({ + eligibilityScope: { type: "channel", channelId: "other" }, + profiles, + }), + ], + [], + ); + // No owner metadata, no admission -- unchanged from before. + assert.deepEqual([...call({})], []); +}); + test("relayAgentCanRespondInChannel: requires exact channel membership and viewer access", () => { const agent = { respondTo: "allowlist", diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 0abdad82fa..de36873061 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,3 +1,5 @@ +import { mergeOwnedAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -13,11 +15,21 @@ export function relayAgentIsSharedWithUser( agent: Pick, sharedChannelIds: ReadonlySet, currentPubkey?: string | null, + isOwnedByViewer = false, ) { const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) : null; + // `owner-only` names exactly one person who may instruct the agent, so that + // person can invoke it. It is also the server default, so an agent created + // without an explicit mode used to be invocable by nobody at all. Ownership + // is matched, never assumed: an agent whose owner we cannot resolve stays + // hidden. + if (agent.respondTo === "owner-only") { + return isOwnedByViewer && normalizedCurrentPubkey !== null; + } + if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { return agent.respondToAllowlist .map((pubkey) => normalizePubkey(pubkey)) @@ -34,10 +46,16 @@ export function relayAgentCanRespondInChannel( agent: Pick, channelId: string, currentPubkey?: string | null, + isOwnedByViewer = false, ) { return ( agent.channelIds.includes(channelId) && - relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey) + relayAgentIsSharedWithUser( + agent, + new Set([channelId]), + currentPubkey, + isOwnedByViewer, + ) ); } @@ -46,33 +64,57 @@ export type AgentEligibilityScope = | { type: "channel"; channelId: string } | { type: "managed-only" }; +const EMPTY_CHANNEL_IDS: ReadonlySet = new Set(); + export function getMentionableAgentPubkeys({ currentPubkey, eligibilityScope, managedAgentPubkeys, + profiles, relayAgents, - sharedChannelIds, + sharedChannelIds = EMPTY_CHANNEL_IDS, }: { currentPubkey?: string | null; eligibilityScope: AgentEligibilityScope; managedAgentPubkeys: Iterable; + /** + * Profile metadata, the only place an agent's owner is published: the relay + * directory (`RelayAgent`) carries no owner field. Omit it and `owner-only` + * agents stay hidden, which is the pre-existing behaviour. + */ + profiles?: UserProfileLookup; relayAgents: readonly RelayAgent[] | undefined; - sharedChannelIds: ReadonlySet; + /** Read only by the `community` scope; the others derive their own. */ + sharedChannelIds?: ReadonlySet; }) { const pubkeys = new Set( [...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), ); + const ownedAgentPubkeys = mergeOwnedAgentPubkeys( + undefined, + profiles, + currentPubkey, + ); for (const agent of relayAgents ?? []) { + const isOwnedByViewer = ownedAgentPubkeys.has( + normalizePubkey(agent.pubkey), + ); const isAllowed = eligibilityScope.type === "managed-only" ? false : eligibilityScope.type === "community" - ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) + ? relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + isOwnedByViewer, + ) : relayAgentCanRespondInChannel( agent, eligibilityScope.channelId, currentPubkey, + isOwnedByViewer, ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index cd52b1bebf..fc77658433 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -5,10 +5,7 @@ import { useRelayAgentsQuery, useTeamsQuery, } from "@/features/agents/hooks"; -import { - useChannelMembersQuery, - useChannelsQuery, -} from "@/features/channels/hooks"; +import { useChannelMembersQuery } from "@/features/channels/hooks"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { @@ -16,7 +13,6 @@ import { coalesceAutocompleteCandidatesByKey, filterCachedAgentSuggestions, getMentionableAgentPubkeys, - getSharedChannelIds, isAgentIdentityInAllowedList, isAgentMentionChannelType, shouldHideAgentFromMentions, @@ -107,7 +103,6 @@ export function useMentions( const isArchivedDiscovery = useIsArchivedPredicate(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); - const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); const managedAgentDirectoryReady = @@ -190,10 +185,6 @@ export function useMentions( ), [relayAgentsQuery.data], ); - const sharedChannelIds = React.useMemo( - () => getSharedChannelIds(channelsQuery.data), - [channelsQuery.data], - ); const mentionChannelId = isAgentMentionChannelType(options?.channelType) ? channelId : null; @@ -205,15 +196,15 @@ export function useMentions( ? { type: "channel", channelId: mentionChannelId } : { type: "managed-only" }, managedAgentPubkeys, + profiles, relayAgents: relayAgentsQuery.data, - sharedChannelIds, }), [ currentPubkey, managedAgentPubkeys, mentionChannelId, + profiles, relayAgentsQuery.data, - sharedChannelIds, ], ); const personaNameByPubkey = React.useMemo(() => {