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:
Jordan Mecom
2026-08-26 13:22:09 -07:00
committed by GitHub
parent 4f554b6385
commit 01c87a350b
7 changed files with 214 additions and 67 deletions

View File

@@ -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: [] },
);
});

View File

@@ -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],
};
}

View File

@@ -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

View File

@@ -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)),
),

View File

@@ -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,
],

View File

@@ -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 &

View File

@@ -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 ({