Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions desktop/src/features/agents/channelAgents.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import test from "node:test";

import { provisionChannelManagedAgent } from "./channelAgents.ts";

const PUBKEY = "a".repeat(64);
const RUNTIME = {
id: "goose",
label: "Goose",
command: "goose",
defaultArgs: [],
mcpCommand: "buzz-dev-mcp",
};

function makeAgent(personaId, overrides = {}) {
return {
pubkey: PUBKEY,
name: "reviewer",
personaId,
runtime: null,
teamId: null,
relayUrl: "wss://relay.example",
acpCommand: "buzz-acp",
agentCommand: "goose",
agentCommandOverride: null,
agentArgs: [],
mcpCommand: "buzz-dev-mcp",
turnTimeoutSeconds: 320,
idleTimeoutSeconds: null,
maxTurnDurationSeconds: null,
parallelism: 1,
systemPrompt: null,
avatarUrl: null,
model: null,
modelSource: null,
provider: null,
personaOutOfDate: false,
personaOrphaned: false,
needsRestart: false,
restartDiff: [],
envVars: {},
status: "running",
pid: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
lastStartedAt: null,
lastStoppedAt: null,
lastExitCode: null,
lastError: null,
lastErrorCode: null,
logPath: "",
startOnAppLaunch: false,
autoRestartOnConfigChange: true,
backend: { type: "local" },
backendAgentId: null,
respondTo: "anyone",
respondToAllowlist: [PUBKEY],
...overrides,
};
}

function toRawAgent(agent) {
return {
pubkey: agent.pubkey,
name: agent.name,
persona_id: agent.personaId,
runtime: agent.runtime,
team_id: agent.teamId,
relay_url: agent.relayUrl,
acp_command: agent.acpCommand,
agent_command: agent.agentCommand,
agent_command_override: agent.agentCommandOverride,
agent_args: agent.agentArgs,
mcp_command: agent.mcpCommand,
turn_timeout_seconds: agent.turnTimeoutSeconds,
idle_timeout_seconds: agent.idleTimeoutSeconds,
max_turn_duration_seconds: agent.maxTurnDurationSeconds,
parallelism: agent.parallelism,
system_prompt: agent.systemPrompt,
avatar_url: agent.avatarUrl,
model: agent.model,
model_source: agent.modelSource,
provider: agent.provider,
persona_out_of_date: agent.personaOutOfDate,
persona_orphaned: agent.personaOrphaned,
needs_restart: agent.needsRestart,
restart_diff: agent.restartDiff,
env_vars: agent.envVars,
status: agent.status,
pid: agent.pid,
created_at: agent.createdAt,
updated_at: agent.updatedAt,
last_started_at: agent.lastStartedAt,
last_stopped_at: agent.lastStoppedAt,
last_exit_code: agent.lastExitCode,
last_error: agent.lastError,
last_error_code: agent.lastErrorCode,
log_path: agent.logPath,
start_on_app_launch: agent.startOnAppLaunch,
auto_restart_on_config_change: agent.autoRestartOnConfigChange,
backend: agent.backend,
backend_agent_id: agent.backendAgentId,
respond_to: agent.respondTo,
respond_to_allowlist: agent.respondToAllowlist,
};
}

test("reuse applies the owner-only default and clears stale allowlists", async (t) => {
const priorWindow = globalThis.window;
t.after(() => {
globalThis.window = priorWindow;
});

for (const personaId of ["persona-1", null]) {
const existing = makeAgent(personaId);
const calls = [];
globalThis.window ??= {};
window.__TAURI_INTERNALS__ = {
invoke(command, args) {
calls.push([command, args]);
assert.equal(command, "update_managed_agent");
return Promise.resolve({
agent: toRawAgent({
...existing,
respondTo: args.input.respondTo,
respondToAllowlist: args.input.respondToAllowlist,
}),
profile_sync_error: null,
});
},
};

const result = await provisionChannelManagedAgent(
{
runtime: RUNTIME,
name: "reviewer",
personaId,
},
{
managedAgents: [existing],
channelMemberPubkeys: new Set(),
},
);

assert.equal(calls.length, 1);
assert.equal(calls[0][1].input.respondTo, "owner-only");
assert.deepEqual(calls[0][1].input.respondToAllowlist, []);
assert.equal(result.agent.respondTo, "owner-only");
assert.deepEqual(result.agent.respondToAllowlist, []);
}
});

test("reuse skips updates when the owner-only default already matches", async (t) => {
const priorWindow = globalThis.window;
t.after(() => {
globalThis.window = priorWindow;
});

globalThis.window ??= {};
window.__TAURI_INTERNALS__ = {
invoke(command) {
assert.fail(`unexpected ${command} call`);
},
};

for (const personaId of ["persona-1", null]) {
const existing = makeAgent(personaId, {
respondTo: "owner-only",
respondToAllowlist: [],
});
const result = await provisionChannelManagedAgent(
{
runtime: RUNTIME,
name: "reviewer",
personaId,
},
{
managedAgents: [existing],
channelMemberPubkeys: new Set(),
},
);

assert.equal(result.agent, existing);
}
});
66 changes: 36 additions & 30 deletions desktop/src/features/agents/channelAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,34 @@ export async function ensureChannelAgentPresetInChannel(
};
}

async function applyInboundAuthorGateForReuse(
agent: ManagedAgent,
input: CreateChannelManagedAgentInput,
) {
// Creation defaults an omitted mode to owner-only. Reuse must apply the
// same default instead of inheriting the agent's previous access policy.
const respondTo = input.respondTo ?? "owner-only";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMPORTANT — Correctness/Security: This fallback is not reached by the production path that prompted the fix. An existing managed-agent autocomplete result is handled by ensureManagedAgentMentionsReady() in desktop/src/features/messages/ui/useMentionSendFlow.ts:128-171, which directly attaches/starts the identity without calling provisionChannelManagedAgent(). In an exact-head composer run, an out-of-channel persona-linked agent seeded as anyone plus a stale allowlist emitted no update_managed_agent and remained broad after attach/start/send. The new tests call this helper seam, so they do not catch that bypass. Please enforce the intended gate in the already-managed identity attach path and add a production-caller regression. Also resolve omitted policy using the same definition-aware semantics as fresh minting: this unconditional owner-only fallback diverges for linked personas whose stored default is anyone or allowlist.

const respondToAllowlist =
respondTo === "allowlist" ? (input.respondToAllowlist ?? []) : [];
const allowlistMatches =
agent.respondToAllowlist.length === respondToAllowlist.length &&
agent.respondToAllowlist.every(
(pubkey, index) => pubkey === respondToAllowlist[index],
);

if (agent.respondTo === respondTo && allowlistMatches) {
return agent;
}

return (
await updateManagedAgent({
pubkey: agent.pubkey,
respondTo,
respondToAllowlist,
})
).agent;
}

export async function provisionChannelManagedAgent(
input: CreateChannelManagedAgentInput,
context?: {
Expand Down Expand Up @@ -279,22 +307,10 @@ 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 updatedAgent = await applyInboundAuthorGateForReuse(
reusable,
input,
);

return {
agent: updatedAgent,
Expand All @@ -319,20 +335,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 applyInboundAuthorGateForReuse(
reusable,
input,
);

return {
agent: updatedAgent,
Expand Down
Loading