fix(desktop): relay-scope the channel agent attach path

Close the cross-community silent-success trap: a managed agent pinned to
community A's relay could be selected and "attached" to a channel while
Desktop was viewing community B — membership was added against a process
frozen on relay A that never hears B (visible-but-deaf, with
`membershipAdded: true` reported over a deaf agent).

Two guards on the attach path, both using the existing
`agentBelongsToRelay` relay-scope helper:

1. `pickPreferredChannelPresetAgent` now filters reuse candidates to the
   active community's relay before selection, so a foreign-relay agent is
   never reused; `ensureChannelAgentPresetInChannel` falls through to
   creating a new agent on the active relay (correct per-community
   behavior for scope (a)).

2. `attachManagedAgentToChannel` gains a REQUIRED `activeRelayUrl` and
   asserts the agent belongs to it via the exported
   `assertAgentBelongsToActiveRelay` guard, throwing an actionable error
   (names the agent, its home relay, and the active relay) rather than
   reporting false success. Required — not optional — so the compiler
   finds every caller and no future direct caller can silently bypass it.

The relay is sourced inside the mutation/attachment hooks via
`useActiveRelayUrl()` and threaded down, so UI callers are unchanged. All
five call sites (attach/ensure/create mutations, template apply, and the
created-agent attachment hook) surface the thrown message rather than
swallow it.

Direct unit tests exercise the real selection and attach guards (not a
re-derivation of `agentBelongsToRelay`): a B-pinned running agent is not
selected in A (both member and name-match branches) and throws on direct
attach, while same-relay and blank-pin agents remain eligible.

Stacked on wren/agents-every-community-successor.

Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-07-17 21:28:24 -04:00
parent 6d5c1ea85e
commit b49523e7f7
5 changed files with 239 additions and 31 deletions
@@ -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));
});
+78 -19
View File
@@ -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<ManagedAgent, "name" | "relayUrl">,
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<string>,
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<EnsureChannelAgentPresetResult> {
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<string>;
},
): Promise<CreateChannelManagedAgentResult> {
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<CreateChannelManagedAgentsResult> {
// 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({
+20 -6
View File
@@ -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);
@@ -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<Channel, "id" | "name">;
@@ -17,6 +18,7 @@ export function useCreatedAgentChannelAttachment() {
React.useState<AgentChannelAttachmentFailure | null>(null);
const targetChannelRef = React.useRef<TargetChannel | null>(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);
@@ -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(