mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
Apply access policy when reusing channel agents (#6838)
Reused managed identities now apply their intended inbound-author policy before attachment or startup. An explicit policy wins, then the linked persona policy, then `owner-only`. This covers both channel-agent provisioning and the direct composer autocomplete path. If the policy update fails, attachment, startup, and message send stop. The production Playwright regression starts with a persona-linked agent set to `anyone` with a stale allowlist, then verifies the update happens before membership and startup and that the stored policy is `owner-only`. A small resolver test covers explicit, persona, and default precedence. This closes the stale-policy entry described by [Project Loupe issue #497](https://github.com/project-loupe/audit-buzz/issues/497). The separate Welcome Team reuse path is outside this PR. Checked with the 5,502-test desktop suite, `pnpm check`, `pnpm typecheck`, `pnpm check:file-sizes`, and the focused Playwright composer regression. --------- Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
findReusablePersonaAgent,
|
||||
findReusableGenericAgent,
|
||||
findReusableAgent,
|
||||
resolveReusableAgentAccessPolicy,
|
||||
} from "./agentReuse.ts";
|
||||
|
||||
const PUB_A = "a".repeat(64);
|
||||
@@ -372,3 +373,26 @@ test("findReusableAgent: null personaId in input routes to generic", () => {
|
||||
});
|
||||
assert.equal(result, agent);
|
||||
});
|
||||
|
||||
test("resolveReusableAgentAccessPolicy uses explicit, persona, then safe defaults", () => {
|
||||
const persona = {
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [PUB_B],
|
||||
};
|
||||
|
||||
assert.deepEqual(resolveReusableAgentAccessPolicy({}, persona), {
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: [PUB_B],
|
||||
});
|
||||
assert.deepEqual(resolveReusableAgentAccessPolicy({}), {
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveReusableAgentAccessPolicy(
|
||||
{ respondTo: "owner-only", respondToAllowlist: [] },
|
||||
persona,
|
||||
),
|
||||
{ respondTo: "owner-only", respondToAllowlist: [] },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import type {
|
||||
AgentPersona,
|
||||
CreateManagedAgentInput,
|
||||
ManagedAgent,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
/** Inline normalization — avoids runtime dependency on @/shared/lib/pubkey. */
|
||||
function normalizePubkey(pubkey: string): string {
|
||||
@@ -103,3 +107,30 @@ export function findReusableAgent(
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveReusableAgentAccessPolicy(
|
||||
request: Pick<CreateManagedAgentInput, "respondTo" | "respondToAllowlist">,
|
||||
persona?: Pick<AgentPersona, "respondTo" | "respondToAllowlist">,
|
||||
) {
|
||||
const requestedAllowlist = request.respondToAllowlist ?? [];
|
||||
if (request.respondTo !== undefined) {
|
||||
return {
|
||||
respondTo: request.respondTo,
|
||||
respondToAllowlist: [...requestedAllowlist],
|
||||
};
|
||||
}
|
||||
if (persona?.respondTo != null) {
|
||||
return {
|
||||
respondTo: persona.respondTo,
|
||||
respondToAllowlist: [
|
||||
...(requestedAllowlist.length > 0
|
||||
? requestedAllowlist
|
||||
: persona.respondToAllowlist),
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
respondTo: "owner-only" as const,
|
||||
respondToAllowlist: [...requestedAllowlist],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
findReusableGenericAgent,
|
||||
findReusablePersonaAgent,
|
||||
pickPreferredManagedAgent,
|
||||
resolveReusableAgentAccessPolicy,
|
||||
} from "@/features/agents/agentReuse";
|
||||
export { findReusableAgent } from "@/features/agents/agentReuse";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
@@ -14,10 +15,13 @@ import {
|
||||
listManagedAgents,
|
||||
updateManagedAgent,
|
||||
} from "@/shared/api/tauri";
|
||||
import { listPersonas } from "@/shared/api/tauriPersonas";
|
||||
import { startManagedAgent } from "@/shared/api/tauriManagedAgents";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
AgentPersona,
|
||||
ChannelRole,
|
||||
CreateManagedAgentInput,
|
||||
ManagedAgent,
|
||||
ManagedAgentBackend,
|
||||
RespondToMode,
|
||||
@@ -72,7 +76,10 @@ export type CreateChannelManagedAgentInput = {
|
||||
role?: Exclude<ChannelRole, "owner">;
|
||||
ensureRunning?: boolean;
|
||||
backend?: ManagedAgentBackend;
|
||||
/** Inbound author gate mode. Omitted = server default ("owner-only"). */
|
||||
/**
|
||||
* Inbound author gate mode. Omitted = linked persona default, then
|
||||
* `"owner-only"` when the persona leaves it unset or no persona is linked.
|
||||
*/
|
||||
respondTo?: RespondToMode;
|
||||
/** Hex pubkeys for allowlist mode. */
|
||||
respondToAllowlist?: string[];
|
||||
@@ -104,6 +111,37 @@ export type CreateChannelManagedAgentsResult = {
|
||||
failures: CreateChannelManagedAgentBatchFailure[];
|
||||
};
|
||||
|
||||
type ChannelAgentReuseContext = {
|
||||
managedAgents: ManagedAgent[];
|
||||
channelMemberPubkeys: ReadonlySet<string>;
|
||||
personas: readonly Pick<
|
||||
AgentPersona,
|
||||
"id" | "respondTo" | "respondToAllowlist"
|
||||
>[];
|
||||
};
|
||||
|
||||
export async function applyReusableAgentAccessPolicy(
|
||||
agent: ManagedAgent,
|
||||
request: Pick<CreateManagedAgentInput, "respondTo" | "respondToAllowlist">,
|
||||
persona?: Pick<AgentPersona, "respondTo" | "respondToAllowlist">,
|
||||
) {
|
||||
const policy = resolveReusableAgentAccessPolicy(request, persona);
|
||||
const matches =
|
||||
agent.respondTo === policy.respondTo &&
|
||||
agent.respondToAllowlist.length === policy.respondToAllowlist.length &&
|
||||
agent.respondToAllowlist.every(
|
||||
(pubkey, index) => pubkey === policy.respondToAllowlist[index],
|
||||
);
|
||||
if (matches) return agent;
|
||||
|
||||
return (
|
||||
await updateManagedAgent({
|
||||
pubkey: agent.pubkey,
|
||||
...policy,
|
||||
})
|
||||
).agent;
|
||||
}
|
||||
|
||||
export async function attachManagedAgentToChannel(
|
||||
channelId: string,
|
||||
input: AttachManagedAgentToChannelInput,
|
||||
@@ -254,10 +292,7 @@ export async function ensureChannelAgentPresetInChannel(
|
||||
|
||||
export async function provisionChannelManagedAgent(
|
||||
input: CreateChannelManagedAgentInput,
|
||||
context?: {
|
||||
managedAgents?: ManagedAgent[];
|
||||
channelMemberPubkeys?: ReadonlySet<string>;
|
||||
},
|
||||
context?: ChannelAgentReuseContext,
|
||||
): Promise<ProvisionChannelManagedAgentResult> {
|
||||
const trimmedName = input.name.trim();
|
||||
|
||||
@@ -279,22 +314,14 @@ export async function provisionChannelManagedAgent(
|
||||
context.channelMemberPubkeys,
|
||||
);
|
||||
if (reusable) {
|
||||
// Apply the caller's respondTo settings so the user's permission
|
||||
// choice in the dialog is always honored, even when reusing.
|
||||
const needsRespondToUpdate =
|
||||
input.respondTo && input.respondTo !== "owner-only";
|
||||
const updatedAgent = needsRespondToUpdate
|
||||
? (
|
||||
await updateManagedAgent({
|
||||
pubkey: reusable.pubkey,
|
||||
respondTo: input.respondTo,
|
||||
respondToAllowlist:
|
||||
input.respondTo === "allowlist"
|
||||
? input.respondToAllowlist
|
||||
: undefined,
|
||||
})
|
||||
).agent
|
||||
: reusable;
|
||||
const definition = context.personas.find(
|
||||
(persona) => persona.id === input.personaId,
|
||||
);
|
||||
const updatedAgent = await applyReusableAgentAccessPolicy(
|
||||
reusable,
|
||||
input,
|
||||
definition,
|
||||
);
|
||||
|
||||
return {
|
||||
agent: updatedAgent,
|
||||
@@ -319,20 +346,10 @@ export async function provisionChannelManagedAgent(
|
||||
context.channelMemberPubkeys,
|
||||
);
|
||||
if (reusable) {
|
||||
const needsRespondToUpdate =
|
||||
input.respondTo && input.respondTo !== "owner-only";
|
||||
const updatedAgent = needsRespondToUpdate
|
||||
? (
|
||||
await updateManagedAgent({
|
||||
pubkey: reusable.pubkey,
|
||||
respondTo: input.respondTo,
|
||||
respondToAllowlist:
|
||||
input.respondTo === "allowlist"
|
||||
? input.respondToAllowlist
|
||||
: undefined,
|
||||
})
|
||||
).agent
|
||||
: reusable;
|
||||
const updatedAgent = await applyReusableAgentAccessPolicy(
|
||||
reusable,
|
||||
input,
|
||||
);
|
||||
|
||||
return {
|
||||
agent: updatedAgent,
|
||||
@@ -387,10 +404,7 @@ export async function provisionChannelManagedAgent(
|
||||
export async function createChannelManagedAgent(
|
||||
channelId: string,
|
||||
input: CreateChannelManagedAgentInput,
|
||||
context?: {
|
||||
managedAgents?: ManagedAgent[];
|
||||
channelMemberPubkeys?: ReadonlySet<string>;
|
||||
},
|
||||
context?: ChannelAgentReuseContext,
|
||||
): Promise<CreateChannelManagedAgentResult> {
|
||||
const provisioned = await provisionChannelManagedAgent(input, context);
|
||||
const attached = await attachManagedAgentToChannel(channelId, {
|
||||
@@ -411,14 +425,21 @@ export async function createChannelManagedAgents(
|
||||
inputs: readonly CreateChannelManagedAgentInput[],
|
||||
): Promise<CreateChannelManagedAgentsResult> {
|
||||
// Fetch managed agents and channel members once for smart reuse checks.
|
||||
const [managedAgents, members] = await Promise.all([
|
||||
const needsPersonaPolicy = inputs.some(
|
||||
(input) =>
|
||||
Boolean(input.personaId) &&
|
||||
!input.forceNewInstance &&
|
||||
input.respondTo === undefined,
|
||||
);
|
||||
const [managedAgents, members, personas] = await Promise.all([
|
||||
listManagedAgents(),
|
||||
getChannelMembers(channelId),
|
||||
needsPersonaPolicy ? listPersonas() : Promise.resolve([]),
|
||||
]);
|
||||
const channelMemberPubkeys = new Set(
|
||||
members.map((m) => normalizePubkey(m.pubkey)),
|
||||
);
|
||||
const context = { managedAgents, channelMemberPubkeys };
|
||||
const context = { managedAgents, channelMemberPubkeys, personas };
|
||||
|
||||
// Sequential loop: each agent must be fully created and its relay membership
|
||||
// written before the next starts. Concurrent writes to the replaceable
|
||||
|
||||
@@ -804,12 +804,16 @@ export function useProvisionChannelManagedAgentMutation(
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
const [managedAgents, members] = await Promise.all([
|
||||
const [managedAgents, members, personas] = await Promise.all([
|
||||
listManagedAgents(),
|
||||
getChannelMembers(effectiveChannelId),
|
||||
rest.personaId && rest.respondTo === undefined
|
||||
? listPersonas()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return provisionChannelManagedAgent(rest, {
|
||||
managedAgents,
|
||||
personas,
|
||||
channelMemberPubkeys: new Set(
|
||||
members.map((member) => normalizePubkey(member.pubkey)),
|
||||
),
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
useAvailableAcpRuntimes,
|
||||
useCreateChannelManagedAgentMutation,
|
||||
useManagedAgentsQuery,
|
||||
usePersonasQuery,
|
||||
useProvisionChannelManagedAgentMutation,
|
||||
useStartManagedAgentMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents";
|
||||
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
|
||||
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
|
||||
import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers";
|
||||
@@ -95,6 +97,7 @@ export function useMentionSendFlow({
|
||||
useProvisionChannelManagedAgentMutation(channelId);
|
||||
const availableRuntimesQuery = useAvailableAcpRuntimes();
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const startAgentMutation = useStartManagedAgentMutation();
|
||||
const getManagedAgentsByPubkey = React.useCallback(async () => {
|
||||
const agents =
|
||||
@@ -105,6 +108,9 @@ export function useMentionSendFlow({
|
||||
agents.map((agent) => [normalizePubkey(agent.pubkey), agent]),
|
||||
);
|
||||
}, [managedAgentsQuery.data, managedAgentsQuery.refetch]);
|
||||
const getPersonas = React.useCallback(async () => {
|
||||
return personasQuery.data ?? (await personasQuery.refetch()).data ?? [];
|
||||
}, [personasQuery.data, personasQuery.refetch]);
|
||||
const getAvailableRuntimes = React.useCallback(async (): Promise<
|
||||
AcpRuntime[]
|
||||
> => {
|
||||
@@ -137,55 +143,62 @@ export function useMentionSendFlow({
|
||||
pubkeys: [] as string[],
|
||||
};
|
||||
}
|
||||
const managedAgentsByPubkey = await getManagedAgentsByPubkey();
|
||||
const [managedAgentsByPubkey, personas] = await Promise.all([
|
||||
getManagedAgentsByPubkey(),
|
||||
getPersonas(),
|
||||
]);
|
||||
for (const agent of preparedManagedAgents) {
|
||||
managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent);
|
||||
}
|
||||
const participantPubkeys = new Set([
|
||||
...mentions.memberPubkeys,
|
||||
const existingMembers = new Set(
|
||||
[...mentions.memberPubkeys].map(normalizePubkey),
|
||||
);
|
||||
const participants = new Set([
|
||||
...existingMembers,
|
||||
...preparedParticipantPubkeys.map(normalizePubkey),
|
||||
]);
|
||||
const errors: string[] = [];
|
||||
const pubkeys: string[] = [];
|
||||
for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) {
|
||||
const agent = managedAgentsByPubkey.get(pubkey);
|
||||
if (!agent) {
|
||||
continue;
|
||||
}
|
||||
if (!agent) continue;
|
||||
try {
|
||||
if (participantPubkeys.has(pubkey)) {
|
||||
if (isProviderBackedAgent(agent)) {
|
||||
if (agent.status !== "deployed") {
|
||||
await startAgentMutation.mutateAsync(agent.pubkey);
|
||||
}
|
||||
} else if (!isManagedAgentRunning(agent)) {
|
||||
await startAgentMutation.mutateAsync(agent.pubkey);
|
||||
const readyAgent = existingMembers.has(pubkey)
|
||||
? agent
|
||||
: await applyReusableAgentAccessPolicy(
|
||||
agent,
|
||||
{},
|
||||
personas.find((persona) => persona.id === agent.personaId),
|
||||
);
|
||||
if (participants.has(pubkey)) {
|
||||
if (
|
||||
(isProviderBackedAgent(readyAgent) &&
|
||||
readyAgent.status !== "deployed") ||
|
||||
(!isProviderBackedAgent(readyAgent) &&
|
||||
!isManagedAgentRunning(readyAgent))
|
||||
) {
|
||||
await startAgentMutation.mutateAsync(readyAgent.pubkey);
|
||||
}
|
||||
} else {
|
||||
await attachAgentMutation.mutateAsync({
|
||||
channelId: capturedChannelId,
|
||||
agent,
|
||||
agent: readyAgent,
|
||||
role: "bot",
|
||||
});
|
||||
}
|
||||
pubkeys.push(pubkey);
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`${agent.name}: ${getErrorMessage(
|
||||
error,
|
||||
"Could not prepare agent.",
|
||||
)}`,
|
||||
`${agent.name}: ${getErrorMessage(error, "Could not prepare agent.")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
errors,
|
||||
pubkeys: uniqueNormalizedPubkeys(pubkeys),
|
||||
};
|
||||
return { errors, pubkeys: uniqueNormalizedPubkeys(pubkeys) };
|
||||
},
|
||||
[
|
||||
attachAgentMutation,
|
||||
getManagedAgentsByPubkey,
|
||||
getPersonas,
|
||||
mentions.memberPubkeys,
|
||||
startAgentMutation,
|
||||
],
|
||||
|
||||
@@ -422,7 +422,7 @@ export type CreateManagedAgentInput = {
|
||||
spawnAfterCreate?: boolean;
|
||||
startOnAppLaunch?: boolean;
|
||||
backend?: ManagedAgentBackend;
|
||||
/** Inbound author gate mode. Omitted = `"owner-only"` (server default). */
|
||||
/** Omitted uses the linked persona default, then `"owner-only"`. */
|
||||
respondTo?: RespondToMode;
|
||||
/**
|
||||
* Hex pubkeys to allow when `respondTo === "allowlist"`. Validated &
|
||||
|
||||
@@ -1154,7 +1154,6 @@ test("selecting a persona mention creates a channel agent before sending", async
|
||||
baselineCommands,
|
||||
"start_managed_agent",
|
||||
);
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||
|
||||
@@ -2161,11 +2160,21 @@ test("mentioning a non-member managed agent adds and starts it before sending",
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
personas: [
|
||||
{
|
||||
id: "persona-owner",
|
||||
displayName: "Fizz",
|
||||
systemPrompt: "",
|
||||
},
|
||||
],
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY,
|
||||
name: "fizz",
|
||||
personaId: "persona-owner",
|
||||
status: "stopped",
|
||||
respondTo: "anyone",
|
||||
respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2191,6 +2200,7 @@ test("mentioning a non-member managed agent adds and starts it before sending",
|
||||
baselineCommands,
|
||||
"start_managed_agent",
|
||||
);
|
||||
const baselinePayloadCount = (await readCommandPayloadLog(page)).length;
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||
@@ -2206,11 +2216,55 @@ test("mentioning a non-member managed agent adds and starts it before sending",
|
||||
)
|
||||
.toBeGreaterThan(baselineStartCount);
|
||||
|
||||
const sendCommands = (await readCommandPayloadLog(page)).slice(
|
||||
baselinePayloadCount,
|
||||
);
|
||||
const updateIndex = sendCommands.findIndex(
|
||||
(entry) => entry.command === "update_managed_agent",
|
||||
);
|
||||
const addIndex = sendCommands.findIndex(
|
||||
(entry) => entry.command === "add_channel_members",
|
||||
);
|
||||
const startIndex = sendCommands.findIndex(
|
||||
(entry) => entry.command === "start_managed_agent",
|
||||
);
|
||||
expect(updateIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(updateIndex).toBeLessThan(addIndex);
|
||||
expect(updateIndex).toBeLessThan(startIndex);
|
||||
expect(sendCommands[updateIndex]?.payload).toMatchObject({
|
||||
input: {
|
||||
pubkey: OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
},
|
||||
});
|
||||
|
||||
const mentionChip = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator("[data-mention].agent-mention-highlight", { hasText: "fizz" });
|
||||
await expect(mentionChip).toBeVisible();
|
||||
|
||||
const persistedPolicy = await page.evaluate(async (pubkey) => {
|
||||
const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("Mock bridge is not installed.");
|
||||
const agents = (await invoke("list_managed_agents", {})) as Array<{
|
||||
pubkey: string;
|
||||
respond_to: string;
|
||||
respond_to_allowlist: string[];
|
||||
}>;
|
||||
const agent = agents.find((candidate) => candidate.pubkey === pubkey);
|
||||
return agent
|
||||
? {
|
||||
respondTo: agent.respond_to,
|
||||
respondToAllowlist: agent.respond_to_allowlist,
|
||||
}
|
||||
: null;
|
||||
}, OUT_OF_CHANNEL_MANAGED_AGENT_PUBKEY);
|
||||
expect(persistedPolicy).toEqual({
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("mentioning a non-member provider managed agent deploys it before sending", async ({
|
||||
|
||||
Reference in New Issue
Block a user