Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
16 changes: 7 additions & 9 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ use crate::{
load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy,
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
stop_managed_agent_process, stop_managed_agent_workspace_pair,
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord,
ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
sync_managed_agent_processes, try_regenerate_nest, validate_managed_agent_team_deletion,
validate_provider_config, BackendKind, CreateManagedAgentRequest,
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
Expand Down Expand Up @@ -1304,12 +1304,10 @@ pub async fn delete_managed_agent(
state.clear_agent_session_caches(pubkey);
}

// Guard: reject deletion of deployed remote agents unless explicitly forced.
// This turns "don't orphan remote infra" from a UI convention into a backend
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
// remote deployment. The frontend sends force_remote_delete: true only after
// the user confirms the orphan warning.
// Backend guards prevent a stale or compromised IPC caller from
// deleting current team members or silently orphaning remote infra.
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
validate_managed_agent_team_deletion(record, &load_teams(&app)?)?;
if record.backend != BackendKind::Local
&& record.backend_agent_id.is_some()
&& !force_remote_delete.unwrap_or(false)
Expand Down
38 changes: 38 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,44 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen
}
}

fn team_record(id: &str, persona_ids: &[&str]) -> crate::managed_agents::TeamRecord {
crate::managed_agents::TeamRecord {
id: id.to_string(),
name: "Test Team".to_string(),
description: None,
instructions: None,
persona_ids: persona_ids.iter().map(|id| (*id).to_string()).collect(),
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: "2026-08-04T00:00:00Z".to_string(),
updated_at: "2026-08-04T00:00:00Z".to_string(),
}
}

#[test]
fn managed_agent_deletion_rejects_current_team_members() {
let record = bare_agent_record(Some("builtin:fizz"), None, None);
let teams = vec![team_record("team-1", &["builtin:fizz"])];

let error = validate_managed_agent_team_deletion(&record, &teams).unwrap_err();

assert_eq!(
error,
"Cannot remove Agent: this agent belongs to a team. Remove it from every team first."
);
}

#[test]
fn managed_agent_deletion_allows_agents_removed_from_teams() {
let record = bare_agent_record(Some("builtin:fizz"), None, None);
let teams = vec![team_record("team-1", &["builtin:honey"])];

assert!(validate_managed_agent_team_deletion(&record, &teams).is_ok());
}

/// Auto-archive uses the same NIP-IA wire builder as the explicit GUI action,
/// attaches owner consent, and marks a deliberate delete as `retired`.
#[test]
Expand Down
41 changes: 31 additions & 10 deletions desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::fs;

use tauri::AppHandle;

use crate::{managed_agents::AgentDefinition, util::now_iso};
use crate::{
managed_agents::{AgentDefinition, ManagedAgentRecord, TeamRecord},
util::now_iso,
};

struct BuiltInPersona {
id: &'static str,
Expand Down Expand Up @@ -288,16 +291,37 @@ pub fn validate_persona_deletion(
));
}

validate_agent_not_in_team(&persona.display_name, referenced_by_team)?;

Ok(())
}

pub fn validate_agent_not_in_team(
display_name: &str,
referenced_by_team: bool,
) -> Result<(), String> {
if referenced_by_team {
return Err(format!(
"{} is still referenced by a team. Remove it from those teams first.",
persona.display_name
"Cannot remove {display_name}: this agent belongs to a team. \
Remove it from every team first."
));
}

Ok(())
}

pub fn validate_managed_agent_team_deletion(
record: &ManagedAgentRecord,
teams: &[TeamRecord],
) -> Result<(), String> {
let referenced_by_team = record.persona_id.as_deref().is_some_and(|persona_id| {
teams
.iter()
.any(|team| team.persona_ids.iter().any(|id| id == persona_id))
});
validate_agent_not_in_team(&record.name, referenced_by_team)
}

