mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
6d5c1ea85e
commit
b49523e7f7
@@ -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));
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user