diff --git a/desktop/src/features/agents/channelAgents.test.mjs b/desktop/src/features/agents/channelAgents.test.mjs new file mode 100644 index 000000000..d783d898b --- /dev/null +++ b/desktop/src/features/agents/channelAgents.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertAgentBelongsToActiveRelay, + pickPreferredChannelPresetAgent, +} from "./channelAgents.ts"; + +// Regression guard for the cross-community "silent-success trap": before this +// fix, a managed agent pinned to community A's relay could be selected and +// "attached" while operating in community B — membership was added against a +// process frozen on relay A that never hears B (Max's baseline). Both guards +// below close that path. They exercise the real selection/attach guards, not a +// re-derivation of `agentBelongsToRelay` (Mari's coverage requirement). + +const RELAY_A = "wss://relay-a.example"; +const RELAY_B = "wss://relay-b.example"; +const PUB_A = "a".repeat(64); +const PUB_B = "b".repeat(64); + +function makeAgent(overrides = {}) { + return { + pubkey: PUB_A, + name: "goose", + relayUrl: RELAY_A, + agentCommand: "goose", + status: "running", + updatedAt: "2026-01-15T00:00:00Z", + ...overrides, + }; +} + +test("pickPreferredChannelPresetAgent: excludes a foreign-relay agent (in-channel branch)", () => { + // A-pinned running agent that is already a member of the B channel: it must + // NOT be reused, so the caller falls through to creating a B agent. + const agentA = makeAgent({ pubkey: PUB_A, relayUrl: RELAY_A }); + const memberPubkeys = new Set([PUB_A]); + + const picked = pickPreferredChannelPresetAgent( + [agentA], + memberPubkeys, + "goose", + "goose", + RELAY_B, + ); + + assert.equal(picked, undefined, "foreign-relay agent must not be selected"); +}); + +test("pickPreferredChannelPresetAgent: excludes a foreign-relay agent (name-match branch)", () => { + const agentA = makeAgent({ pubkey: PUB_A, relayUrl: RELAY_A }); + + const picked = pickPreferredChannelPresetAgent( + [agentA], + new Set(), // not a member — exercises the name-match fallback branch + "goose", + "goose", + RELAY_B, + ); + + assert.equal(picked, undefined, "foreign-relay agent must not match by name"); +}); + +test("pickPreferredChannelPresetAgent: selects a same-relay agent", () => { + const agentB = makeAgent({ pubkey: PUB_B, relayUrl: RELAY_B }); + + const picked = pickPreferredChannelPresetAgent( + [agentB], + new Set([PUB_B]), + "goose", + "goose", + RELAY_B, + ); + + assert.equal(picked?.pubkey, PUB_B, "home-relay agent is reusable"); +}); + +test("pickPreferredChannelPresetAgent: blank-pin agent follows the active relay", () => { + // Defense in depth: a record that escaped stamping (blank relayUrl) follows + // the active community rather than being hidden everywhere. + const blank = makeAgent({ pubkey: PUB_B, relayUrl: "" }); + + const picked = pickPreferredChannelPresetAgent( + [blank], + new Set([PUB_B]), + "goose", + "goose", + RELAY_B, + ); + + assert.equal( + picked?.pubkey, + PUB_B, + "blank-pin agent is eligible in any community", + ); +}); + +test("assertAgentBelongsToActiveRelay: throws an actionable error for a foreign agent", () => { + const agentA = makeAgent({ name: "haiku-bot", relayUrl: RELAY_A }); + + assert.throws( + () => assertAgentBelongsToActiveRelay(agentA, RELAY_B), + (err) => { + // Actionable per Eva's refinement: names the agent, its home relay, and + // the active relay — not a bare boolean-y message. + assert.ok(err instanceof Error); + assert.match(err.message, /haiku-bot/, "names the agent"); + assert.match(err.message, /relay-a\.example/, "names the home relay"); + assert.match(err.message, /relay-b\.example/, "names the active relay"); + return true; + }, + ); +}); + +test("assertAgentBelongsToActiveRelay: does not throw for a same-relay agent", () => { + const agentB = makeAgent({ relayUrl: RELAY_B }); + assert.doesNotThrow(() => assertAgentBelongsToActiveRelay(agentB, RELAY_B)); +}); + +test("assertAgentBelongsToActiveRelay: does not throw for a blank-pin agent", () => { + const blank = makeAgent({ relayUrl: "" }); + assert.doesNotThrow(() => assertAgentBelongsToActiveRelay(blank, RELAY_B)); +}); diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index 4eb951f6d..40ef189ee 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -5,6 +5,7 @@ import { pickPreferredManagedAgent, } from "@/features/agents/agentReuse"; export { findReusableAgent } from "@/features/agents/agentReuse"; +import { agentBelongsToRelay } from "@/features/agents/agentRelayScope"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { resolveManagedAgentAvatarUrl } from "@/features/agents/ui/managedAgentAvatar"; import { @@ -104,13 +105,41 @@ export type CreateChannelManagedAgentsResult = { failures: CreateChannelManagedAgentBatchFailure[]; }; +/** + * Relay invariant guard for channel attachment. An agent serves its home + * relay; attaching a foreign-relay agent to a channel on the active relay + * would add membership against a process that cannot hear this community — + * the silent-success trap where `membershipAdded: true` reports over a deaf + * agent. Throws an actionable error naming the agent, its home relay, and + * the active relay so the caller's onError surfaces a real message. + * + * Exported so the attach path and its regression tests exercise the same + * guard rather than a re-derivation of `agentBelongsToRelay`. + */ +export function assertAgentBelongsToActiveRelay( + agent: Pick, + activeRelayUrl: string | null, +): void { + if (!agentBelongsToRelay(agent.relayUrl, activeRelayUrl)) { + throw new Error( + `Agent "${agent.name}" belongs to ${agent.relayUrl || "another community"} ` + + `and cannot be added to a channel on ${activeRelayUrl || "this community"}. ` + + `Add it from its home community, or create a new agent here.`, + ); + } +} + export async function attachManagedAgentToChannel( channelId: string, input: AttachManagedAgentToChannelInput, + activeRelayUrl: string | null, ) { const role = input.role ?? "bot"; const ensureRunning = input.ensureRunning ?? true; const agentPubkey = normalizePubkey(input.agent.pubkey); + + assertAgentBelongsToActiveRelay(input.agent, activeRelayUrl); + const membershipResult = await addChannelMembers({ channelId, pubkeys: [input.agent.pubkey], @@ -164,14 +193,23 @@ function buildChannelAgentName(runtimeId: string, runtimeLabel: string) { return runtimeLabel.trim().toLowerCase() || "agent"; } -function pickPreferredChannelPresetAgent( +export function pickPreferredChannelPresetAgent( agents: ManagedAgent[], memberPubkeys: ReadonlySet, runtimeCommand: string, expectedName: string, + activeRelayUrl: string | null, ) { + // Only agents pinned to the active community's relay are reusable here. + // Selecting a foreign-relay agent would attach a process that cannot hear + // this community; excluding it lets the caller fall through to creating a + // new agent on the active relay (correct per-community behavior). + const relayScoped = agents.filter((agent) => + agentBelongsToRelay(agent.relayUrl, activeRelayUrl), + ); + const inChannelAgent = pickPreferredManagedAgent( - agents.filter( + relayScoped.filter( (agent) => commandsMatch(agent.agentCommand, runtimeCommand) && memberPubkeys.has(normalizePubkey(agent.pubkey)), @@ -182,7 +220,7 @@ function pickPreferredChannelPresetAgent( } return pickPreferredManagedAgent( - agents.filter( + relayScoped.filter( (agent) => commandsMatch(agent.agentCommand, runtimeCommand) && agent.name.trim().toLowerCase() === expectedName.trim().toLowerCase(), @@ -193,6 +231,7 @@ function pickPreferredChannelPresetAgent( export async function ensureChannelAgentPresetInChannel( channelId: string, input: EnsureChannelAgentPresetInput, + activeRelayUrl: string | null, ): Promise { const role = input.role ?? "bot"; const ensureRunning = input.ensureRunning ?? true; @@ -210,14 +249,19 @@ export async function ensureChannelAgentPresetInChannel( memberPubkeys, input.runtime.command, expectedName, + activeRelayUrl, ); if (existingAgent) { - const attached = await attachManagedAgentToChannel(channelId, { - agent: existingAgent, - role, - ensureRunning, - }); + const attached = await attachManagedAgentToChannel( + channelId, + { + agent: existingAgent, + role, + ensureRunning, + }, + activeRelayUrl, + ); return { ...attached, created: false, @@ -233,11 +277,15 @@ export async function ensureChannelAgentPresetInChannel( mcpCommand: input.runtime.mcpCommand ?? "", spawnAfterCreate: false, }); - const attached = await attachManagedAgentToChannel(channelId, { - agent: created.agent, - role, - ensureRunning, - }); + const attached = await attachManagedAgentToChannel( + channelId, + { + agent: created.agent, + role, + ensureRunning, + }, + activeRelayUrl, + ); return { ...attached, @@ -379,17 +427,22 @@ export async function provisionChannelManagedAgent( export async function createChannelManagedAgent( channelId: string, input: CreateChannelManagedAgentInput, + activeRelayUrl: string | null, context?: { managedAgents?: ManagedAgent[]; channelMemberPubkeys?: ReadonlySet; }, ): Promise { const provisioned = await provisionChannelManagedAgent(input, context); - const attached = await attachManagedAgentToChannel(channelId, { - agent: provisioned.agent, - role: input.role ?? "bot", - ensureRunning: input.ensureRunning ?? true, - }); + const attached = await attachManagedAgentToChannel( + channelId, + { + agent: provisioned.agent, + role: input.role ?? "bot", + ensureRunning: input.ensureRunning ?? true, + }, + activeRelayUrl, + ); return { ...attached, @@ -401,6 +454,7 @@ export async function createChannelManagedAgent( export async function createChannelManagedAgents( channelId: string, inputs: readonly CreateChannelManagedAgentInput[], + activeRelayUrl: string | null, ): Promise { // Fetch managed agents and channel members once for smart reuse checks. const [managedAgents, members] = await Promise.all([ @@ -421,7 +475,12 @@ export async function createChannelManagedAgents( for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; try { - const result = await createChannelManagedAgent(channelId, input, context); + const result = await createChannelManagedAgent( + channelId, + input, + activeRelayUrl, + context, + ); successes.push(result); } catch (error) { failures.push({ diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 9ceeb0220..6bf7a102c 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -581,6 +581,7 @@ export function useAttachManagedAgentToChannelMutation( channelId: string | null, ) { const queryClient = useQueryClient(); + const activeRelayUrl = useActiveRelayUrl(); return useMutation({ mutationFn: async ( @@ -592,7 +593,11 @@ export function useAttachManagedAgentToChannelMutation( throw new Error("No channel selected."); } - return attachManagedAgentToChannel(effectiveChannelId, rest); + return attachManagedAgentToChannel( + effectiveChannelId, + rest, + activeRelayUrl, + ); }, onSuccess: (result, variables) => { const effectiveChannelId = variables.channelId ?? channelId; @@ -627,6 +632,7 @@ export function useAttachManagedAgentToChannelMutation( export function useEnsureChannelAgentPresetMutation(channelId: string | null) { const queryClient = useQueryClient(); + const activeRelayUrl = useActiveRelayUrl(); return useMutation({ mutationFn: async ( @@ -636,7 +642,11 @@ export function useEnsureChannelAgentPresetMutation(channelId: string | null) { throw new Error("No channel selected."); } - return ensureChannelAgentPresetInChannel(channelId, input); + return ensureChannelAgentPresetInChannel( + channelId, + input, + activeRelayUrl, + ); }, onSettled: () => { invalidateAgentQueriesInBackground(queryClient, channelId); @@ -646,6 +656,7 @@ export function useEnsureChannelAgentPresetMutation(channelId: string | null) { export function useCreateChannelManagedAgentMutation(channelId: string | null) { const queryClient = useQueryClient(); + const activeRelayUrl = useActiveRelayUrl(); return useMutation({ mutationFn: async ( @@ -657,9 +668,11 @@ export function useCreateChannelManagedAgentMutation(channelId: string | null) { throw new Error("No channel selected."); } - const result = await createChannelManagedAgents(effectiveChannelId, [ - rest, - ]); + const result = await createChannelManagedAgents( + effectiveChannelId, + [rest], + activeRelayUrl, + ); const success = result.successes[0]; if (success) { return success; @@ -745,6 +758,7 @@ export function useCreateChannelManagedAgentsMutation( channelId: string | null, ) { const queryClient = useQueryClient(); + const activeRelayUrl = useActiveRelayUrl(); return useMutation({ mutationFn: async ( @@ -754,7 +768,7 @@ export function useCreateChannelManagedAgentsMutation( throw new Error("No channel selected."); } - return createChannelManagedAgents(channelId, inputs); + return createChannelManagedAgents(channelId, inputs, activeRelayUrl); }, onSettled: () => { invalidateAgentQueriesInBackground(queryClient, channelId); diff --git a/desktop/src/features/agents/useCreatedAgentChannelAttachment.ts b/desktop/src/features/agents/useCreatedAgentChannelAttachment.ts index e016766c2..5b7fe7cc5 100644 --- a/desktop/src/features/agents/useCreatedAgentChannelAttachment.ts +++ b/desktop/src/features/agents/useCreatedAgentChannelAttachment.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { attachManagedAgentToChannel } from "./channelAgents"; import type { AgentChannelAttachmentFailure } from "./channelAttachmentFailure"; +import { useActiveRelayUrl } from "@/features/communities/useCommunities"; import type { Channel, CreateManagedAgentResponse } from "@/shared/api/types"; type TargetChannel = Pick; @@ -17,6 +18,7 @@ export function useCreatedAgentChannelAttachment() { React.useState(null); const targetChannelRef = React.useRef(null); const [isRetryingAttachment, setIsRetryingAttachment] = React.useState(false); + const activeRelayUrl = useActiveRelayUrl(); async function attach( created: CreateManagedAgentResponse, @@ -24,11 +26,15 @@ export function useCreatedAgentChannelAttachment() { ) { targetChannelRef.current = targetChannel; try { - const attached = await attachManagedAgentToChannel(targetChannel.id, { - agent: created.agent, - role: "bot", - ensureRunning: true, - }); + const attached = await attachManagedAgentToChannel( + targetChannel.id, + { + agent: created.agent, + role: "bot", + ensureRunning: true, + }, + activeRelayUrl, + ); created.agent = attached.agent; targetChannelRef.current = null; setAttachmentFailure(null); diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index 1f7d066fa..4babfe302 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -12,6 +12,7 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; +import { useActiveRelayUrl } from "@/features/communities/useCommunities"; import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; import { setCanvas } from "@/shared/api/tauri"; import type { ChannelTemplate } from "@/shared/api/types"; @@ -33,6 +34,7 @@ export function useApplyTemplate() { const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); const { lastRuntimeId } = useLastRuntime(); + const activeRelayUrl = useActiveRelayUrl(); async function applyCanvas( templateId: string | undefined, @@ -132,7 +134,11 @@ export function useApplyTemplate() { if (inputs.length === 0) return; try { - const result = await createChannelManagedAgents(channelId, inputs); + const result = await createChannelManagedAgents( + channelId, + inputs, + activeRelayUrl, + ); if (result.failures.length > 0) { const { toast } = await import("sonner"); toast.warning(