Files
buzz/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Wes ee0c7076af Fix cross-owner relay agent mentions in owner-only builds (#6338)
## Summary

- keep `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` scoped to Desktop-managed
local start and provider deployment boundaries
- stop filtering independently operated relay agents by viewer/owner
equality in native discovery, autocomplete, and send-time revalidation
- preserve NIP-OA ownership verification, owner-authored `respond_to`
policy, shared-channel membership, and fail-closed send-time checks
- replace the packaged-build regression expectation with coverage for
cross-owner allowlisted and `respond_to=anyone` relay agents, including
the emitted agent `p` tag

Fixes #6329.

## Why

The packaged 0.5.17 build reused its managed-runtime owner-only
capability in relay-agent mention admission. That silently hid correctly
configured shared agents owned by another operator, even when their
verified policy explicitly authorized the viewer. The capability is
intended to constrain runtimes Desktop starts or deploys, not external
relay agents.

## Validation

- `pnpm --dir desktop test` (5093 passed)
- `cargo test --manifest-path desktop/src-tauri/Cargo.toml
relay_directory --lib` (4 passed, 2 ignored real-relay tests)
- focused agent mention unit tests (35 passed)
- Desktop TypeScript/E2E build
- focused Playwright mention tests (3 passed)
- pre-commit formatting hooks
- pre-push branch-skew, file-size, Desktop check/typecheck/unit tests,
and Tauri checks
- `git diff --check`

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-20 12:35:07 -06:00

387 lines
9.6 KiB
TypeScript

import type { Channel, RelayAgent } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function getSharedChannelIds(channels: readonly Channel[] | undefined) {
return new Set(
(channels ?? [])
.filter((channel) => channel.isMember && channel.archivedAt === null)
.map((channel) => channel.id),
);
}
export function relayAgentIsSharedWithUser(
agent: Pick<
RelayAgent,
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
>,
sharedChannelIds: ReadonlySet<string>,
currentPubkey?: string | null,
) {
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
if (
agent.respondTo === "owner-only" &&
normalizedCurrentPubkey &&
agent.ownerPubkey
) {
return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey;
}
if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) {
return agent.respondToAllowlist
.map((pubkey) => normalizePubkey(pubkey))
.includes(normalizedCurrentPubkey);
}
return (
agent.respondTo === "anyone" &&
agent.channelIds.some((channelId) => sharedChannelIds.has(channelId))
);
}
export function relayAgentCanRespondInChannel(
agent: Pick<
RelayAgent,
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
>,
channelId: string,
currentPubkey?: string | null,
) {
return (
agent.channelIds.includes(channelId) &&
relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey)
);
}
export type AgentEligibilityScope =
| { type: "community" }
| { type: "channel"; channelId: string }
| { type: "managed-only" };
export function getMentionableAgentPubkeys({
currentPubkey,
eligibilityScope,
managedAgentPubkeys,
relayAgents,
sharedChannelIds,
}: {
currentPubkey?: string | null;
eligibilityScope: AgentEligibilityScope;
managedAgentPubkeys: Iterable<string>;
relayAgents: readonly RelayAgent[] | undefined;
sharedChannelIds: ReadonlySet<string>;
}) {
const pubkeys = new Set(
[...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)),
);
for (const agent of relayAgents ?? []) {
const isAllowed =
eligibilityScope.type === "managed-only"
? false
: eligibilityScope.type === "community"
? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)
: relayAgentCanRespondInChannel(
agent,
eligibilityScope.channelId,
currentPubkey,
);
if (isAllowed) {
pubkeys.add(normalizePubkey(agent.pubkey));
}
}
return pubkeys;
}
export function isAgentIdentityInAllowedList(
candidate: { isAgent?: boolean; pubkey: string },
allowedAgentPubkeys: ReadonlySet<string>,
) {
return (
candidate.isAgent !== true ||
allowedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
}
export type AgentMentionAdmission = "allow" | "deny" | "unknown";
export function getAgentMentionAdmission({
isAgent,
pubkey,
mentionableAgentPubkeys,
directoryReady,
}: {
isAgent: boolean;
pubkey: string;
mentionableAgentPubkeys: ReadonlySet<string>;
directoryReady: boolean;
}): AgentMentionAdmission {
if (!isAgent) return "allow";
if (!directoryReady) return "unknown";
return mentionableAgentPubkeys.has(normalizePubkey(pubkey))
? "allow"
: "deny";
}
export function shouldHideAgentFromMentions({
isAgent,
pubkey,
mentionableAgentPubkeys,
directoryReady = true,
}: {
isAgent: boolean;
pubkey: string;
mentionableAgentPubkeys: ReadonlySet<string>;
directoryReady?: boolean;
}) {
return (
getAgentMentionAdmission({
isAgent,
pubkey,
mentionableAgentPubkeys,
directoryReady,
}) !== "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) {
return type === "stream" || type === "forum";
}
export function uniqueAutocompleteLabels(
candidates: readonly AgentAutocompleteCandidate[],
) {
const unique = new Map<string, string>();
for (const candidate of candidates) {
for (const label of [
candidate.displayName,
candidate.personaName,
candidate.secondaryLabel,
]) {
const trimmed = label?.trim();
if (trimmed && !unique.has(trimmed.toLowerCase())) {
unique.set(trimmed.toLowerCase(), trimmed);
}
}
}
return [...unique.values()];
}
export function filterCachedAgentSuggestions<
T extends {
isAgent?: boolean;
pubkey?: string;
},
>(
suggestions: readonly T[],
currentCandidates: readonly AgentAutocompleteCandidate[],
) {
const admittedAgentPubkeys = new Set(
currentCandidates.flatMap((candidate) =>
candidate.isAgent && candidate.pubkey
? [normalizePubkey(candidate.pubkey)]
: [],
),
);
return suggestions.filter(
(suggestion) =>
!suggestion.isAgent ||
!suggestion.pubkey ||
admittedAgentPubkeys.has(normalizePubkey(suggestion.pubkey)),
);
}
type AgentAutocompleteCandidate = {
pubkey?: string;
displayName?: string | null;
personaName?: string | null;
secondaryLabel?: string | null;
ownerPubkey?: string | null;
isAgent?: boolean;
isManagedAgent?: boolean;
isMember?: boolean;
personaId?: string | null;
};
function agentIdentityKey<T extends AgentAutocompleteCandidate>(candidate: T) {
if (candidate.isAgent !== true || !candidate.pubkey) {
return null;
}
// Pubkeys—not persona metadata or a display name—are agent identities.
// A persona may be installed more than once, and an owner may intentionally
// create multiple same-named agents. Collapsing either case makes one agent
// impossible to choose from autocomplete.
return `pubkey:${normalizePubkey(candidate.pubkey)}`;
}
function agentCandidateRank<T extends AgentAutocompleteCandidate>(
candidate: T,
preferredPubkeys: ReadonlySet<string>,
) {
const pubkey = candidate.pubkey ? normalizePubkey(candidate.pubkey) : null;
return [
candidate.isMember === true ? 0 : 1,
pubkey && preferredPubkeys.has(pubkey) ? 0 : 1,
candidate.isManagedAgent === true ? 0 : 1,
candidate.personaId ? 0 : 1,
];
}
function isPreferredAgentCandidate<T extends AgentAutocompleteCandidate>(
next: T,
current: T,
preferredPubkeys: ReadonlySet<string>,
) {
const nextRank = agentCandidateRank(next, preferredPubkeys);
const currentRank = agentCandidateRank(current, preferredPubkeys);
for (let index = 0; index < nextRank.length; index++) {
if (nextRank[index] !== currentRank[index]) {
return nextRank[index] < currentRank[index];
}
}
return false;
}
export function coalesceAutocompleteCandidatesByKey<T>(
candidates: readonly T[],
getKey: (candidate: T) => string | null,
) {
const output: T[] = [];
const indexesByKey = new Map<string, number>();
for (const candidate of candidates) {
const key = getKey(candidate);
if (!key) {
output.push(candidate);
continue;
}
if (!indexesByKey.has(key)) {
indexesByKey.set(key, output.length);
output.push(candidate);
}
}
return output;
}
export function coalesceAgentAutocompleteCandidates<
T extends AgentAutocompleteCandidate,
>(
candidates: readonly T[],
{
currentPubkey: _currentPubkey,
getLabel: _getLabel,
preferredPubkeys = new Set(),
}: {
currentPubkey?: string | null;
getLabel: (candidate: T) => string | null | undefined;
preferredPubkeys?: ReadonlySet<string>;
},
) {
const output: T[] = [];
const indexesByKey = new Map<string, number>();
for (const candidate of candidates) {
const key = agentIdentityKey(candidate);
if (!key) {
output.push(candidate);
continue;
}
const currentIndex = indexesByKey.get(key);
if (currentIndex === undefined) {
indexesByKey.set(key, output.length);
output.push(candidate);
continue;
}
if (
isPreferredAgentCandidate(
candidate,
output[currentIndex],
preferredPubkeys,
)
) {
output[currentIndex] = candidate;
}
}
return output;
}