fix(desktop): enforce agent mention authorization at send boundaries (#5681)

## Summary
- allow channel-member remote/headless agents only with current kind
`10100` directory evidence, while stale member identities remain hidden
- fail closed while managed/relay directories load, error, or
background-refetch across channel, forum, and cached autocomplete
surfaces
- revalidate agent mention authorization immediately before normal sends
and message-edit saves, including after deferred uploads
- in owner-only builds, fetch fresh authoritative profile ownership at
send time and deny missing, changed-owner, or unavailable proofs
- preserve human mention tags when agent authorization is revoked or
unknown

Supersedes #5536 because its contributor-fork head cannot be updated by
maintainers.

## Validation
Exact head: `7278cdd5fbcee676c7b858ea098503c62eeeff0d`

- mandatory pre-push suites passed: desktop check/typecheck/tests, Rust
tests, mobile tests, desktop Tauri checks, branch-skew
- desktop unit tests: 4,732 passed
- focused edit/ownership regressions: 8 passed
- focused mention E2E: 5 passed (remote positive, stale-member negative,
directory error, pre-send revocation, mid-send revocation)
- file-size ratchet passed

One first focused E2E batch had a timing-only miss where the send click
did not emit; the isolated rerun passed. One separate pre-push attempt
hit the existing randomized passphrase separator test; the successful
exact-head push reran and passed the mandatory suite.

---------

Signed-off-by: JDiz00 <174381550+JDiz00@users.noreply.github.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: JDiz00 <174381550+JDiz00@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-13 21:40:02 +00:00
committed by GitHub
co-authored by JDiz00 Carl
parent b269e8df7e
commit bcf353c969
15 changed files with 1204 additions and 184 deletions
@@ -3,7 +3,9 @@ import test from "node:test";
import {
coalesceAgentAutocompleteCandidates,
filterAdmittedMentionPubkeys,
filterCachedAgentSuggestions,
getAgentMentionAdmission,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInAllowedList,
@@ -258,6 +260,7 @@ test("isAgentIdentityInAllowedList: keeps people and only explicitly allowed age
test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: false,
isMember: false,
pubkey: PUB_A,
@@ -271,6 +274,7 @@ test("shouldHideAgentFromMentions: never hides non-agents", () => {
test("shouldHideAgentFromMentions: shows invocable agents even when non-member", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: false,
pubkey: PUB_A,
@@ -284,6 +288,7 @@ test("shouldHideAgentFromMentions: shows invocable agents even when non-member",
test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: false,
pubkey: PUB_A,
@@ -297,6 +302,7 @@ test("shouldHideAgentFromMentions: hides non-member non-invocable agents", () =>
test("shouldHideAgentFromMentions: hides member agents with an explicit not-invocable directory entry (Fizz)", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: true,
pubkey: PUB_A,
@@ -307,25 +313,100 @@ test("shouldHideAgentFromMentions: hides member agents with an explicit not-invo
);
});
test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => {
test("shouldHideAgentFromMentions: hides member agents without an affirmative directory grant", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set(),
}),
true,
);
});
test("shouldHideAgentFromMentions: hides unknown member agents while directories load", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set(),
directoryReady: false,
}),
true,
);
});
test("shouldHideAgentFromMentions: hides mentionable member agents while directories load", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set([PUB_A]),
directoryAgentPubkeys: new Set(),
directoryReady: false,
}),
true,
);
});
test("shouldHideAgentFromMentions: shows non-agent members while directories load", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: false,
isMember: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set([PUB_A]),
directoryReady: false,
}),
false,
);
});
test("shouldHideAgentFromMentions: hides unknown member agents after empty directories settle", () => {
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set(),
directoryAgentPubkeys: new Set(),
directoryReady: true,
}),
true,
);
});
test("shouldHideAgentFromMentions: hides agents while owner policy loads", () => {
assert.equal(
shouldHideAgentFromMentions({
isAgent: true,
pubkey: PUB_A,
mentionableAgentPubkeys: new Set([PUB_A]),
directoryReady: true,
ownerOnly: undefined,
}),
true,
);
});
test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => {
const mixedCase = "Ab".repeat(32);
const normalized = mixedCase.toLowerCase();
assert.equal(
shouldHideAgentFromMentions({
ownerOnly: false,
isAgent: true,
isMember: true,
pubkey: mixedCase,
@@ -336,6 +417,58 @@ test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => {
);
});
test("getAgentMentionAdmission: owner-only requires current verified ownership", () => {
const common = {
isAgent: true,
isManagedAgent: false,
pubkey: PUB_A,
currentPubkey: CURRENT_PUBKEY,
mentionableAgentPubkeys: new Set([PUB_A]),
directoryReady: true,
ownerOnly: true,
};
assert.equal(
getAgentMentionAdmission({ ...common, ownerPubkey: CURRENT_PUBKEY }),
"allow",
);
assert.equal(
getAgentMentionAdmission({ ...common, ownerPubkey: OTHER_OWNER_PUBKEY }),
"deny",
);
assert.equal(
getAgentMentionAdmission({ ...common, ownerPubkey: null }),
"unknown",
);
});
test("getAgentMentionAdmission: unresolved directory state stays unknown", () => {
assert.equal(
getAgentMentionAdmission({
isAgent: true,
isManagedAgent: false,
pubkey: PUB_A,
currentPubkey: CURRENT_PUBKEY,
ownerPubkey: CURRENT_PUBKEY,
mentionableAgentPubkeys: new Set([PUB_A]),
directoryReady: false,
ownerOnly: false,
}),
"unknown",
);
});
test("filterAdmittedMentionPubkeys: rechecks agent admission without dropping people", () => {
assert.deepEqual(
filterAdmittedMentionPubkeys(
[PUB_A, PUB_B, PUB_C],
new Set([PUB_A, PUB_B]),
new Set([PUB_B]),
),
[PUB_B, PUB_C],
);
});
test("coalesceAgentAutocompleteCandidates: keeps agents with the same persona id distinct", () => {
const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" });
const second = makeAgent({
@@ -92,37 +92,138 @@ export function isAgentIdentityInAllowedList(
);
}
export function shouldHideAgentFromMentions({
export type AgentMentionAdmission = "allow" | "deny" | "unknown";
export function getAgentMentionAdmission({
isAgent,
isMember,
isManagedAgent,
pubkey,
ownerPubkey,
currentPubkey,
mentionableAgentPubkeys,
directoryAgentPubkeys,
directoryReady,
ownerOnly,
}: {
isAgent: boolean;
isMember: boolean;
isManagedAgent: boolean;
pubkey: string;
ownerPubkey?: string | null;
currentPubkey?: string | null;
mentionableAgentPubkeys: ReadonlySet<string>;
directoryAgentPubkeys: ReadonlySet<string>;
}) {
if (!isAgent) return false;
directoryReady: boolean;
ownerOnly: boolean | undefined;
}): AgentMentionAdmission {
if (!isAgent) return "allow";
if (!directoryReady || ownerOnly === undefined) return "unknown";
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 (!mentionableAgentPubkeys.has(normalized)) return "deny";
if (!ownerOnly || isManagedAgent) return "allow";
if (!ownerPubkey || !currentPubkey) return "unknown";
return normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey)
? "allow"
: "deny";
}
export function shouldHideAgentFromMentions({
isAgent,
isManagedAgent = false,
pubkey,
ownerPubkey,
currentPubkey,
mentionableAgentPubkeys,
directoryReady = true,
ownerOnly,
}: {
isAgent: boolean;
isManagedAgent?: boolean;
pubkey: string;
ownerPubkey?: string | null;
currentPubkey?: string | null;
mentionableAgentPubkeys: ReadonlySet<string>;
directoryReady?: boolean;
ownerOnly: boolean | undefined;
}) {
return (
getAgentMentionAdmission({
isAgent,
isManagedAgent,
pubkey,
ownerPubkey,
currentPubkey,
mentionableAgentPubkeys,
directoryReady,
ownerOnly,
}) !== "allow"
);
}
export function getAgentIdentityPubkeys({
managedAgentPubkeys,
relayAgents,
members,
profileIsAgent,
}: {
managedAgentPubkeys: ReadonlySet<string>;
relayAgents: readonly { pubkey: string }[];
members: readonly {
pubkey: string;
isAgent?: boolean;
role?: string | null;
}[];
profileIsAgent: (pubkey: string) => boolean;
}) {
return new Set([
...managedAgentPubkeys,
...relayAgents.map(({ pubkey }) => normalizePubkey(pubkey)),
...members
.filter(
(member) =>
member.isAgent === true ||
member.role === "bot" ||
profileIsAgent(normalizePubkey(member.pubkey)),
)
.map(({ pubkey }) => normalizePubkey(pubkey)),
]);
}
export function getAdmittedAgentPubkeys(
candidates: readonly { pubkey?: string; isAgent?: boolean }[],
) {
return new Set(
candidates.flatMap((candidate) =>
candidate.isAgent && candidate.pubkey
? [normalizePubkey(candidate.pubkey)]
: [],
),
);
}
export function rememberSelectedAgentPubkeys(
target: Set<string>,
selected: readonly { pubkey?: string; isAgent?: boolean }[],
selectionIsAgent: boolean,
) {
for (const candidate of selected) {
if (candidate.pubkey && (selectionIsAgent || candidate.isAgent === true)) {
target.add(normalizePubkey(candidate.pubkey));
}
}
}
export function filterAdmittedMentionPubkeys(
pubkeys: readonly string[],
agentIdentityPubkeys: ReadonlySet<string>,
admittedAgentPubkeys: ReadonlySet<string>,
) {
return pubkeys.filter((pubkey) => {
const normalized = normalizePubkey(pubkey);
return (
!agentIdentityPubkeys.has(normalized) ||
admittedAgentPubkeys.has(normalized)
);
});
}
export function isAgentMentionChannelType(type?: string | null) {
+94 -48
View File
@@ -58,6 +58,7 @@ export function ForumComposer({
const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [isSubmissionPending, setIsSubmissionPending] = React.useState(false);
const [submitMode, setSubmitMode] = React.useState<"primary" | "secondary">(
"primary",
);
@@ -83,6 +84,7 @@ export function ForumComposer({
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
const isUploadingRef = React.useRef(media.isUploading);
const isSubmissionPendingRef = React.useRef(false);
const onSubmitRef = React.useRef(onSubmit);
const onSecondarySubmitRef = React.useRef(onSecondarySubmit);
const submitModeRef = React.useRef(submitMode);
@@ -111,7 +113,7 @@ export function ForumComposer({
const richText = useRichTextEditor({
placeholder,
editable: !disabled,
editable: !disabled && !isSubmissionPending,
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
messageLinkChannels: channelLinks.channels,
@@ -139,6 +141,7 @@ export function ForumComposer({
// Native ProseMirror transactions — no markdown round-trip.
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
if (isSubmissionPendingRef.current) return;
const { cursor } = richText.getPlainTextAndCursor();
const { replaceFromOffset, replaceToOffset, insertText } =
mentions.insertMention(suggestion, cursor);
@@ -157,6 +160,7 @@ export function ForumComposer({
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
if (isSubmissionPendingRef.current) return;
const { cursor } = richText.getPlainTextAndCursor();
const { replaceFromOffset, replaceToOffset, insertText } =
channelLinks.insertChannel(suggestion, cursor);
@@ -175,7 +179,7 @@ export function ForumComposer({
const insertEmoji = React.useCallback(
(emoji: string) => {
if (!richText.editor) return;
if (isSubmissionPendingRef.current || !richText.editor) return;
richText.editor.chain().focus().insertContent(emoji).run();
setIsEmojiPickerOpen(false);
mentions.clearMentions();
@@ -213,7 +217,7 @@ export function ForumComposer({
// ── Submit ──────────────────────────────────────────────────────────
const submitMessage = React.useCallback(
(submitter = onSubmitRef.current) => {
async (submitter = onSubmitRef.current) => {
const trimmed = contentRef.current.trim();
const currentPendingImeta = media.pendingImetaRef.current;
const hasMedia = currentPendingImeta.length > 0;
@@ -222,58 +226,68 @@ export function ForumComposer({
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current ||
isUploadingRef.current
isUploadingRef.current ||
isSubmissionPendingRef.current
) {
return;
}
const pubkeys = mentions.extractMentionPubkeys(trimmed);
// Reuse the shared send-path builder so forum/notes posts emit the same
// body + imeta as chat: generic files become `[filename](url)` links with a
// `filename` imeta tag (FileCard renderer), images/video stay inline. Send
// semantics use `undefined` for "no attachments" (no imeta tags emitted).
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
currentPendingImeta,
);
// Save draft state so we can restore on failure.
const savedContent = contentRef.current;
const savedImeta = [...currentPendingImeta];
setContent("");
contentRef.current = "";
richText.clearContent();
media.setPendingImeta([]);
mentions.clearMentions();
isSubmissionPendingRef.current = true;
setIsSubmissionPending(true);
mentions.cancelMentionAutocomplete();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
try {
const pubkeys = await mentions.revalidateMentionPubkeys(
mentions.extractMentionPubkeys(trimmed),
);
const result = submitter(finalContent, pubkeys, mediaTags);
const completeSubmission = () => {
setSubmitMode("primary");
if (compact) setIsCompactExpanded(false);
};
// Reuse the shared send-path builder so forum/notes posts emit the same
// body + imeta as chat: generic files become `[filename](url)` links with a
// `filename` imeta tag (FileCard renderer), images/video stay inline. Send
// semantics use `undefined` for "no attachments" (no imeta tags emitted).
const { content: finalContent, mediaTags } = buildOutgoingMessage(
trimmed,
currentPendingImeta,
);
// If onSubmit returns a promise, restore draft on failure.
if (result && typeof result.then === "function") {
result.then(completeSubmission).catch(() => {
// Save draft state so we can restore on failure.
const savedContent = contentRef.current;
const savedImeta = [...currentPendingImeta];
setContent("");
contentRef.current = "";
richText.clearContent();
media.setPendingImeta([]);
mentions.clearMentions();
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
try {
await submitter(finalContent, pubkeys, mediaTags);
setSubmitMode("primary");
if (compact) setIsCompactExpanded(false);
} catch {
setContent(savedContent);
contentRef.current = savedContent;
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
if (compact) setIsCompactExpanded(true);
});
} else {
completeSubmission();
}
} catch {
// Keep the draft intact when authorization refresh fails.
} finally {
isSubmissionPendingRef.current = false;
setIsSubmissionPending(false);
}
},
[
compact,
media.pendingImetaRef,
media.setPendingImeta,
mentions.cancelMentionAutocomplete,
mentions.extractMentionPubkeys,
mentions.revalidateMentionPubkeys,
mentions.clearMentions,
channelLinks.clearChannels,
richText.clearContent,
@@ -375,9 +389,16 @@ export function ForumComposer({
const sendDisabled = React.useMemo(
() =>
disabled ||
isSubmissionPending ||
media.isUploading ||
(content.trim().length === 0 && media.pendingImeta.length === 0),
[disabled, media.isUploading, content, media.pendingImeta.length],
[
disabled,
isSubmissionPending,
media.isUploading,
content,
media.pendingImeta.length,
],
);
const hasComposerContent =
content.trim().length > 0 ||
@@ -448,15 +469,30 @@ export function ForumComposer({
"relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4",
className,
)}
inert={isSubmissionPending ? true : undefined}
onBlurCapture={handleFormBlur}
onDragEnter={(event) => {
if (isSubmissionPending) {
event.preventDefault();
return;
}
expandCompactComposer();
media.handleDragEnter(event);
}}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
onDragOver={(event) => {
if (isSubmissionPending) {
event.preventDefault();
return;
}
media.handleDragOver(event);
}}
onDrop={(event) => {
if (isSubmissionPending) {
event.preventDefault();
return;
}
void media.handleDrop(event);
}}
onFocusCapture={expandCompactComposer}
onSubmit={handleSubmit}
@@ -466,7 +502,7 @@ export function ForumComposer({
<ForumComposerCompactLayout
editor={richText.editor}
header={header}
isSending={isSending}
isSending={Boolean(isSending || isSubmissionPending)}
onEditorKeyDown={handleEditorKeyDown}
sendDisabled={sendDisabled}
/>
@@ -496,7 +532,15 @@ export function ForumComposer({
position={autocompletePosition}
/>
<ForumComposerMediaStatus media={media} />
<fieldset
className="min-w-0 border-0 p-0"
disabled={isSubmissionPending}
>
<ForumComposerMediaStatus
disabled={isSubmissionPending}
media={media}
/>
</fieldset>
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
@@ -507,14 +551,14 @@ export function ForumComposer({
</div>
<MessageComposerToolbar
composerDisabled={disabled ?? false}
composerDisabled={Boolean(disabled || isSubmissionPending)}
editor={richText.editor}
extraActions={
onCancel || (onSecondarySubmit && secondarySubmitLabel) ? (
<>
{onCancel ? (
<Button
disabled={isSending}
disabled={isSending || isSubmissionPending}
onClick={onCancel}
size="sm"
type="button"
@@ -531,7 +575,9 @@ export function ForumComposer({
submitMode === "secondary" &&
"border-amber-500/40 text-amber-700 hover:bg-amber-500/10 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300",
)}
disabled={disabled || isSending}
disabled={
disabled || isSending || isSubmissionPending
}
size="sm"
type="button"
variant="outline"
@@ -562,10 +608,10 @@ export function ForumComposer({
</>
) : undefined
}
formattingDisabled={disabled ?? false}
formattingDisabled={Boolean(disabled || isSubmissionPending)}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending ?? false}
isSending={Boolean(isSending || isSubmissionPending)}
isUploading={media.isUploading}
onCaptureSelection={handleToolbarMouseDown}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
@@ -579,8 +625,8 @@ export function ForumComposer({
</>
)}
</form>
{linkEditor.card}
{linkEditor.dialog}
{!isSubmissionPending && linkEditor.card}
{!isSubmissionPending && linkEditor.dialog}
</>
);
}
@@ -19,17 +19,27 @@ type ComposerMedia = Pick<
>;
type ForumComposerMediaStatusProps = {
disabled?: boolean;
media: ComposerMedia;
};
export function ForumComposerMediaStatus({
disabled = false,
media,
}: ForumComposerMediaStatusProps) {
const handleEditSave = React.useCallback(
async (url: string, bytes: Uint8Array) => {
if (disabled) return;
await media.uploadEditedAttachment(url, bytes);
},
[media.uploadEditedAttachment],
[disabled, media.uploadEditedAttachment],
);
const guard = React.useCallback(
<T,>(callback: (value: T) => void) =>
(value: T) => {
if (!disabled) callback(value);
},
[disabled],
);
return (
@@ -39,6 +49,7 @@ export function ForumComposerMediaStatus({
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
disabled={disabled}
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
@@ -52,10 +63,10 @@ export function ForumComposerMediaStatus({
<ComposerAttachments
attachments={media.pendingImeta}
isUploading={media.isUploading}
onCancelUpload={media.cancelUpload}
onCancelUpload={guard(media.cancelUpload)}
onEditSave={handleEditSave}
onRemove={media.removeAttachment}
onRevert={media.revertAttachment}
onRemove={guard(media.removeAttachment)}
onRevert={guard(media.revertAttachment)}
originalUrlByUrl={media.originalUrlByUrl}
uploadingCount={media.uploadingCount}
uploadingPreviews={media.uploadingPreviews}
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import { revalidateAgentMentionPubkeys } from "./agentMentionRevalidation.ts";
const CURRENT = "a".repeat(64);
const AGENT = "b".repeat(64);
const HUMAN = "c".repeat(64);
const OTHER_OWNER = "d".repeat(64);
function options(refetchOwnerProfiles) {
return {
pubkeys: [HUMAN, AGENT],
agentPubkeys: new Set([AGENT]),
currentPubkey: CURRENT,
eligibilityScope: { type: "channel", channelId: "general" },
sharedChannelIds: new Set(["general"]),
ownerOnly: true,
ownerPolicyError: null,
refetchManagedAgents: async () => ({ data: [], error: null }),
refetchRelayAgents: async () => ({
data: [
{
pubkey: AGENT,
respondTo: "anyone",
respondToAllowlist: [],
channelIds: ["general"],
},
],
error: null,
}),
refetchOwnerProfiles,
};
}
test("owner-only revalidation admits an agent only from a fresh same-owner proof", async () => {
const requested = [];
const result = await revalidateAgentMentionPubkeys(
options(async (pubkeys) => {
requested.push(...pubkeys);
return {
profiles: { [AGENT]: { ownerPubkey: CURRENT } },
missing: [],
};
}),
);
assert.deepEqual(requested, [AGENT]);
assert.deepEqual(result, [HUMAN, AGENT]);
});
for (const [name, refetchOwnerProfiles] of [
["revoked owner proof", async () => ({ profiles: {}, missing: [AGENT] })],
[
"changed owner proof",
async () => ({
profiles: { [AGENT]: { ownerPubkey: OTHER_OWNER } },
missing: [],
}),
],
[
"owner profile query error",
async () => {
throw new Error("relay unavailable");
},
],
]) {
test(`owner-only revalidation fails closed on ${name}`, async () => {
assert.deepEqual(
await revalidateAgentMentionPubkeys(options(refetchOwnerProfiles)),
[HUMAN],
);
});
}
@@ -0,0 +1,156 @@
import {
filterAdmittedMentionPubkeys,
getAgentMentionAdmission,
getMentionableAgentPubkeys,
type AgentEligibilityScope,
} from "@/features/agents/lib/agentAutocompleteEligibility";
import { evictUsersBatchEntries } from "@/features/profile/hooks";
import { getUsersBatch } from "@/shared/api/tauriProfiles";
import type {
ManagedAgent,
RelayAgent,
UsersBatchResponse,
} from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useQueryClient } from "@tanstack/react-query";
import * as React from "react";
type DirectoryResult<T> = {
data: T | undefined;
error: Error | null;
};
export async function revalidateAgentMentionPubkeys({
pubkeys,
agentPubkeys,
currentPubkey,
eligibilityScope,
sharedChannelIds,
ownerOnly,
ownerPolicyError,
refetchManagedAgents,
refetchRelayAgents,
refetchOwnerProfiles,
}: {
pubkeys: readonly string[];
agentPubkeys: ReadonlySet<string>;
currentPubkey: string | null;
eligibilityScope: AgentEligibilityScope;
sharedChannelIds: ReadonlySet<string>;
ownerOnly: boolean | undefined;
ownerPolicyError: Error | null;
refetchManagedAgents: () => Promise<DirectoryResult<ManagedAgent[]>>;
refetchRelayAgents: () => Promise<DirectoryResult<RelayAgent[]>>;
refetchOwnerProfiles: (pubkeys: string[]) => Promise<UsersBatchResponse>;
}) {
const requestedAgentPubkeys = new Set(
pubkeys.map(normalizePubkey).filter((pubkey) => agentPubkeys.has(pubkey)),
);
if (requestedAgentPubkeys.size === 0) {
return [...pubkeys];
}
const [managedResult, relayResult, ownerProfiles] = await Promise.all([
refetchManagedAgents(),
refetchRelayAgents(),
ownerOnly
? refetchOwnerProfiles([...requestedAgentPubkeys]).catch(() => null)
: Promise.resolve(null),
]);
if (
managedResult.error !== null ||
relayResult.error !== null ||
managedResult.data === undefined ||
relayResult.data === undefined ||
ownerOnly === undefined ||
ownerPolicyError !== null ||
(ownerOnly && ownerProfiles === null)
) {
return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set());
}
const managedPubkeys = new Set(
managedResult.data.map((agent) => normalizePubkey(agent.pubkey)),
);
const mentionablePubkeys = getMentionableAgentPubkeys({
currentPubkey,
eligibilityScope,
managedAgentPubkeys: managedPubkeys,
relayAgents: relayResult.data,
sharedChannelIds,
});
const admittedPubkeys = new Set(
[...agentPubkeys].filter(
(pubkey) =>
getAgentMentionAdmission({
isAgent: true,
isManagedAgent: managedPubkeys.has(pubkey),
pubkey,
ownerPubkey: ownerProfiles?.profiles[pubkey]?.ownerPubkey,
currentPubkey,
mentionableAgentPubkeys: mentionablePubkeys,
directoryReady: true,
ownerOnly,
}) === "allow",
),
);
return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, admittedPubkeys);
}
export function useAgentMentionRevalidation({
agentPubkeys,
getSelectedAgentPubkeys,
currentPubkey,
eligibilityScope,
sharedChannelIds,
ownerOnly,
ownerPolicyError,
refetchManagedAgents,
refetchRelayAgents,
}: {
agentPubkeys: ReadonlySet<string>;
getSelectedAgentPubkeys: () => ReadonlySet<string>;
currentPubkey: string | null;
eligibilityScope: AgentEligibilityScope;
sharedChannelIds: ReadonlySet<string>;
ownerOnly: boolean | undefined;
ownerPolicyError: Error | null;
refetchManagedAgents: () => Promise<DirectoryResult<ManagedAgent[]>>;
refetchRelayAgents: () => Promise<DirectoryResult<RelayAgent[]>>;
}) {
const queryClient = useQueryClient();
const refetchOwnerProfiles = React.useCallback(
async (pubkeys: string[]) => {
evictUsersBatchEntries(queryClient, pubkeys);
return getUsersBatch(pubkeys);
},
[queryClient],
);
return React.useCallback(
(pubkeys: readonly string[]) =>
revalidateAgentMentionPubkeys({
pubkeys,
agentPubkeys: new Set([...agentPubkeys, ...getSelectedAgentPubkeys()]),
currentPubkey,
eligibilityScope,
sharedChannelIds,
ownerOnly,
ownerPolicyError,
refetchManagedAgents,
refetchRelayAgents,
refetchOwnerProfiles,
}),
[
agentPubkeys,
currentPubkey,
eligibilityScope,
getSelectedAgentPubkeys,
ownerOnly,
ownerPolicyError,
refetchManagedAgents,
refetchOwnerProfiles,
refetchRelayAgents,
sharedChannelIds,
],
);
}
@@ -1,7 +1,30 @@
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import type { AgentPersona, AgentTeam, ChannelRole } from "@/shared/api/types";
import type {
AgentPersona,
AgentTeam,
ChannelRole,
UserSearchResult,
} from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";
export function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}
export function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
return displayName && nip05Handle ? nip05Handle : null;
}
export function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
)
? current
: [...current, name];
}
export type TeamMentionMember = {
displayName: string;
kind: "identity" | "persona";
@@ -5,6 +5,7 @@ import {
useRelayAgentsQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly";
import {
useChannelMembersQuery,
useChannelsQuery,
@@ -14,11 +15,14 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple
import {
coalesceAgentAutocompleteCandidates,
coalesceAutocompleteCandidatesByKey,
filterAdmittedMentionPubkeys,
filterCachedAgentSuggestions,
getAdmittedAgentPubkeys,
getAgentIdentityPubkeys,
getMentionableAgentPubkeys,
getSharedChannelIds,
isAgentIdentityInAllowedList,
isAgentMentionChannelType,
rememberSelectedAgentPubkeys,
shouldHideAgentFromMentions,
uniqueAutocompleteLabels,
} from "@/features/agents/lib/agentAutocompleteEligibility";
@@ -32,20 +36,23 @@ import type {
AgentPersona,
ChannelMember,
ChannelType,
UserSearchResult,
} from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import { flushMentionDebounce } from "./flushMentionDebounce";
import { useAgentMentionRevalidation } from "./agentMentionRevalidation";
import { hasMention } from "./hasMention";
import { extractMentionPubkeys } from "./extractMentionPubkeys";
import { useDraftMentionRouting } from "./useDraftMentionRouting";
import { rankMentionCandidates } from "./mentionRanking";
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
import {
appendUniqueName,
buildTeamMentionCandidates,
formatSearchUserDisplayName,
formatSearchUserSecondaryLabel,
formatTeamMention,
globalSearchIdentityKey,
type MentionCandidate,
@@ -60,25 +67,6 @@ export type PersonaMentionTarget = {
type UseMentionsOptions = {
channelType?: ChannelType | null;
};
function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}
function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}
function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
)
? current
: [...current, name];
}
export function useMentions(
channelId: string | null,
externalMembers?: ChannelMember[],
@@ -94,6 +82,7 @@ export function useMentions(
const [selectedAgentMentionNames, setSelectedAgentMentionNames] =
React.useState<string[]>([]);
const selectedAgentMentionNamesRef = React.useRef<string[]>([]);
const selectedAgentMentionPubkeysRef = React.useRef<Set<string>>(new Set());
selectedAgentMentionNamesRef.current = selectedAgentMentionNames;
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
const personaMentionMapRef = React.useRef<Map<string, string>>(new Map());
@@ -112,18 +101,22 @@ export function useMentions(
const channelsQuery = useChannelsQuery();
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const agentAccessOwnerOnlyQuery = useAgentAccessOwnerOnlyQuery();
const managedAgentDirectoryReady =
managedAgentsQuery.data !== undefined ||
!managedAgentsQuery.isLoading ||
managedAgentsQuery.error !== null;
managedAgentsQuery.data !== undefined &&
managedAgentsQuery.error === null &&
!managedAgentsQuery.isFetching;
const relayAgentDirectoryReady =
relayAgentsQuery.data !== undefined ||
!relayAgentsQuery.isLoading ||
relayAgentsQuery.error !== null;
const canSearchGlobalUsers =
canSearchGlobalPeople &&
managedAgentDirectoryReady &&
relayAgentDirectoryReady;
relayAgentsQuery.data !== undefined &&
relayAgentsQuery.error === null &&
!relayAgentsQuery.isFetching;
const ownerPolicyReady =
agentAccessOwnerOnlyQuery.data !== undefined &&
agentAccessOwnerOnlyQuery.error === null &&
!agentAccessOwnerOnlyQuery.isFetching;
const agentDirectoriesReady =
managedAgentDirectoryReady && relayAgentDirectoryReady && ownerPolicyReady;
const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady;
const userSearchQuery = useInfiniteUserSearchQuery(mentionQuery ?? "", {
allowEmpty: true,
enabled: canSearchGlobalUsers && mentionQuery !== null,
@@ -183,15 +176,6 @@ export function useMentions(
),
[relayAgentsQuery.data],
);
const directoryAgentPubkeys = React.useMemo(
() =>
new Set(
(relayAgentsQuery.data ?? []).map((agent) =>
normalizePubkey(agent.pubkey),
),
),
[relayAgentsQuery.data],
);
const sharedChannelIds = React.useMemo(
() => getSharedChannelIds(channelsQuery.data),
[channelsQuery.data],
@@ -231,7 +215,10 @@ export function useMentions(
}
return lookup;
}, [managedAgentsQuery.data, personasQuery.data]);
const knownAgentPubkeys = mentionableAgentPubkeys;
const knownAgentPubkeys = React.useMemo(
() => new Set([...mentionableAgentPubkeys, ...managedAgentPubkeys]),
[managedAgentPubkeys, mentionableAgentPubkeys],
);
const activePersonas = React.useMemo(
() => (personasQuery.data ?? []).filter((persona) => persona.isActive),
[personasQuery.data],
@@ -249,24 +236,36 @@ export function useMentions(
new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))),
[members],
);
const agentIdentityPubkeys = React.useMemo(
() =>
getAgentIdentityPubkeys({
managedAgentPubkeys,
relayAgents: relayAgentsQuery.data ?? [],
members: members ?? [],
profileIsAgent: (pubkey) => profiles?.[pubkey]?.isAgent === true,
}),
[managedAgentPubkeys, members, profiles, relayAgentsQuery.data],
);
const mentionCandidates = React.useMemo<MentionCandidate[]>(() => {
const candidatesByPubkey = new Map<string, MentionCandidate>();
const addCandidate = (candidate: MentionCandidate & { pubkey: string }) => {
const pubkey = normalizePubkey(candidate.pubkey);
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys)) {
return;
}
if (
shouldHideAgentFromMentions({
isAgent: candidate.isAgent === true,
isMember: candidate.isMember === true,
isManagedAgent: candidate.isManagedAgent === true,
pubkey,
ownerPubkey: candidate.ownerPubkey,
currentPubkey,
mentionableAgentPubkeys,
directoryAgentPubkeys,
directoryReady:
candidate.isManagedAgent === true
? managedAgentDirectoryReady
: relayAgentDirectoryReady,
ownerOnly: agentAccessOwnerOnlyQuery.data,
})
) {
return;
@@ -276,7 +275,6 @@ export function useMentions(
candidatesByPubkey.set(pubkey, { ...candidate, pubkey });
return;
}
candidatesByPubkey.set(pubkey, {
...current,
avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null,
@@ -341,7 +339,6 @@ export function useMentions(
: null,
});
}
for (const agent of relayAgentsQuery.data ?? []) {
const pubkey = normalizePubkey(agent.pubkey);
addCandidate({
@@ -356,7 +353,6 @@ export function useMentions(
isAgent: true,
});
}
for (const agent of managedAgentsQuery.data ?? []) {
addCandidate({
kind: "identity",
@@ -371,7 +367,6 @@ export function useMentions(
ownerPubkey: currentPubkey,
});
}
if (canSearchGlobalUsers) {
for (const user of userSearchResults) {
const pubkey = normalizePubkey(user.pubkey);
@@ -396,7 +391,6 @@ export function useMentions(
});
}
}
const personaCandidates: MentionCandidate[] = activePersonas
.filter((persona) => !managedAgentPersonaIds.has(persona.id))
.map((persona) => ({
@@ -408,7 +402,6 @@ export function useMentions(
isAgent: true,
}))
.filter((candidate) => candidate.displayName.trim().length > 0);
return coalesceAgentAutocompleteCandidates(
coalesceAutocompleteCandidatesByKey(
[...candidatesByPubkey.values(), ...personaCandidates],
@@ -423,11 +416,12 @@ export function useMentions(
}, [
activePersonaById,
activePersonas,
agentAccessOwnerOnlyQuery.data,
userSearchResults,
canSearchGlobalUsers,
currentPubkey,
directoryAgentPubkeys,
isArchivedDiscovery,
managedAgentDirectoryReady,
managedAgentNamesByPubkey,
managedAgentPersonaIds,
managedAgentPersonaIdsByPubkey,
@@ -437,10 +431,14 @@ export function useMentions(
mentionableAgentPubkeys,
personaNameByPubkey,
profiles,
relayAgentDirectoryReady,
relayAgentNamesByPubkey,
relayAgentsQuery.data,
]);
const admittedAgentPubkeys = React.useMemo(
() => getAdmittedAgentPubkeys(mentionCandidates),
[mentionCandidates],
);
const mentionCandidatesWithTeams = React.useMemo(
() => [
...mentionCandidates,
@@ -452,7 +450,6 @@ export function useMentions(
],
[mentionCandidates, personasQuery.data, teamsQuery.data],
);
const ownerPubkeys = React.useMemo(
() => [
...new Set(
@@ -466,7 +463,6 @@ export function useMentions(
const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, {
enabled: ownerPubkeys.length > 0,
});
const searchableNames = React.useMemo(
() => uniqueAutocompleteLabels(mentionCandidatesWithTeams),
[mentionCandidatesWithTeams],
@@ -651,6 +647,11 @@ export function useMentions(
(suggestion.pubkey
? knownAgentPubkeys.has(normalizePubkey(suggestion.pubkey))
: false);
rememberSelectedAgentPubkeys(
selectedAgentMentionPubkeysRef.current,
selectedMentions,
isAgentMention,
);
if (isAgentMention) {
setSelectedAgentMentionNames((current) => {
const known = new Set(current.map((name) => name.toLowerCase()));
@@ -794,15 +795,40 @@ export function useMentions(
);
const extractMentionPubkeysForCurrentMentions = React.useCallback(
(text: string): string[] =>
extractMentionPubkeys({
(text: string): string[] => {
const extracted = extractMentionPubkeys({
text,
selectedMentions: mentionMapRef.current,
selectedDisplayNames: personaMentionMapRef.current.keys(),
memberCandidates: mentionCandidates,
}),
[mentionCandidates],
});
return filterAdmittedMentionPubkeys(
extracted,
new Set([
...agentIdentityPubkeys,
...selectedAgentMentionPubkeysRef.current,
]),
admittedAgentPubkeys,
);
},
[admittedAgentPubkeys, agentIdentityPubkeys, mentionCandidates],
);
const getSelectedAgentPubkeys = React.useRef(
() => selectedAgentMentionPubkeysRef.current,
).current;
const revalidateMentionPubkeys = useAgentMentionRevalidation({
agentPubkeys: agentIdentityPubkeys,
getSelectedAgentPubkeys,
currentPubkey,
eligibilityScope: mentionChannelId
? { type: "channel", channelId: mentionChannelId }
: { type: "managed-only" },
sharedChannelIds,
ownerOnly: agentAccessOwnerOnlyQuery.data,
ownerPolicyError: agentAccessOwnerOnlyQuery.error,
refetchManagedAgents: managedAgentsQuery.refetch,
refetchRelayAgents: relayAgentsQuery.refetch,
});
const extractMentionPersonas = React.useCallback(
(text: string): PersonaMentionTarget[] => {
@@ -838,12 +864,12 @@ export function useMentions(
setMentionQuery(null);
setMentionSelectedIndex(0);
}, []);
const clearMentions = React.useCallback(() => {
cancelMentionAutocomplete();
mentionMapRef.current.clear();
personaMentionMapRef.current.clear();
selectedAgentMentionNamesRef.current = [];
selectedAgentMentionPubkeysRef.current.clear();
setSelectedMentionNames([]);
setSelectedAgentMentionNames([]);
}, [cancelMentionAutocomplete]);
@@ -946,6 +972,7 @@ export function useMentions(
clearMentions,
extractMentionPersonas,
extractMentionPubkeys: extractMentionPubkeysForCurrentMentions,
revalidateMentionPubkeys,
getDraftMentionRefs,
getMentionDisplayName,
handleMentionKeyDown,
@@ -516,9 +516,7 @@ function MessageComposerImpl({
// Edit mode
if (editTargetRef.current && onEditSaveRef.current) {
if (isEditSubmissionLocked) return;
// No empty-edit guard here: clearing an edit to empty (no text, no
// attachments) flows through to onEditSave as empty content, which
// deletes the message instead of publishing it (see handleEditSave).
// Empty edits delete the message through handleEditSave.
await submitMessageEdit({
content: trimmed,
editTargetId: editTargetRef.current.id,
@@ -551,6 +549,7 @@ function MessageComposerImpl({
setSpoileredAttachmentUrls(draft.spoileredAttachmentUrls);
},
restoreMentionRefs: mentions.restoreDraftMentionRefs,
revalidateMentionPubkeys: mentions.revalidateMentionPubkeys,
shouldRestoreComposer: () => canRestoreEditDraftRef.current,
setDeferredUploadPending: setDeferredEditPending,
setUploadError: (message) =>
@@ -638,6 +637,7 @@ function MessageComposerImpl({
effectiveDraftKey,
mentions.getDraftMentionRefs,
mentions.restoreDraftMentionRefs,
mentions.revalidateMentionPubkeys,
]);
submitMessageRef.current = submitMessage;
// ── Auto-submit on draft send ────────────────────────────────────────────
@@ -29,6 +29,7 @@ function baseOptions(
queuedAttachments: [],
restoreComposer: () => {},
restoreMentionRefs: () => {},
revalidateMentionPubkeys: async (pubkeys) => [...pubkeys],
setDeferredUploadPending: () => {},
setUploadError: () => {},
shouldRestoreComposer: () => true,
@@ -81,3 +82,63 @@ test("edit save uses edit-target refs that resolve after edit-open", async () =>
eventId: "event-id",
});
});
test("edit save revalidates added mentions immediately before save", async () => {
const agent = "c".repeat(64);
const calls = [];
await submitMessageEdit({
...baseOptions(async (_content, _tags, mentionPubkeys) => {
calls.push(["save", mentionPubkeys]);
}),
content: "hello @Agent",
originalContent: "hello",
extractMentionPubkeys: (content) =>
content.includes("@Agent") ? [agent] : [],
revalidateMentionPubkeys: async (pubkeys) => {
calls.push(["revalidate", pubkeys]);
return [];
},
});
assert.deepEqual(calls, [
["revalidate", [agent]],
["save", []],
]);
});
test("edit upload pause revalidates revoked mentions only after upload completes", async () => {
const agent = "d".repeat(64);
const calls = [];
let completeUpload;
await submitMessageEdit({
...baseOptions(async (_content, _tags, mentionPubkeys) => {
calls.push(["save", mentionPubkeys]);
}),
content: "hello @Agent",
originalContent: "hello",
extractMentionPubkeys: (content) =>
content.includes("@Agent") ? [agent] : [],
queuedAttachments: [
{
file: new File(["image"], "image.png", { type: "image/png" }),
id: 1,
spoilered: false,
},
],
enqueueUpload: ({ onComplete }) => {
completeUpload = () => onComplete([], new AbortController().signal);
return {};
},
revalidateMentionPubkeys: async (pubkeys) => {
calls.push(["revalidate", pubkeys]);
return [];
},
});
assert.deepEqual(calls, []);
await completeUpload();
assert.deepEqual(calls, [
["revalidate", [agent]],
["save", []],
]);
});
@@ -31,6 +31,7 @@ type SubmitMessageEditOptions = Omit<
extractMentionPubkeys: (content: string) => string[];
getMentionRefs: (content: string) => DraftMentionRef[];
editTargetId: string;
enqueueUpload?: typeof enqueueBackgroundMediaUpload;
editTarget: Pick<
MessageComposerEditTarget,
"mentionRefs" | "unresolvedMentionPubkeys"
@@ -39,6 +40,7 @@ type SubmitMessageEditOptions = Omit<
ownerPubkey: string | null;
restoreComposer: (draft: EditDraft) => void;
restoreMentionRefs: (refs: DraftMentionRef[]) => void;
revalidateMentionPubkeys: (pubkeys: readonly string[]) => Promise<string[]>;
shouldRestoreComposer: () => boolean;
setDeferredUploadPending: (isPending: boolean) => void;
save: (
@@ -55,6 +57,7 @@ export async function submitMessageEdit({
content,
customEmoji,
editTargetId,
enqueueUpload = enqueueBackgroundMediaUpload,
editTarget,
extractMentionPubkeys,
getMentionRefs,
@@ -64,6 +67,7 @@ export async function submitMessageEdit({
queuedAttachments,
restoreComposer,
restoreMentionRefs,
revalidateMentionPubkeys,
setDeferredUploadPending,
shouldRestoreComposer,
save,
@@ -122,11 +126,19 @@ export async function submitMessageEdit({
],
);
if (signal?.aborted) return;
await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId);
const revalidatedMentionPubkeys =
await revalidateMentionPubkeys(addedMentionPubkeys);
if (signal?.aborted) return;
await save(
finalContent,
outgoingTags,
revalidatedMentionPubkeys,
editTargetId,
);
};
if (hasQueuedAttachments) {
enqueueBackgroundMediaUpload({
enqueueUpload({
attachments: draft.queuedAttachments,
onComplete: async (uploaded, signal) => {
try {
@@ -240,7 +240,6 @@ export function useMentionSendFlow({
startAgentMutation,
],
);
const createMentionedPersonaAgents = React.useCallback(
async (trimmed: string, capturedChannelId: string) => {
const personaMentions = mentions.extractMentionPersonas(trimmed);
@@ -251,7 +250,6 @@ export function useMentionSendFlow({
pubkeys: [] as string[],
};
}
const runtimes = await getAvailableRuntimes();
const defaultRuntime = runtimes[0] ?? null;
const errors: string[] = [];
@@ -260,13 +258,11 @@ export function useMentionSendFlow({
const seenPersonaIds = new Set<string>();
const shouldProvisionForDm =
channelType === "dm" && Boolean(onPrepareSendChannel);
for (const { displayName, persona } of personaMentions) {
if (seenPersonaIds.has(persona.id)) {
continue;
}
seenPersonaIds.add(persona.id);
const { runtime } = resolvePersonaRuntime(
persona.runtime,
runtimes,
@@ -276,7 +272,6 @@ export function useMentionSendFlow({
errors.push(`${displayName}: No agent runtime available.`);
continue;
}
try {
const input: CreateChannelManagedAgentInput & {
channelId: string;
@@ -309,7 +304,6 @@ export function useMentionSendFlow({
);
}
}
return {
agents,
errors,
@@ -404,8 +398,15 @@ export function useMentionSendFlow({
};
let uploadStarted = false;
try {
const admittedMentionPubkeys = uniqueNormalizedPubkeys(
await mentions.revalidateMentionPubkeys(mentionPubkeys),
);
if (!isMountedRef.current) return persistPreflightDraft();
const admittedMentionPubkeySet = new Set(admittedMentionPubkeys);
const readyAgentPubkeys = new Set(
(draft.readyAgentPubkeys ?? []).map(normalizePubkey),
uniqueNormalizedPubkeys(draft.readyAgentPubkeys ?? []).filter(
(pubkey) => admittedMentionPubkeySet.has(pubkey),
),
);
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
if (!isMountedRef.current) {
@@ -415,8 +416,7 @@ export function useMentionSendFlow({
for (const agent of draft.preparedManagedAgents ?? []) {
managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent);
}
const normalizedMentionPubkeys =
uniqueNormalizedPubkeys(mentionPubkeys);
const normalizedMentionPubkeys = admittedMentionPubkeys;
const managedMentionPubkeys = normalizedMentionPubkeys.filter(
(pubkey) => managedAgentsByPubkey.has(pubkey),
);
@@ -463,7 +463,6 @@ export function useMentionSendFlow({
toast.error(message);
return;
}
if (preparedAgentPubkeys.length > 0 && sendChannelId) {
try {
await invokeTauri("sync_agents_to_active_huddle", {
@@ -480,13 +479,11 @@ export function useMentionSendFlow({
return;
}
}
const effectiveExplicitAgentPubkeys =
filterEffectiveExplicitAgentPubkeys(
draft.explicitAgentPubkeys,
mentionPubkeys,
);
const send = onSendRef.current;
const persistCanceledDraft = () => {
if (!draft.recoveryDraftKey) return;
@@ -560,23 +557,28 @@ export function useMentionSendFlow({
outgoingTags ?? [],
);
if (signal?.aborted) return;
const revalidatedMentionPubkeys =
await mentions.revalidateMentionPubkeys(mentionPubkeys);
if (signal?.aborted) return;
const revalidatedExplicitAgentPubkeys =
filterEffectiveExplicitAgentPubkeys(
draft.explicitAgentPubkeys,
revalidatedMentionPubkeys,
);
await send(
finalContent,
mentionPubkeys,
revalidatedMentionPubkeys,
finalOutgoingTags,
sendChannelId,
draft.capturedThreadContext,
);
if (signal?.aborted) return;
if (effectiveExplicitAgentPubkeys.length > 0) {
// Promote only explicitly authored agents that remained effective
// for this successful send. "Send without inviting" removes its
// excluded recipients here as well as from event routing.
if (revalidatedExplicitAgentPubkeys.length > 0) {
onSuccessfulExplicitAgentAudience?.({
channelId: sendChannelId ?? draft.capturedChannelId ?? "",
expectedGeneration: draft.audienceGeneration,
expectedRevision: draft.audienceRevision,
explicitAgentPubkeys: effectiveExplicitAgentPubkeys,
explicitAgentPubkeys: revalidatedExplicitAgentPubkeys,
});
}
if (draft.sentDraftKey) {
@@ -612,7 +614,6 @@ export function useMentionSendFlow({
return;
}
}
// Replace the sent body directly with its final post-send state before
// the async network send starts. This avoids an intermediate blank frame
// for persistent audiences while preserving the ordinary empty state.
@@ -647,6 +648,7 @@ export function useMentionSendFlow({
ensureManagedAgentMentionsReady,
getManagedAgentsByPubkey,
mentions.isAgentPubkey,
mentions.revalidateMentionPubkeys,
onPrepareSendChannel,
onSendRef,
onSuccessfulExplicitAgentAudience,
@@ -873,38 +875,37 @@ export function useMentionSendFlow({
);
void completeSend(pendingNonMemberSend, mentionPubkeys, outgoingTags);
}, [completeSend, pendingNonMemberSend]);
const handleInviteNonMembers = React.useCallback(() => {
if (!pendingNonMemberSend) return;
// The dialog hides Invite in this case; this guards the keyboard/programmatic
// path so we surface the reason instead of a raw relay rejection.
if (!canInviteNonMembers) {
setNonMemberPromptError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE);
return;
}
const invitedPubkeys = new Set(
pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey),
);
const mentionPubkeys = uniqueNormalizedPubkeys([
...pendingNonMemberSend.mentionPubkeys,
...pendingNonMemberSend.nonMemberPubkeys,
]);
const outgoingTags = (pendingNonMemberSend.outgoingTags ?? []).filter(
(tag) =>
tag[0] !== MENTION_REFERENCE_TAG ||
!invitedPubkeys.has(normalizePubkey(tag[1] ?? "")),
);
setNonMemberPromptError(null);
void (async () => {
const mentionPubkeys = uniqueNormalizedPubkeys(
await mentions.revalidateMentionPubkeys([
...pendingNonMemberSend.mentionPubkeys,
...pendingNonMemberSend.nonMemberPubkeys,
]),
);
const admittedMentionPubkeys = new Set(mentionPubkeys);
const originalNonMemberPubkeys = new Set(
pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey),
);
const nonMemberPubkeys = [...originalNonMemberPubkeys].filter(
admittedMentionPubkeys.has.bind(admittedMentionPubkeys),
);
const outgoingTags = (pendingNonMemberSend.outgoingTags ?? []).filter(
(tag) =>
tag[0] !== MENTION_REFERENCE_TAG ||
!originalNonMemberPubkeys.has(normalizePubkey(tag[1] ?? "")),
);
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
if (!isMountedRef.current) return;
const peoplePubkeys: string[] = [];
const relayAgentPubkeys: string[] = [];
for (const pubkey of uniqueNormalizedPubkeys(
pendingNonMemberSend.nonMemberPubkeys,
)) {
for (const pubkey of nonMemberPubkeys) {
if (managedAgentsByPubkey.has(pubkey)) {
continue;
}
@@ -960,6 +961,7 @@ export function useMentionSendFlow({
completeSend,
getManagedAgentsByPubkey,
mentions.isAgentPubkey,
mentions.revalidateMentionPubkeys,
pendingNonMemberSend,
]);
+4
View File
@@ -293,6 +293,8 @@ type E2eConfig = {
personaSharePublicationStatuses?: Array<"published" | "queued">;
teams?: MockTeamSeed[];
relayAgents?: MockRelayAgentSeed[];
/** Reject successive relay-agent directory reads, then resume. */
relayAgentListErrors?: (string | null)[];
/** Native-like huddle state seeded from authoritative role-bearing membership. */
huddle?: MockHuddleSeed;
agentListDelayMs?: number;
@@ -7463,6 +7465,8 @@ async function handleListRelayAgents(
config: E2eConfig | undefined,
): Promise<RawRelayAgent[]> {
await delayAgentList(config);
const error = config?.mock?.relayAgentListErrors?.shift();
if (error) throw new Error(error);
syncMockRelayAgentsFromManagedAgents();
return mockRelayAgents.map(cloneRelayAgent);
}
+368
View File
@@ -73,6 +73,22 @@ async function readOutgoingMentionPubkeys(
content: string,
) {
return page.evaluate((expectedContent) => {
const signedEvent = (
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
content?: string;
tags?: string[][];
}>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.find(
(event) => event.content === expectedContent,
);
if (signedEvent) {
return (signedEvent.tags ?? [])
.filter((tag) => tag[0] === "p" && tag[1])
.map((tag) => tag[1]);
}
const entries =
(
window as Window & {
@@ -84,6 +100,25 @@ async function readOutgoingMentionPubkeys(
).__BUZZ_E2E_COMMAND_LOG__ ?? [];
for (const entry of entries) {
if (entry.command === "send_channel_message") {
const payload = entry.payload as
| { content?: string; mentionPubkeys?: string[] }
| undefined;
if (payload?.content === expectedContent) {
return payload.mentionPubkeys ?? [];
}
}
if (entry.command === "sign_event") {
const unsignedEvent = entry.payload as
| { content?: string; tags?: string[][] }
| undefined;
if (unsignedEvent?.content !== expectedContent) continue;
return (unsignedEvent.tags ?? [])
.filter((tag) => tag[0] === "p" && tag[1])
.map((tag) => tag[1]);
}
if (entry.command !== "plugin:websocket|send") continue;
const data = (
entry.payload as { message?: { data?: string } } | undefined
@@ -969,6 +1004,74 @@ test("relay-only shared agents appear in forum mentions", async ({ page }) => {
).toBeVisible();
});
test("forum sends revalidate relay-agent authorization before signing", async ({
page,
}) => {
await installMockBridge(page, {
deferredComposerUploads: true,
uploadDescriptors: [
{
url: `https://mock.relay/media/${"f".repeat(64)}.pdf`,
sha256: "f".repeat(64),
size: 12345,
type: "application/pdf",
uploaded: Math.floor(Date.now() / 1000),
filename: "forum-race.pdf",
},
],
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["watercooler"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await page.getByRole("button", { name: "Start a new post..." }).click();
const input = page.getByTestId("message-input");
await input.fill("@quinn");
await page.getByTestId("mention-autocomplete").getByText("quinn").click();
await page.keyboard.type("hello");
await page.getByRole("button", { name: "Attach file" }).click();
const removeAttachment = page.getByRole("button", {
name: "Remove attachment",
});
await expect(removeAttachment).toBeVisible();
await page.evaluate(() => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000;
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill(
"mock forum directory revoked before send",
);
});
await page.getByTestId("send-message").click();
await expect(input).toHaveAttribute("contenteditable", "false");
await expect(removeAttachment).toBeDisabled();
await removeAttachment.evaluate((button: HTMLButtonElement) =>
button.click(),
);
await expect(removeAttachment).toBeVisible();
await input.focus();
await page.keyboard.type(" later edit");
await expect(input).toContainText("@quinn hello");
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);
});
test("relay-only allowlisted agents are visible in channel mentions", async ({
page,
}) => {
@@ -995,6 +1098,271 @@ test("relay-only allowlisted agents are visible in channel mentions", async ({
await expect(dropdown.getByText("agent")).toBeVisible();
});
test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({
page,
}) => {
await installMockBridge(page, {
relayAgentListErrors: ["mock directory unavailable", null],
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill("@quinn");
await expect(autocomplete(page)).toHaveCount(0);
await page.evaluate(async () => {
await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["relay-agents"],
});
});
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
await page.evaluate(() => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.agentListDelayMs = 1_000;
void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({
queryKey: ["relay-agents"],
});
});
await expect(autocomplete(page).getByText("quinn")).toHaveCount(0);
await expect(autocomplete(page).getByText("quinn")).toBeVisible();
});
test("relay-only allowlisted agents emit a p tag when sent", async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill("@quinn");
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
await expect(quinnRow).toBeVisible();
await quinnRow.click();
await page.keyboard.type("hello");
await expect(input).toHaveText("@quinn hello");
await page.getByTestId("send-message").click();
await page.getByRole("button", { name: "Invite", exact: true }).click();
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
});
test("selected relay agents revoked before send emit no p tag", async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill("@quinn");
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
await expect(quinnRow).toBeVisible();
await quinnRow.click();
await page.keyboard.type("hello");
await page.evaluate(async () => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill(
"mock directory revoked",
);
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as {
invalidateQueries: (filters: {
queryKey: readonly unknown[];
}) => Promise<void>;
getQueryState: (
queryKey: readonly unknown[],
) => { status?: string } | undefined;
};
await queryClient.invalidateQueries({ queryKey: ["relay-agents"] });
if (queryClient.getQueryState(["relay-agents"])?.status !== "error") {
throw new Error(
"relay-agent directory refetch did not enter error state",
);
}
});
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);
const commands = await readCommandLog(page);
for (const command of [
"add_channel_members",
"start_managed_agent",
"attach_managed_agent",
"sync_agents_to_active_huddle",
]) {
expect(commandCount(commands, command)).toBe(
commandCount(baselineCommands, command),
);
}
});
test("selected relay agents revoked after the invite prompt cause no side effects", async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill("@quinn");
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
await expect(quinnRow).toBeVisible();
await quinnRow.click();
await page.keyboard.type("hello");
await page.getByTestId("send-message").click();
const inviteButton = page.getByRole("button", {
name: "Invite",
exact: true,
});
await expect(inviteButton).toBeVisible();
await page.evaluate(() => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill(
"mock directory revoked after invite prompt",
);
});
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);
const commands = await readCommandLog(page);
for (const command of [
"add_channel_members",
"start_managed_agent",
"attach_managed_agent",
"sync_agents_to_active_huddle",
]) {
expect(commandCount(commands, command)).toBe(
commandCount(baselineCommands, command),
);
}
});
test("selected relay agents revoked during send emit no p tag", async ({
page,
}) => {
await installMockBridge(page, {
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill("@quinn");
const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" });
await expect(quinnRow).toBeVisible();
await quinnRow.click();
await page.keyboard.type("hello");
await page.evaluate(() => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.agentListDelayMs = 300;
});
await page.getByTestId("send-message").click();
await page.getByRole("button", { name: "Invite", exact: true }).click();
await page.evaluate(() => {
window.__BUZZ_E2E__.mock ??= {};
window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(100).fill(
"mock directory revoked mid-send",
);
});
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
.not.toBeNull();
await expect
.poll(() => readOutgoingMentionPubkeys(page, "@quinn hello"))
.not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY);
});
test("owner-only builds hide other-owned relay agents", async ({ page }) => {
await installMockBridge(page, {
ownerOnlyAccessBuild: true,
searchProfiles: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
displayName: "quinn",
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
isAgent: true,
},
],
relayAgents: [
{
pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY,
name: "quinn",
respondTo: "allowlist",
respondToAllowlist: [MOCK_VIEWER_PUBKEY],
channelNames: ["general"],
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill("@quinn");
await expect(autocomplete(page)).toHaveCount(0);
});
test("relay-only allowlisted agents stay hidden outside their channel", async ({
page,
}) => {
+2
View File
@@ -250,6 +250,8 @@ type MockBridgeOptions = {
personaSharePublicationStatuses?: Array<"published" | "queued">;
teams?: MockTeamSeed[];
relayAgents?: MockRelayAgentSeed[];
/** Reject successive relay-agent directory reads, then resume. */
relayAgentListErrors?: (string | null)[];
/** Delay both managed and relay agent directory reads. */
agentListDelayMs?: number;
createManagedAgentDelayMs?: number;