diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 3eb0fded6a..af2019b625 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -50,7 +50,10 @@ pub(super) fn build_launch_block( effective_model: Option<&str>, owner_pubkey: &str, ) -> Result { - let assigned_relay_skills = live_assigned_relay_skills(record, personas)?; + let assigned_relay_skills = + crate::managed_agents::effective_config::resolve_effective_assigned_relay_skills( + record, personas, + )?; Ok(serialize_launch_block( record, descriptor, @@ -193,20 +196,6 @@ pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result Ok(()) } -fn live_assigned_relay_skills<'a>( - record: &'a ManagedAgentRecord, - personas: &'a [AgentDefinition], -) -> Result<&'a [String], String> { - match record.persona_id.as_deref() { - Some(persona_id) => personas - .iter() - .find(|persona| persona.id == persona_id) - .map(|persona| persona.assigned_relay_skills.as_slice()) - .ok_or_else(|| format!("linked agent definition {persona_id:?} is missing")), - None => Ok(record.assigned_relay_skills.as_slice()), - } -} - /// Build the standard agent JSON payload for provider deploy calls. pub(crate) fn build_deploy_payload( app: &AppHandle, diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs index e079c76a1d..f887d6c5c6 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -261,6 +261,22 @@ pub fn resolve_effective_config( } } +/// Resolve shared-instruction authorization from the live linked definition. +/// Definition-less records retain their own assignments; orphaned links are refused. +pub fn resolve_effective_assigned_relay_skills<'a>( + record: &'a ManagedAgentRecord, + definitions: &'a [AgentDefinition], +) -> Result<&'a [String], String> { + match record.persona_id.as_deref() { + Some(persona_id) => definitions + .iter() + .find(|definition| definition.id == persona_id) + .map(|definition| definition.assigned_relay_skills.as_slice()) + .ok_or_else(|| ORPHANED_INSTANCE_ERROR.to_string()), + None => Ok(record.assigned_relay_skills.as_slice()), + } +} + pub fn resolve_effective_model_provider_pair( record: &ManagedAgentRecord, definitions: &[AgentDefinition], diff --git a/desktop/src-tauri/src/managed_agents/relay_skills.rs b/desktop/src-tauri/src/managed_agents/relay_skills.rs index 15ae5af873..78b10ef558 100644 --- a/desktop/src-tauri/src/managed_agents/relay_skills.rs +++ b/desktop/src-tauri/src/managed_agents/relay_skills.rs @@ -699,6 +699,58 @@ mod tests { assert_eq!(env, Some(coordinate.as_str())); } + #[test] + fn effective_assigned_relay_skill_env_uses_live_definition() { + let stale = format!("30023:{}:revoked", "a".repeat(64)); + let current = format!("30023:{}:kept", "b".repeat(64)); + let record: super::super::ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", + "name": "agent", + "persona_id": "persona", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://relay.example", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 300, + "parallelism": 1, + "assigned_relay_skills": [stale.clone()], + "created_at": "now", + "updated_at": "now" + })) + .unwrap(); + let definition: super::super::AgentDefinition = serde_json::from_value(serde_json::json!({ + "id": "persona", + "display_name": "Persona", + "system_prompt": "prompt", + "name_pool": [], + "is_builtin": false, + "is_active": true, + "assigned_relay_skills": [current.clone()], + "created_at": "now", + "updated_at": "now" + })) + .unwrap(); + let mut command = std::process::Command::new("buzz-acp"); + + let definitions = [definition]; + let assigned = + crate::managed_agents::effective_config::resolve_effective_assigned_relay_skills( + &record, + &definitions, + ) + .unwrap(); + apply_assigned_relay_skills_env(&mut command, assigned); + + let env = command + .get_envs() + .find(|(key, _)| *key == ASSIGNED_RELAY_SKILLS_ENV) + .and_then(|(_, value)| value) + .and_then(|value| value.to_str()); + assert_eq!(env, Some(current.as_str())); + } + #[test] fn accepts_exact_coordinate_and_deduplicates() { let key = "a".repeat(64); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index aaa54ccecc..fc0ef17b5f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -694,10 +694,9 @@ pub fn spawn_agent_child( } else { command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); } - super::relay_skills::apply_assigned_relay_skills_env( - &mut command, - &record.assigned_relay_skills, - ); + let assigned_relay_skills = + super::effective_config::resolve_effective_assigned_relay_skills(record, &personas)?; + super::relay_skills::apply_assigned_relay_skills_env(&mut command, assigned_relay_skills); // Prompt, model, and provider all come from the single `effective_cfg` // resolved at the top of this function — the SAME resolve the spawn-config @@ -864,6 +863,7 @@ pub fn spawn_agent_child( system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), provider: effective_provider.as_deref(), + assigned_relay_skills, enforced_owner_only: super::owner_only_access_build(), }, ); diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 1a921e47b9..ed944d2309 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -73,6 +73,8 @@ pub(crate) struct SpawnConfigInputs<'a> { pub system_prompt: Option<&'a str>, pub model: Option<&'a str>, pub provider: Option<&'a str>, + /// Exact live authorization list applied to the spawned process. + pub assigned_relay_skills: &'a [String], /// Compile-time distribution capability projected at this runtime boundary. /// The stored record remains portable; only effective spawned access is stamped. pub enforced_owner_only: bool, @@ -167,6 +169,7 @@ impl SpawnConfigSnapshot { system_prompt, model, provider, + assigned_relay_skills, enforced_owner_only, } = inputs; let (respond_to, respond_to_allowlist) = @@ -195,7 +198,7 @@ impl SpawnConfigSnapshot { system_prompt: system_prompt.map(str::to_string), model: model.map(str::to_string), provider: provider.map(str::to_string), - assigned_relay_skills: record.assigned_relay_skills.clone(), + assigned_relay_skills: assigned_relay_skills.to_vec(), session_title: (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR)) .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) .flatten(), @@ -301,6 +304,10 @@ pub(crate) fn prospective_spawn_config_snapshot( EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), }; + let assigned_relay_skills = + super::effective_config::resolve_effective_assigned_relay_skills(record, personas) + .unwrap_or_default(); + SpawnConfigSnapshot::from_inputs(SpawnConfigInputs { record, descriptor: &descriptor, @@ -311,6 +318,7 @@ pub(crate) fn prospective_spawn_config_snapshot( system_prompt: prompt.as_deref(), model: model.as_deref(), provider: provider.as_deref(), + assigned_relay_skills, enforced_owner_only, }) } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 84566e8e89..93077d2835 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -2,6 +2,8 @@ use super::*; use crate::managed_agents::types::RespondTo; use std::collections::BTreeMap; +mod assigned_relay_skills; + /// Canonical projection of a prospective snapshot — the exact value the drift /// comparison reads, so these tests assert on drift itself rather than on a /// proxy for it. diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests/assigned_relay_skills.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests/assigned_relay_skills.rs new file mode 100644 index 0000000000..73371a4a0a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests/assigned_relay_skills.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn restart_stamp_uses_live_shared_instructions_and_clears_diff() { + let mut rec = record(); + rec.persona_id = Some("pers".into()); + rec.assigned_relay_skills = vec![ + format!("30023:{}:kept", "b".repeat(64)), + format!("30023:{}:revoked", "a".repeat(64)), + ]; + let mut live = persona("pers", Some("goose"), "prompt"); + live.assigned_relay_skills = vec![format!("30023:{}:kept", "b".repeat(64))]; + let personas = [live]; + let global = GlobalAgentConfig::default(); + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(&rec, &personas, &global) + .unwrap(); + let effective = resolve_effective_config(&rec, &personas, &global) + .require_resolved() + .unwrap(); + let assigned = + crate::managed_agents::effective_config::resolve_effective_assigned_relay_skills( + &rec, &personas, + ) + .unwrap(); + let stamped = SpawnConfigSnapshot::from_inputs(SpawnConfigInputs { + record: &rec, + descriptor: &descriptor, + relay_url: "wss://ws.example", + team_instructions: None, + system_prompt: effective.system_prompt.value.as_deref(), + model: effective.model.value.as_deref(), + provider: effective.provider.value.as_deref(), + assigned_relay_skills: assigned, + enforced_owner_only: false, + }); + let current = + prospective_spawn_config_snapshot(&rec, &personas, &[], "wss://ws.example", &global, false); + + assert_eq!( + stamped.assigned_relay_skills, + personas[0].assigned_relay_skills + ); + assert_eq!(stamped.canonical(), current.canonical()); +}