pub fn validate_persona_activation_change(
persona: &AgentDefinition,
active: bool,
Expand All @@ -308,16 +332,13 @@ pub fn validate_persona_activation_change(
return Err("Only built-in agents can be added to or removed from My Agents.".to_string());
}

if !active && referenced_by_managed_agent {
return Err(format!(
"{} is still assigned to a managed agent. Remove or reassign those agents first.",
persona.display_name
));
if !active {
validate_agent_not_in_team(&persona.display_name, referenced_by_team)?;
}

if !active && referenced_by_team {
if !active && referenced_by_managed_agent {
return Err(format!(
"{} is still referenced by a team. Remove it from those teams first.",
"{} is still assigned to a managed agent. Remove or reassign those agents first.",
persona.display_name
));
}
Expand Down
17 changes: 15 additions & 2 deletions desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,20 @@ fn validate_persona_activation_change_rejects_team_references() {

assert_eq!(
err,
"Fizz is still referenced by a team. Remove it from those teams first."
"Cannot remove Fizz: this agent belongs to a team. Remove it from every team first."
);
}

#[test]
fn validate_persona_activation_change_prioritizes_team_membership() {
let mut persona = custom_persona("builtin:fizz", "Fizz");
persona.is_builtin = true;

let err = validate_persona_activation_change(&persona, false, true, true).unwrap_err();

assert_eq!(
err,
"Cannot remove Fizz: this agent belongs to a team. Remove it from every team first."
);
}

Expand Down Expand Up @@ -255,7 +268,7 @@ fn validate_persona_deletion_rejects_team_references() {

assert_eq!(
err,
"Alpha is still referenced by a team. Remove it from those teams first."
"Cannot remove Alpha: this agent belongs to a team. Remove it from every team first."
);
}

Expand Down
16 changes: 10 additions & 6 deletions desktop/src-tauri/src/managed_agents/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ fn agents_referencing_team<'a>(
.collect()
}

fn team_in_use_deletion_error(team_id: &str, agent_names: &[&str]) -> String {
format!(
"Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \
Edit the team to remove those agents, then delete or reconfigure their instances.",
agent_names.len(),
agent_names.join(", ")
)
}

/// Delete a team, cascading removal of its sourced personas and backing dir.
///
/// Returns the d-tags of the personas removed by the cascade so the caller can
Expand All @@ -248,12 +257,7 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<Vec<St
let agents = crate::managed_agents::load_managed_agents(app)?;
let referencing = agents_referencing_team(&agents, team);
if !referencing.is_empty() {
return Err(format!(
"Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \
Delete or reconfigure them first.",
referencing.len(),
referencing.join(", ")
));
return Err(team_in_use_deletion_error(team_id, &referencing));
}

let mut cascaded_persona_d_tags = Vec::new();
Expand Down
11 changes: 10 additions & 1 deletion desktop/src-tauri/src/managed_agents/teams_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use super::{
agents_referencing_team, load_teams_readonly, merge_teams, merge_teams_impl, sort_teams,
validate_team_deletion, BuiltInTeam,
team_in_use_deletion_error, validate_team_deletion, BuiltInTeam,
};
use crate::managed_agents::{ManagedAgentRecord, TeamRecord};

Expand Down Expand Up @@ -261,6 +261,15 @@ fn agents_referencing_team_empty_when_no_matches() {
assert!(agents_referencing_team(&agents, &t).is_empty());
}

#[test]
fn team_in_use_error_explains_the_cleanup_order() {
assert_eq!(
team_in_use_deletion_error("team-1", &["Fizz", "Honey"]),
"Cannot delete team \"team-1\": 2 agent(s) still reference it (Fizz, Honey). \
Edit the team to remove those agents, then delete or reconfigure their instances."
);
}

// Migration pins — exercise the real merge_teams wrapper (with production consts).

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
deleteManagedAgentWithRules,
startManagedAgentWithRules,
respawnManagedAgentWithRules,
} from "./managedAgentControlActions.ts";
Expand Down Expand Up @@ -81,6 +82,66 @@ test("ordinary local agents still start normally", async () => {
assert.equal(calledWith, "deadbeef".repeat(8));
});

test("remote team members are rejected before shutdown or deletion", async () => {
const teamAgent = agent({
backend: {
type: "provider",
id: "remote-host",
config: {},
},
backendAgentId: "remote-agent-1",
name: "Team Agent",
personaId: "custom:team-agent",
status: "deployed",
});
let deleteCalled = false;

await assert.rejects(
deleteManagedAgentWithRules({
agent: teamAgent,
channels: [{ id: "channel-1", name: "general" }],
deleteManagedAgent: async () => {
deleteCalled = true;
},
presenceLookup: {
[teamAgent.pubkey]: "online",
},
relayAgents: [
{
pubkey: teamAgent.pubkey,
channelIds: ["channel-1"],
channels: ["general"],
},
],
teams: [{ personaIds: ["custom:team-agent"] }],
}),
{
message:
"Cannot remove Team Agent: this agent belongs to a team. Remove it from every team first.",
},
);
assert.equal(deleteCalled, false);
});

test("agents removed from their teams can still be deleted", async () => {
const formerTeamAgent = agent({
personaId: "custom:former-team-agent",
});
let deletedPubkey = null;

await deleteManagedAgentWithRules({
agent: formerTeamAgent,
channels: [],
deleteManagedAgent: async ({ pubkey }) => {
deletedPubkey = pubkey;
},
relayAgents: [],
teams: [{ personaIds: ["custom:other-agent"] }],
});

assert.equal(deletedPubkey, formerTeamAgent.pubkey);
});

// --- respawnManagedAgentWithRules: stop→clear→start boundary tests -----------

test("test_respawn_stop_success_start_failure_onStopped_still_fires", async () => {
Expand Down
14 changes: 14 additions & 0 deletions desktop/src/features/agents/lib/managedAgentControlActions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { sendChannelMessage } from "@/shared/api/tauri";
import type {
AgentTeam,
Channel,
ManagedAgent,
PresenceLookup,
Expand All @@ -24,6 +25,7 @@ type ManagedAgentChannelContext = {

type ManagedAgentActionContext = ManagedAgentChannelContext & {
presenceLookup?: PresenceLookup | null;
teams: readonly AgentTeam[];
};

export type ManagedAgentActionResult = {
Expand Down Expand Up @@ -149,11 +151,23 @@ export async function deleteManagedAgentWithRules({
presenceLookup,
relayAgents,
skipRemoteDeleteConfirm = false,
teams,
}: {
agent: ManagedAgent;
deleteManagedAgent: DeleteManagedAgent;
skipRemoteDeleteConfirm?: boolean;
} & ManagedAgentActionContext): Promise<ManagedAgentActionResult> {
const personaId = agent.personaId;
const belongsToTeam =
personaId !== null &&
teams.some((team) => team.personaIds.includes(personaId));
if (belongsToTeam) {
throw new Error(
`Cannot remove ${agent.name}: this agent belongs to a team. ` +
"Remove it from every team first.",
);
}

if (agent.backend.type === "provider" && agent.backendAgentId) {
const presence = presenceLookup?.[normalizePubkey(agent.pubkey)];
const channelId = resolveManagedAgentChannelId(agent, {
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/features/agents/ui/useManagedAgentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
ManagedAgent,
} from "@/shared/api/types";
import { removeChannelMember } from "@/shared/api/tauri";
import { listTeams } from "@/shared/api/tauriTeams";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
deleteManagedAgentWithRules,
Expand Down Expand Up @@ -237,6 +238,10 @@ export function useManagedAgentActions() {
return result.data ?? [];
}

async function getTeamsForAction() {
return listTeams();
}

async function handleStop(pubkey: string) {
clearFeedback();
try {
Expand Down Expand Up @@ -284,12 +289,14 @@ export function useManagedAgentActions() {
const agent = managedAgents.find((a) => a.pubkey === pubkey);
if (!agent) return;
const channels = await getChannelsForAction();
const teams = await getTeamsForAction();
const result = await deleteManagedAgentWithRules({
agent,
channels,
deleteManagedAgent: deleteMutation.mutateAsync,
presenceLookup: managedPresenceQuery.data,
relayAgents: relayAgentsQuery.data ?? [],
teams,
});
if (result.cancelled) return;
await removeAgentFromAllChannels(pubkey);
Expand Down
Loading