From 1f7e36cb89b03bc1699f1eb29128fffb737ad190 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 31 Aug 2026 13:42:37 -0400 Subject: [PATCH] fix(desktop): authorize remote mentions at the publication boundary Extract preparation, exact destination checks and retained recipient intent from #7114, independently of labels and profile presentation. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson (cherry picked from commit 1976a2ac953b53d25df831b97d22c5318394e7c1) Signed-off-by: Logan Johnson (cherry picked from commit ffac74d5a64c08a5d3bdad85a8f69c42ff75e232) Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 1 + desktop/src/features/agents/AGENTS.md | 6 +- .../lib/agentAutocompleteEligibility.test.mjs | 89 +++++++ .../lib/agentAutocompleteEligibility.ts | 53 +++- .../src/features/forum/ui/ForumComposer.tsx | 6 +- .../lib/agentMentionRevalidation.test.mjs | 194 +++++++++++--- .../messages/lib/agentMentionRevalidation.ts | 69 +++-- .../messages/lib/useDraftMentionRouting.ts | 6 + .../src/features/messages/lib/useMentions.ts | 34 +-- .../messages/ui/submitMessageEdit.test.mjs | 15 +- .../features/messages/ui/submitMessageEdit.ts | 29 ++- .../ui/useMentionSendFlow.helpers.test.mjs | 22 ++ .../messages/ui/useMentionSendFlow.helpers.ts | 34 +++ .../messages/ui/useMentionSendFlow.ts | 62 +++-- desktop/src/testing/e2eBridge.ts | 12 +- desktop/tests/e2e/mentions.spec.ts | 79 ++++-- .../tests/e2e/remote-owned-mentions.spec.ts | 246 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 1 + docs/remote-mention-routing.md | 31 +++ 19 files changed, 855 insertions(+), 134 deletions(-) create mode 100644 desktop/tests/e2e/remote-owned-mentions.spec.ts create mode 100644 docs/remote-mention-routing.md diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 03f8538a37..e016c9e3f2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -74,6 +74,7 @@ export default defineConfig({ "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/remote-owned-mentions.spec.ts", "**/mention-spacing.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index c332fb7299..19ed775b90 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -272,7 +272,11 @@ with a TypeScript lookup table or an id comparison in a component. managed agent. Independently operated relay agents with NIP-OA ownership remain eligible in every build when their verified owner's signed `respond_to` policy admits the viewer and relay membership includes the - target channel. Marked builds require that verified owner coordinate but do + target channel at publication. Owned nonmembers may be offered for preparation + and Invite; this is not permission to publish. Final authorization refreshes + the exact destination and retains captured selected identities across uploads + and edits. Denial preserves the draft, never silently removes a selected key. + See `docs/remote-mention-routing.md`. Marked builds require that verified owner coordinate but do not require it to equal the viewer; OSS builds retain compatibility with self-authored legacy directory records. Keep native discovery and send-time revalidation fail closed on invalid ownership or managed policy evidence, diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 5398b28f05..7ffd01097c 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -548,3 +548,92 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { assert.deepEqual(coalesce([first, second]), [first, second]); }); + +test("owners remain admitted by allowlist policy without listing themselves", () => { + assert.equal( + relayAgentCanRespondInChannel( + { + ownerPubkey: CURRENT_PUBKEY, + respondTo: "allowlist", + respondToAllowlist: [], + channelIds: ["general"], + }, + "general", + CURRENT_PUBKEY, + ), + true, + ); +}); + +test("owned discovery does not require a shared channel, but sending does", () => { + for (const respondTo of ["owner-only", "allowlist", "anyone"]) { + const agent = { + pubkey: PUB_B, + ownerPubkey: CURRENT_PUBKEY, + respondTo, + respondToAllowlist: [], + channelIds: [], + }; + assert.equal( + relayAgentIsSharedWithUser(agent, new Set(), CURRENT_PUBKEY), + true, + ); + assert.equal( + relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY), + false, + ); + } +}); + +test("DM ownership is independent of local configuration and still requires membership", () => { + const base = { + currentPubkey: CURRENT_PUBKEY, + managedAgentPubkeys: [PUB_A], + sharedChannelIds: new Set(), + relayAgents: [ + { + pubkey: PUB_B, + ownerPubkey: CURRENT_PUBKEY, + respondTo: "allowlist", + respondToAllowlist: [], + channelIds: ["dm"], + }, + { + pubkey: PUB_C, + ownerPubkey: OTHER_OWNER_PUBKEY, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["dm"], + }, + { + pubkey: PUB_D, + ownerPubkey: CURRENT_PUBKEY, + respondTo: "nobody", + respondToAllowlist: [], + channelIds: ["dm"], + }, + ], + }; + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "owned", channelId: "dm" }, + }), + new Set([PUB_A, PUB_B]), + ); + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "owned", channelId: "other" }, + }), + new Set([PUB_A]), + ); + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "owned", channelId: null }, + phase: "prepare", + }), + new Set([PUB_A, PUB_B]), + ); +}); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e3c82cfff4..65655f8163 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -34,12 +34,17 @@ export function relayAgentIsSharedWithUser( ? normalizePubkey(currentPubkey) : null; + // Ownership is relay identity, not local key custody. Like the harness's + // author gate, every supported policy except nobody admits the owner. if ( - agent.respondTo === "owner-only" && + (agent.respondTo === "owner-only" || + agent.respondTo === "allowlist" || + agent.respondTo === "anyone") && normalizedCurrentPubkey && - agent.ownerPubkey + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey ) { - return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey; + return true; } if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { @@ -71,6 +76,7 @@ export function relayAgentCanRespondInChannel( export type AgentEligibilityScope = | { type: "community" } | { type: "channel"; channelId: string } + | { type: "owned"; channelId: string | null } | { type: "managed-only" }; export function getMentionableAgentPubkeys({ @@ -79,9 +85,11 @@ export function getMentionableAgentPubkeys({ managedAgentPubkeys, relayAgents, sharedChannelIds, + phase = "publish", }: { currentPubkey?: string | null; eligibilityScope: AgentEligibilityScope; + phase?: "prepare" | "publish"; managedAgentPubkeys: Iterable; relayAgents: readonly RelayAgent[] | undefined; sharedChannelIds: ReadonlySet; @@ -94,13 +102,38 @@ export function getMentionableAgentPubkeys({ const isAllowed = eligibilityScope.type === "managed-only" ? false - : eligibilityScope.type === "community" - ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) - : relayAgentCanRespondInChannel( - agent, - eligibilityScope.channelId, - currentPubkey, - ); + : eligibilityScope.type === "owned" + ? Boolean( + currentPubkey && + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === + normalizePubkey(currentPubkey) && + relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + ) && + (phase === "prepare" || + (eligibilityScope.channelId !== null && + agent.channelIds.includes(eligibilityScope.channelId))), + ) + : eligibilityScope.type === "community" + ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) + : phase === "prepare" && + currentPubkey && + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === + normalizePubkey(currentPubkey) + ? relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + ) + : relayAgentCanRespondInChannel( + agent, + eligibilityScope.channelId, + currentPubkey, + ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); } diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 03fb365ebd..bfb38cd700 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { EditorContent } from "@tiptap/react"; import { ChevronDown } from "lucide-react"; +import { toast } from "sonner"; import { buildOutgoingMessage } from "@/features/messages/lib/imetaMediaMarkdown"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; @@ -278,8 +279,9 @@ export function ForumComposer({ media.setPendingImeta(savedImeta); if (compact) setIsCompactExpanded(true); } - } catch { - // Keep the draft intact when authorization refresh fails. + } catch (error) { + // Fresh authorization failures must be visible; preserve the draft. + toast.error(error instanceof Error ? error.message : String(error)); } finally { isSubmissionPendingRef.current = false; setIsSubmissionPending(false); diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs index d04446a341..b04580921b 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { revalidateAgentMentionPubkeys } from "./agentMentionRevalidation.ts"; +import { + revalidateAgentMentionPubkeys, + AgentMentionAuthorizationError, +} from "./agentMentionRevalidation.ts"; const CURRENT = "a".repeat(64); const AGENT = "b".repeat(64); @@ -47,37 +50,170 @@ test("fresh managed evidence survives unrelated relay authorization errors", asy throw new Error("relay directory unavailable"); }, }); - assert.deepEqual(result, [HUMAN, LOCAL_AGENT]); }); test("relay-only agents still fail closed when relay discovery fails", async () => { - const result = await revalidateAgentMentionPubkeys({ - ...options(), - fetchRelayAgents: async () => { - throw new Error("relay directory unavailable"); - }, - }); - - assert.deepEqual(result, [HUMAN]); -}); - -test("mixed evidence preserves only fresh managed agents and humans", async () => { - const result = await revalidateAgentMentionPubkeys({ - ...options(async () => ({ - profiles: { [AGENT]: { ownerPubkey: CURRENT } }, - missing: [LOCAL_AGENT], - })), - pubkeys: [HUMAN, LOCAL_AGENT, AGENT], - agentPubkeys: new Set([LOCAL_AGENT, AGENT]), - refetchManagedAgents: async () => ({ - data: [{ pubkey: LOCAL_AGENT }], - error: null, + await assert.rejects( + revalidateAgentMentionPubkeys({ + ...options(), + fetchRelayAgents: async () => { + throw new Error("relay directory unavailable"); + }, }), - fetchRelayAgents: async () => { - throw new Error("relay directory unavailable"); - }, - }); - - assert.deepEqual(result, [HUMAN, LOCAL_AGENT]); + AgentMentionAuthorizationError, + ); +}); + +test("mixed evidence cannot silently drop an intended relay recipient", async () => { + await assert.rejects( + revalidateAgentMentionPubkeys({ + ...options(async () => ({ + profiles: { [AGENT]: { ownerPubkey: CURRENT } }, + missing: [LOCAL_AGENT], + })), + pubkeys: [HUMAN, LOCAL_AGENT, AGENT], + agentPubkeys: new Set([LOCAL_AGENT, AGENT]), + refetchManagedAgents: async () => ({ + data: [{ pubkey: LOCAL_AGENT }], + error: null, + }), + fetchRelayAgents: async () => { + throw new Error("relay directory unavailable"); + }, + }), + AgentMentionAuthorizationError, + ); +}); + +test("remote-owned membership does not depend on local runtime discovery", async () => { + assert.deepEqual( + await revalidateAgentMentionPubkeys({ + ...options(), + refetchManagedAgents: async () => ({ + data: undefined, + error: new Error("local unavailable"), + }), + fetchRelayAgents: async () => [ + { + pubkey: AGENT, + ownerPubkey: CURRENT, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + }, + ], + }), + [HUMAN, AGENT], + ); +}); + +test("stale local data is not authority when its refresh fails", async () => { + await assert.rejects( + revalidateAgentMentionPubkeys({ + ...options(), + pubkeys: [HUMAN, LOCAL_AGENT, AGENT], + agentPubkeys: new Set([LOCAL_AGENT, AGENT]), + refetchManagedAgents: async () => ({ + data: [{ pubkey: LOCAL_AGENT }], + error: new Error("local unavailable"), + }), + }), + AgentMentionAuthorizationError, + ); +}); + +test("owned remote policy revocation and missing membership fail closed", async () => { + for (const agent of [ + { respondTo: "nobody", channelIds: ["general"] }, + { respondTo: "owner-only", channelIds: [] }, + ]) { + await assert.rejects( + revalidateAgentMentionPubkeys({ + ...options(), + fetchRelayAgents: async () => [ + { + pubkey: AGENT, + ownerPubkey: CURRENT, + respondToAllowlist: [], + ...agent, + }, + ], + }), + AgentMentionAuthorizationError, + ); + } +}); + +for (const type of ["channel", "owned"]) { + test(`${type}: preparation admits owned nonmembers but publication requires actual membership`, async () => { + let channelIds = []; + const opts = { + ...options(), + eligibilityScope: { type, channelId: "target" }, + sharedChannelIds: new Set(), + fetchRelayAgents: async () => [ + { + pubkey: AGENT, + ownerPubkey: CURRENT, + respondTo: "allowlist", + respondToAllowlist: [], + channelIds, + }, + ], + }; + assert.deepEqual( + await revalidateAgentMentionPubkeys({ ...opts, phase: "prepare" }), + [HUMAN, AGENT], + ); + await assert.rejects( + revalidateAgentMentionPubkeys(opts), + AgentMentionAuthorizationError, + ); + channelIds = ["target"]; + assert.deepEqual(await revalidateAgentMentionPubkeys(opts), [HUMAN, AGENT]); + channelIds = ["other"]; + await assert.rejects( + revalidateAgentMentionPubkeys(opts), + AgentMentionAuthorizationError, + ); + }); +} + +test("preparation cannot bypass a fresh owner-policy denial", async () => { + await assert.rejects( + revalidateAgentMentionPubkeys({ + ...options(), + phase: "prepare", + fetchRelayAgents: async () => [ + { + pubkey: AGENT, + ownerPubkey: CURRENT, + respondTo: "nobody", + respondToAllowlist: [], + channelIds: [], + }, + ], + }), + AgentMentionAuthorizationError, + ); +}); + +test("publication cannot authorize a DM that still has no destination", async () => { + await assert.rejects( + revalidateAgentMentionPubkeys({ + ...options(), + eligibilityScope: { type: "owned", channelId: null }, + fetchRelayAgents: async () => [ + { + pubkey: AGENT, + ownerPubkey: CURRENT, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["other"], + }, + ], + }), + AgentMentionAuthorizationError, + ); }); diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts index 37f7ce9d4e..1d7c197d6f 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.ts +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts @@ -1,5 +1,4 @@ import { - filterAdmittedMentionPubkeys, getAgentMentionAdmission, getMentionableAgentPubkeys, type AgentEligibilityScope, @@ -9,6 +8,20 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import * as React from "react"; +export type MentionRevalidationOptions = { + phase?: "prepare" | "publish"; + intendedAgentPubkeys?: readonly string[]; +}; + +export class AgentMentionAuthorizationError extends Error { + constructor() { + super( + "Could not authorize a mentioned agent. Check its access and channel membership, then retry or remove the mention.", + ); + this.name = "AgentMentionAuthorizationError"; + } +} + type DirectoryResult = { data: T | undefined; error: Error | null; @@ -22,7 +35,9 @@ export async function revalidateAgentMentionPubkeys({ sharedChannelIds, refetchManagedAgents, fetchRelayAgents, + phase = "publish", }: { + phase?: "prepare" | "publish"; pubkeys: readonly string[]; agentPubkeys: ReadonlySet; currentPubkey: string | null; @@ -39,20 +54,21 @@ export async function revalidateAgentMentionPubkeys({ } const [managedResult, relayAgents] = await Promise.all([ - refetchManagedAgents(), + refetchManagedAgents().catch(() => null), fetchRelayAgents([...requestedAgentPubkeys]).catch(() => null), ]); const relayDirectoryReady = relayAgents !== null; - if (managedResult.error !== null || managedResult.data === undefined) { - return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set()); - } - + // Each directory proves only its own identities. A failed local runtime + // query must neither veto fresh relay evidence nor admit stale local data. const managedPubkeys = new Set( - managedResult.data.map((agent) => normalizePubkey(agent.pubkey)), + (managedResult?.error === null ? (managedResult.data ?? []) : []).map( + (agent) => normalizePubkey(agent.pubkey), + ), ); const mentionablePubkeys = getMentionableAgentPubkeys({ currentPubkey, eligibilityScope, + phase, managedAgentPubkeys: managedPubkeys, relayAgents: relayDirectoryReady ? relayAgents : [], sharedChannelIds, @@ -71,7 +87,12 @@ export async function revalidateAgentMentionPubkeys({ ); }), ); - return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, admittedPubkeys); + if ( + [...requestedAgentPubkeys].some((pubkey) => !admittedPubkeys.has(pubkey)) + ) { + throw new AgentMentionAuthorizationError(); + } + return [...pubkeys]; } export function useAgentMentionRevalidation({ @@ -90,22 +111,38 @@ export function useAgentMentionRevalidation({ refetchManagedAgents: () => Promise>; }) { return React.useCallback( - (pubkeys: readonly string[]) => - revalidateAgentMentionPubkeys({ + ( + pubkeys: readonly string[], + destinationChannelId?: string | null, + options: MentionRevalidationOptions = {}, + ) => { + // A new DM can acquire its channel during preparation. Validate the + // actual destination at publication, not the composer's original null id. + const scope: AgentEligibilityScope = destinationChannelId + ? { + type: eligibilityScope.type === "owned" ? "owned" : "channel", + channelId: destinationChannelId, + } + : eligibilityScope; + return revalidateAgentMentionPubkeys({ pubkeys, - agentPubkeys: new Set([...agentPubkeys, ...getSelectedAgentPubkeys()]), + agentPubkeys: new Set([ + ...agentPubkeys, + ...getSelectedAgentPubkeys(), + ...(options.intendedAgentPubkeys ?? []).map(normalizePubkey), + ]), + phase: options.phase, currentPubkey, - eligibilityScope, + eligibilityScope: scope, sharedChannelIds, refetchManagedAgents, fetchRelayAgents: (requestedPubkeys) => revalidateRelayAgents( requestedPubkeys, - eligibilityScope.type === "channel" - ? eligibilityScope.channelId - : undefined, + "channelId" in scope ? (scope.channelId ?? undefined) : undefined, ), - }), + }); + }, [ agentPubkeys, currentPubkey, diff --git a/desktop/src/features/messages/lib/useDraftMentionRouting.ts b/desktop/src/features/messages/lib/useDraftMentionRouting.ts index d8e375e876..d65960a548 100644 --- a/desktop/src/features/messages/lib/useDraftMentionRouting.ts +++ b/desktop/src/features/messages/lib/useDraftMentionRouting.ts @@ -11,6 +11,7 @@ import { export function useDraftMentionRouting(params: { mentionMapRef: React.MutableRefObject>; personaMentionMapRef: React.MutableRefObject>; + selectedAgentPubkeysRef: React.MutableRefObject>; selectedAgentNamesRef: React.MutableRefObject; cancelAutocomplete: () => void; setSelectedNames: (names: string[]) => void; @@ -31,6 +32,11 @@ export function useDraftMentionRouting(params: { const restoreDraftMentionRefs = React.useCallback( (refs: readonly DraftMentionRef[]) => { params.cancelAutocomplete(); + params.selectedAgentPubkeysRef.current = new Set( + refs + .filter((ref) => ref.isAgent) + .map((ref) => ref.pubkey.toLowerCase()), + ); const { names, agentNames } = replaceWithDraftMentionRefs( refs, params.mentionMapRef.current, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index f846b545d6..03d7d32c5a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -12,9 +12,7 @@ import { import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import { - filterAdmittedMentionPubkeys, filterCachedAgentSuggestions, - getAdmittedAgentPubkeys, getAgentIdentityPubkeys, getMentionableAgentPubkeys, getSharedChannelIds, @@ -179,15 +177,20 @@ export function useMentions( () => getMentionableAgentPubkeys({ currentPubkey, + phase: "prepare", eligibilityScope: mentionChannelId ? { type: "channel", channelId: mentionChannelId } - : { type: "managed-only" }, + : options?.channelType === "dm" + ? { type: "owned", channelId } + : { type: "managed-only" }, managedAgentPubkeys, relayAgents: relayAgentsQuery.data, sharedChannelIds, }), [ currentPubkey, + channelId, + options?.channelType, managedAgentPubkeys, mentionChannelId, relayAgentsQuery.data, @@ -286,10 +289,6 @@ export function useMentions( relayAgentsQuery.data, ], ); - const admittedAgentPubkeys = React.useMemo( - () => getAdmittedAgentPubkeys(mentionCandidates), - [mentionCandidates], - ); const mentionCandidatesWithTeams = React.useMemo( () => [ ...mentionCandidates, @@ -530,12 +529,15 @@ export function useMentions( appendUniqueName(current, trimmedName), ); if (options?.isAgent) { + selectedAgentMentionPubkeysRef.current.add(normalizePubkey(pubkey)); + selectedAgentMentionPubkeysRef.current.add(normalizePubkey(pubkey)); selectedAgentMentionNamesRef.current = appendUniqueName( selectedAgentMentionNamesRef.current, trimmedName, ); setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current); } + return trimmedName; }, [], ); @@ -662,16 +664,11 @@ export function useMentions( selectedDisplayNames: personaMentionMapRef.current.keys(), memberCandidates: mentionCandidates, }); - return filterAdmittedMentionPubkeys( - extracted, - new Set([ - ...agentIdentityPubkeys, - ...selectedAgentMentionPubkeysRef.current, - ]), - admittedAgentPubkeys, - ); + // Selections are intent, not cached authorization. Never discard a + // selected key because a refresh removed it from the picker. + return extracted; }, - [admittedAgentPubkeys, agentIdentityPubkeys, mentionCandidates], + [mentionCandidates], ); const getSelectedAgentPubkeys = React.useRef( () => selectedAgentMentionPubkeysRef.current, @@ -682,7 +679,9 @@ export function useMentions( currentPubkey, eligibilityScope: mentionChannelId ? { type: "channel", channelId: mentionChannelId } - : { type: "managed-only" }, + : options?.channelType === "dm" + ? { type: "owned", channelId } + : { type: "managed-only" }, sharedChannelIds, refetchManagedAgents: managedAgentsQuery.refetch, }); @@ -721,6 +720,7 @@ export function useMentions( mentionMapRef, personaMentionMapRef, selectedAgentNamesRef: selectedAgentMentionNamesRef, + selectedAgentPubkeysRef: selectedAgentMentionPubkeysRef, cancelAutocomplete: cancelMentionAutocomplete, setSelectedNames: setSelectedMentionNames, setSelectedAgentNames: setSelectedAgentMentionNames, diff --git a/desktop/src/features/messages/ui/submitMessageEdit.test.mjs b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs index 9e6cb957fd..ffebe51167 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.test.mjs +++ b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { AgentMentionAuthorizationError } from "../lib/agentMentionRevalidation.ts"; import { submitMessageEdit } from "./submitMessageEdit.ts"; const UNRESOLVED_USER = "b".repeat(64); @@ -96,13 +97,16 @@ test("edit save revalidates added mentions immediately before save", async () => content.includes("@Agent") ? [agent] : [], revalidateMentionPubkeys: async (pubkeys) => { calls.push(["revalidate", pubkeys]); - return []; + throw new AgentMentionAuthorizationError(); }, + restoreComposer: () => calls.push(["restore"]), + setUploadError: (error) => calls.push(["error", error]), }); assert.deepEqual(calls, [ ["revalidate", [agent]], - ["save", []], + ["restore"], + ["error", new AgentMentionAuthorizationError().message], ]); }); @@ -131,14 +135,17 @@ test("edit upload pause revalidates revoked mentions only after upload completes }, revalidateMentionPubkeys: async (pubkeys) => { calls.push(["revalidate", pubkeys]); - return []; + throw new AgentMentionAuthorizationError(); }, + restoreComposer: () => calls.push(["restore"]), + setUploadError: (error) => calls.push(["error", error]), }); assert.deepEqual(calls, []); await completeUpload(); assert.deepEqual(calls, [ ["revalidate", [agent]], - ["save", []], + ["restore"], + ["error", new AgentMentionAuthorizationError().message], ]); }); diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts index 4eb1e274fe..a05daf2a10 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.ts +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -1,3 +1,7 @@ +import { + AgentMentionAuthorizationError, + type MentionRevalidationOptions, +} from "@/features/messages/lib/agentMentionRevalidation"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; import { hasMention } from "@/features/messages/lib/hasMention"; @@ -40,7 +44,11 @@ type SubmitMessageEditOptions = Omit< ownerPubkey: string | null; restoreComposer: (draft: EditDraft) => void; restoreMentionRefs: (refs: DraftMentionRef[]) => void; - revalidateMentionPubkeys: (pubkeys: readonly string[]) => Promise; + revalidateMentionPubkeys: ( + pubkeys: readonly string[], + channelId?: string | null, + options?: MentionRevalidationOptions, + ) => Promise; shouldRestoreComposer: () => boolean; setDeferredUploadPending: (isPending: boolean) => void; save: ( @@ -126,8 +134,15 @@ export async function submitMessageEdit({ ], ); if (signal?.aborted) return; - const revalidatedMentionPubkeys = - await revalidateMentionPubkeys(addedMentionPubkeys); + const revalidatedMentionPubkeys = await revalidateMentionPubkeys( + addedMentionPubkeys, + undefined, + { + intendedAgentPubkeys: draft.mentionRefs + .filter((ref) => ref.isAgent) + .map((ref) => ref.pubkey), + }, + ); if (signal?.aborted) return; await save( finalContent, @@ -143,8 +158,10 @@ export async function submitMessageEdit({ onComplete: async (uploaded, signal) => { try { await finishEdit(uploaded, signal); - } catch { + } catch (error) { restoreDraft(); + if (error instanceof AgentMentionAuthorizationError) + setUploadError(error.message); } finally { setDeferredUploadPending(false); } @@ -164,7 +181,9 @@ export async function submitMessageEdit({ try { await finishEdit([]); - } catch { + } catch (error) { restoreDraft(); + if (error instanceof AgentMentionAuthorizationError) + setUploadError(error.message); } } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs index bc11d79512..192fe5b7e6 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.test.mjs @@ -5,6 +5,7 @@ import { formatMessageSendError, getErrorMessage, mergeMentionRecipients, + mentionRevalidationOptions, } from "./useMentionSendFlow.helpers.ts"; test("formatMessageSendError preserves the publication failure", () => { @@ -39,3 +40,24 @@ test("address-locked agents join explicit mentions without duplicating recipient "c".repeat(64), ]); }); + +test("revalidation carries captured and prepared agent keys independently of the cleared composer", () => { + const draft = { + inlineAgentMentionPubkeys: ["A".repeat(64)], + addressedAgentPubkeys: ["b".repeat(64)], + }; + assert.deepEqual(mentionRevalidationOptions(draft, "prepare"), { + phase: "prepare", + intendedAgentPubkeys: ["a".repeat(64), "b".repeat(64)], + }); + assert.deepEqual( + mentionRevalidationOptions(draft, "publish", [ + "a".repeat(64), + "c".repeat(64), + ]), + { + phase: "publish", + intendedAgentPubkeys: ["a".repeat(64), "b".repeat(64), "c".repeat(64)], + }, + ); +}); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index f0c71bd393..8b53f1a77a 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -1,3 +1,4 @@ +import type { MentionRevalidationOptions } from "@/features/messages/lib/agentMentionRevalidation"; import type { ManagedAgent } from "@/shared/api/types"; import { type ImetaMedia, @@ -119,3 +120,36 @@ export function isManagedAgentRunning(agent: ManagedAgent) { export function isProviderBackedAgent(agent: ManagedAgent) { return agent.backend.type === "provider"; } + +/** Carry captured recipient identity through composer clearing and uploads. */ +export function mentionRevalidationOptions( + draft: Pick< + PendingNonMemberMentionSend, + "inlineAgentMentionPubkeys" | "addressedAgentPubkeys" + >, + phase: "prepare" | "publish", + preparedAgentPubkeys: readonly string[] = [], +): MentionRevalidationOptions { + return { + phase, + intendedAgentPubkeys: uniqueNormalizedPubkeys([ + ...draft.inlineAgentMentionPubkeys, + ...draft.addressedAgentPubkeys, + ...preparedAgentPubkeys, + ]), + }; +} + +/** Explicit Send without inviting retains nonmembers only as reference tags. */ +export function withoutInvitingRecipients(draft: PendingNonMemberMentionSend) { + const nonMemberPubkeys = new Set(draft.nonMemberPubkeys.map(normalizePubkey)); + return { + mentionPubkeys: draft.mentionPubkeys.filter( + (pubkey) => !nonMemberPubkeys.has(normalizePubkey(pubkey)), + ), + outgoingTags: mergeOutgoingTagsWithReferenceMentions( + draft.outgoingTags, + nonMemberPubkeys, + ), + }; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 021b4ea441..f0a8f5fd50 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -32,17 +32,19 @@ import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { formatMessageSendError, getErrorMessage, + mentionRevalidationOptions, + withoutInvitingRecipients, isManagedAgentRunning, isProviderBackedAgent, mergeMentionRecipients, MENTION_REFERENCE_TAG, - mergeOutgoingTagsWithReferenceMentions, type PendingNonMemberMentionSend, type SendMessageWithMentionFlowInput, resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; +import { AgentMentionAuthorizationError } from "@/features/messages/lib/agentMentionRevalidation"; import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; export function useMentionSendFlow({ @@ -429,7 +431,11 @@ export function useMentionSendFlow({ let uploadStarted = false; try { const admittedMentionPubkeys = uniqueNormalizedPubkeys( - await mentions.revalidateMentionPubkeys(mentionPubkeys), + await mentions.revalidateMentionPubkeys( + mentionPubkeys, + draft.capturedChannelId, + mentionRevalidationOptions(draft, "prepare"), + ), ); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) return persistPreflightDraft(); @@ -439,7 +445,9 @@ export function useMentionSendFlow({ (pubkey) => admittedMentionPubkeySet.has(pubkey), ), ); - const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + const managedAgentsByPubkey = await getManagedAgentsByPubkey().catch( + () => new Map(), + ); if (isSendCancelled()) return restoreComposerAfterFailure(); if (!isMountedRef.current) { persistPreflightDraft(); @@ -539,7 +547,15 @@ export function useMentionSendFlow({ if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) return; const revalidatedMentionPubkeys = - await mentions.revalidateMentionPubkeys(mentionPubkeys); + await mentions.revalidateMentionPubkeys( + mentionPubkeys, + sendChannelId, + mentionRevalidationOptions( + draft, + "publish", + preparedAgentPubkeys, + ), + ); if (signal?.aborted || isSendCancelled()) return; const finalTagsWithAgentAddress = [ ...finalOutgoingTags, @@ -598,7 +614,7 @@ export function useMentionSendFlow({ await finishSend(uploaded, signal); } catch (error) { restoreComposerAfterFailure(); - toast.error(formatMessageSendError(error)); + toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); } finally { settleUpload(); } @@ -626,12 +642,14 @@ export function useMentionSendFlow({ await finishSend([]); } catch (error) { restoreComposerAfterFailure(); - toast.error(formatMessageSendError(error)); + toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error)); } } } catch (error) { restoreComposerAfterFailure(); - throw error; + toast.error( + getErrorMessage(error, "Could not send message. Please retry."), + ); } finally { if (draft.preparedLinkPreviews) { activePreparedLinkPreviews.delete(draft.preparedLinkPreviews); @@ -847,18 +865,8 @@ export function useMentionSendFlow({ }, [mentions.getMentionDisplayName, pendingNonMemberSend]); const handleSendWithoutInviting = React.useCallback(() => { if (!pendingNonMemberSend) return; - const nonMemberPubkeys = new Set( - pendingNonMemberSend.nonMemberPubkeys.map((pubkey) => - normalizePubkey(pubkey), - ), - ); - const mentionPubkeys = pendingNonMemberSend.mentionPubkeys.filter( - (pubkey) => !nonMemberPubkeys.has(normalizePubkey(pubkey)), - ); - const outgoingTags = mergeOutgoingTagsWithReferenceMentions( - pendingNonMemberSend.outgoingTags, - nonMemberPubkeys, - ); + const { mentionPubkeys, outgoingTags } = + withoutInvitingRecipients(pendingNonMemberSend); void completeSend(pendingNonMemberSend, mentionPubkeys, outgoingTags); }, [completeSend, pendingNonMemberSend]); const handleInviteNonMembers = React.useCallback(() => { @@ -870,10 +878,14 @@ export function useMentionSendFlow({ setNonMemberPromptError(null); void (async () => { const mentionPubkeys = uniqueNormalizedPubkeys( - await mentions.revalidateMentionPubkeys([ - ...pendingNonMemberSend.mentionPubkeys, - ...pendingNonMemberSend.nonMemberPubkeys, - ]), + await mentions.revalidateMentionPubkeys( + [ + ...pendingNonMemberSend.mentionPubkeys, + ...pendingNonMemberSend.nonMemberPubkeys, + ], + pendingNonMemberSend.capturedChannelId, + mentionRevalidationOptions(pendingNonMemberSend, "prepare"), + ), ); const admittedMentionPubkeys = new Set(mentionPubkeys); const originalNonMemberPubkeys = new Set( @@ -887,7 +899,9 @@ export function useMentionSendFlow({ tag[0] !== MENTION_REFERENCE_TAG || !originalNonMemberPubkeys.has(normalizePubkey(tag[1] ?? "")), ); - const managedAgentsByPubkey = await getManagedAgentsByPubkey(); + const managedAgentsByPubkey = await getManagedAgentsByPubkey().catch( + () => new Map(), + ); if (!isMountedRef.current) return; const peoplePubkeys: string[] = []; const relayAgentPubkeys: string[] = []; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b31504fe0a..fc35fb7c13 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4155,6 +4155,14 @@ function syncMockRelayAgentsFromManagedAgents() { }, ); + // Owned discovery includes nonmembers, but membership must come from the + // actual roster, including additions and newly created DMs. + for (const agent of baseAgents) { + if (agent.owner_pubkey !== MOCK_IDENTITY_PUBKEY) continue; + const membership = getManagedAgentRelayMembership(agent.pubkey); + agent.channel_ids = membership.channelIds; + agent.channels = membership.channels; + } mockRelayAgents = [...baseAgents, ...managedAgentsAsRelay]; } @@ -13350,7 +13358,9 @@ export function maybeInstallE2eTauriMocks() { (agent) => requested.has(agent.pubkey.toLowerCase()) && !revoked.has(agent.pubkey.toLowerCase()) && - (!channelId || agent.channel_ids.includes(channelId)), + (!channelId || + agent.channel_ids.includes(channelId) || + agent.owner_pubkey === MOCK_IDENTITY_PUBKEY), ); } case "list_personas": diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 88985fedec..c17de04554 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -15,6 +15,20 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach("outgoing-diagnostic", { + body: JSON.stringify( + await page.evaluate(() => ({ + events: window.__BUZZ_E2E_SIGNED_EVENTS__, + commands: window.__BUZZ_E2E_COMMAND_LOG__, + })), + ), + contentType: "application/json", + }); + } +}); + const IN_CHANNEL_MANAGED_AGENT_PUBKEY = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY = @@ -1635,6 +1649,25 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); await page.getByRole("button", { name: "Start a new post..." }).click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels", channelId, "members"], + }); + }, + { + channelId: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); + const input = page.getByTestId("message-input"); await input.fill("@quinn"); await page.getByTestId("mention-autocomplete").getByText("quinn").click(); @@ -1665,12 +1698,11 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ await expect(input).not.toContainText("later edit"); const outgoingContent = `@quinn hello\n[forum-race.pdf](https://mock.relay/media/${"f".repeat(64)}.pdf)`; - await expect - .poll(() => readOutgoingMentionPubkeys(page, outgoingContent)) - .not.toBeNull(); - await expect - .poll(() => readOutgoingMentionPubkeys(page, outgoingContent)) - .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + await expect( + page.getByText(/Could not authorize a mentioned agent/), + ).toBeVisible(); + await expect(input).toContainText("@quinn hello"); + expect(await readOutgoingMentionPubkeys(page, outgoingContent)).toBeNull(); }); test("managed agents use the channel roster for membership labels", async ({ @@ -1942,15 +1974,14 @@ test("targeted revocation before send causes no agent side effects", async ({ const baselineCommands = await readCommandLog(page); await page.getByTestId("send-message").click(); - await expect - .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) - .not.toBeNull(); - await expect - .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) - .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + await expect( + page.getByText(/Could not authorize a mentioned agent/), + ).toBeVisible(); + await expect(input).toHaveText("@quinn hello"); + expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); const commands = await readCommandLog(page); expect(commandCount(commands, "revalidate_relay_agents")).toBe( - commandCount(baselineCommands, "revalidate_relay_agents") + 2, + commandCount(baselineCommands, "revalidate_relay_agents") + 1, ); expect(commandCount(commands, "list_relay_agents")).toBe( commandCount(baselineCommands, "list_relay_agents"), @@ -2055,12 +2086,11 @@ test("selected relay agents revoked after the invite prompt cause no side effect const baselineCommands = await readCommandLog(page); await inviteButton.click(); - await expect - .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) - .not.toBeNull(); - await expect - .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) - .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + await expect( + page.getByText(/Could not authorize a mentioned agent/), + ).toBeVisible(); + await expect(input).toHaveText("@quinn hello"); + expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); const commands = await readCommandLog(page); for (const command of [ "add_channel_members", @@ -2110,12 +2140,11 @@ test("selected relay agents revoked during send emit no p tag", async ({ ); }); - await expect - .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) - .not.toBeNull(); - await expect - .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) - .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + await expect( + page.getByText(/Could not authorize a mentioned agent/), + ).toBeVisible(); + await expect(input).toHaveText("@quinn hello"); + expect(await readOutgoingMentionPubkeys(page, "@quinn hello")).toBeNull(); }); test("owner-only builds admit cross-owner relay agents authorized by allowlist", async ({ diff --git a/desktop/tests/e2e/remote-owned-mentions.spec.ts b/desktop/tests/e2e/remote-owned-mentions.spec.ts new file mode 100644 index 0000000000..18b1f3ba91 --- /dev/null +++ b/desktop/tests/e2e/remote-owned-mentions.spec.ts @@ -0,0 +1,246 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + installMockBridge, + openNewMessagePage, + TEST_IDENTITIES, +} from "../helpers/bridge"; + +const OWNER = "deadbeef".repeat(8); +const REMOTE = "ed".repeat(32); +const GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +async function install(page: Page) { + await installMockBridge(page, { + ownerOnlyAccessBuild: true, + managedAgents: [], + searchProfiles: [ + { + pubkey: REMOTE, + displayName: "RemoteScout", + ownerPubkey: OWNER, + isAgent: true, + }, + ], + relayAgents: [ + { + pubkey: REMOTE, + name: "RemoteScout", + ownerPubkey: OWNER, + respondTo: "allowlist", + respondToAllowlist: [], + channelNames: [], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +} +async function select(page: Page) { + await page.getByTestId("message-input").fill("@Remote"); + const row = page.getByTestId(`mention-suggestion-${REMOTE}`); + await expect(row).toContainText("RemoteScout"); + await row.locator("button").first().click(); + await page.keyboard.type("hello"); +} +async function sent(page: Page) { + return page.evaluate(() => { + const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter((event) => event.content === "@RemoteScout hello") + .map((event) => + event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]), + ); + if (signed.length) return signed; + // New DMs deliberately use the acknowledged native HTTP command rather + // than JS sign_event. Assert its exact outgoing recipients, not fake crypto. + return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).flatMap((call) => { + const payload = call.payload as { + content?: string; + mentionPubkeys?: string[]; + }; + return call.command === "send_channel_message" && + payload.content === "@RemoteScout hello" + ? [payload.mentionPubkeys ?? []] + : []; + }); + }); +} +async function assertNoLocalLifecycle(page: Page) { + const commands = await page.evaluate( + () => window.__BUZZ_E2E_COMMANDS__ ?? [], + ); + for (const command of [ + "start_managed_agent", + "create_managed_agent", + "attach_managed_agent", + ]) { + expect(commands).not.toContain(command); + } +} +for (const role of ["member", "bot"] as const) { + test(`owned ${role} with empty local roster emits exact p tag`, async ({ + page, + }) => { + await install(page); + await page.evaluate( + async ({ pubkey, channelId, role }) => { + await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("add_channel_members", { + channelId, + pubkeys: [pubkey], + role, + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["relay-agents"], + }); + }, + { pubkey: REMOTE, channelId: GENERAL, role }, + ); + await select(page); + await page.getByTestId("send-message").click(); + await expect.poll(() => sent(page)).toEqual([[REMOTE]]); + await expect( + page.getByRole("button", { name: "Invite", exact: true }), + ).toHaveCount(0); + await assertNoLocalLifecycle(page); + }); +} +test("owned nonmember uses authorized add before exact publication", async ({ + page, +}) => { + await install(page); + await select(page); + await page.getByTestId("send-message").click(); + const invite = page.getByRole("button", { name: "Invite", exact: true }); + await expect(invite).toBeVisible(); + expect(await sent(page)).toEqual([]); + await invite.click(); + await expect.poll(() => sent(page)).toEqual([[REMOTE]]); + const calls = await page.evaluate( + () => window.__BUZZ_E2E_COMMAND_LOG__ ?? [], + ); + expect(calls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + command: "add_channel_members", + payload: expect.objectContaining({ pubkeys: [REMOTE], role: "bot" }), + }), + ]), + ); + await assertNoLocalLifecycle(page); +}); +for (const error of [ + "actor not authorized", + "policy:nobody — this agent has disabled external channel additions", +]) { + test(`failed add keeps draft and sends nothing: ${error}`, async ({ + page, + }) => { + await install(page); + await select(page); + await page.evaluate((error) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.addChannelMembersErrors = [error]; + }, error); + await page.getByTestId("send-message").click(); + await page.getByRole("button", { name: "Invite", exact: true }).click(); + await expect(page.getByText(error, { exact: true })).toBeVisible(); + await expect(page.getByTestId("message-input")).toHaveText( + "@RemoteScout hello", + ); + expect(await sent(page)).toEqual([]); + await assertNoLocalLifecycle(page); + }); +} +test("selected owned agent revoked before add keeps draft and sends nothing", async ({ + page, +}) => { + await install(page); + await select(page); + await page.getByTestId("send-message").click(); + await page.evaluate((pubkey) => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, REMOTE); + await page.getByRole("button", { name: "Invite", exact: true }).click(); + await expect( + page.getByText(/Could not authorize a mentioned agent/), + ).toBeVisible(); + await expect(page.getByTestId("message-input")).toHaveText( + "@RemoteScout hello", + ); + expect(await sent(page)).toEqual([]); +}); + +for (const mode of ["existing", "new"] as const) { + test(`${mode} DM prepares actual destination for owned relay mention`, async ({ + page, + }) => { + await install(page); + if (mode === "existing") { + await page.getByTestId("channel-bob-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); + } else { + await openNewMessagePage(page); + await page.getByTestId("new-dm-search").fill("bob"); + await page + .getByTestId(`new-dm-result-${TEST_IDENTITIES.bob.pubkey}`) + .click(); + await page.getByTestId("new-dm-search").press("Escape"); + } + await select(page); + await page.getByTestId("send-message").click(); + await expect + .poll(() => sent(page)) + .toEqual([[REMOTE, TEST_IDENTITIES.bob.pubkey]]); + const calls = await page.evaluate( + () => window.__BUZZ_E2E_COMMAND_LOG__ ?? [], + ); + const checks = calls.filter( + (call) => call.command === "revalidate_relay_agents", + ); + const event = await page.evaluate(() => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).find( + (event) => event.content === "@RemoteScout hello", + ), + ); + expect(checks.at(-1)?.payload).toMatchObject({ + channelId: + event?.tags.find((tag) => tag[0] === "h")?.[1] ?? + ( + calls.find((call) => call.command === "send_channel_message") + ?.payload as { channelId?: string } + )?.channelId, + pubkeys: [REMOTE], + }); + await assertNoLocalLifecycle(page); + }); +} + +test("membership revoked at final publish keeps draft and emits no message", async ({ + page, +}) => { + await install(page); + await select(page); + await page.getByTestId("send-message").click(); + // Let preparation succeed, but make the fresh final directory read fail. + await page.evaluate(() => { + window.__BUZZ_E2E__.mock ??= {}; + window.__BUZZ_E2E__.mock.relayAgentListErrors = [ + null, + null, + "revoked at publication", + ]; + }); + await page.getByRole("button", { name: "Invite", exact: true }).click(); + await expect( + page.getByText(/Could not authorize a mentioned agent/), + ).toBeVisible(); + await expect(page.getByTestId("message-input")).toHaveText( + "@RemoteScout hello", + ); + expect(await sent(page)).toEqual([]); +}); + diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 6694be68f5..725fe1cb28 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -56,6 +56,7 @@ type MockSearchProfileSeed = { type MockRelayAgentSeed = { pubkey: string; + ownerPubkey?: string | null; name: string; agentType?: string; capabilities?: string[]; diff --git a/docs/remote-mention-routing.md b/docs/remote-mention-routing.md new file mode 100644 index 0000000000..25696ce420 --- /dev/null +++ b/docs/remote-mention-routing.md @@ -0,0 +1,31 @@ +# Remote mention preparation and publication + +Owned relay agents can be selected and invited without local runtime custody. +The picker and preparation phase evaluate policy but may admit an owned +nonmember. Publication separately refreshes authorization for the exact eventual +destination, including a newly created DM. Ownership is not membership and +cached picker evidence is never publication authorization. + +Selections are intent: a vanished/revoked key must fail visibly, retain the draft +and send nothing, rather than silently dropping a recipient. Captured agent keys +survive composer clearing, media upload, edits and asynchronous preparation. +A failed local inventory read cannot veto authenticated relay identities or +admit stale local runtimes. Locally managed runtime readiness remains a separate +existing path; remote identities never gain synthetic local management records. + +Chat offers explicit Invite or reference-only send without inviting. Failed adds, +revoked policy, failed final authorization and cancellation preserve recoverable +drafts. Standalone forum sends report authorization failures, but standalone +forum invitation is a subsequent change reusing this phase contract. + +Native discovery prerequisite: `docs/owned-agent-discovery.md` (PR6). +Regression coverage: `agentAutocompleteEligibility.test.mjs`, +`agentMentionRevalidation.test.mjs`, `useMentionSendFlow.helpers.test.mjs`, +`submitMessageEdit.test.mjs`, `mentions.spec.ts`, and +`remote-owned-mentions.spec.ts`. The new remote browser fixtures use a single-word +name deliberately: mention separator behavior belongs to the independent PR1. + +NIP-OA establishes ownership, not physical hosting, availability, or lifecycle +control. Final native queries do not provide an atomic relay transaction with +message publication. Independent review of both native and publication boundaries +is required before landing.