diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14..d1784c01fa 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -39,9 +39,7 @@ pub struct AppState { /// restore. `apply_workspace` consumes it after installing the workspace /// relay and identity, so agents never start against the fallback relay. pub managed_agent_restore_pending: AtomicBool, - /// Whether desktop may repair managed-agent kind:0 profiles from its local - /// records. Disabled by the agent-managed profiles experiment so an agent's - /// own profile updates are not overwritten on start or restore. + /// Disabled by agent-managed profiles so agent profile updates survive start/restore. pub managed_agent_profile_reconcile_enabled: AtomicBool, /// Shared shutdown signal checked by launch-time agent restoration. pub shutdown_started: AtomicBool, @@ -52,6 +50,7 @@ pub struct AppState { pub managed_agents_store_lock: Mutex<()>, pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, + pub provider_deploy_locks: Mutex>>>, pub huddle_state: Mutex, pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, /// Tauri app handle — stored after setup so huddle commands can emit @@ -215,6 +214,7 @@ pub fn build_app_state() -> AppState { managed_agents_store_lock: Mutex::new(()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), + provider_deploy_locks: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), huddle_audio: Default::default(), diff --git a/desktop/src-tauri/src/commands/agent_access.rs b/desktop/src-tauri/src/commands/agent_access.rs index ef118e82b2..f2851626a8 100644 --- a/desktop/src-tauri/src/commands/agent_access.rs +++ b/desktop/src-tauri/src/commands/agent_access.rs @@ -4,6 +4,19 @@ pub fn agent_access_owner_only() -> bool { crate::managed_agents::owner_only_access_build() } +/// Tiny executable-facing probe for release packaging smoke tests. Keeping the +/// probe in the product crate makes it impossible for buzz-releases to validate +/// a copied flag interpretation that has drifted from Desktop's command. +#[doc(hidden)] +pub fn print_agent_access_owner_only_probe_if_requested() -> bool { + if std::env::args().any(|arg| arg == "--print-agent-access-owner-only") { + println!("{}", agent_access_owner_only()); + true + } else { + false + } +} + #[cfg(test)] mod tests { #[test] diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index b63370b95f..77e43f5d64 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -89,6 +89,7 @@ fn agent_record() -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2d..e8e910b645 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,14 +1,7 @@ -use tauri::State; - -use crate::{ - app_state::AppState, - managed_agents::{ - command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, - DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, - RelayAgentInfo, DEFAULT_ACP_COMMAND, - }, - nostr_convert, - relay::query_relay, +use crate::managed_agents::{ + command_availability, is_npm_global_install, AcpRuntimeCatalogEntry, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, + DEFAULT_ACP_COMMAND, }; mod post_install_verification; @@ -1037,31 +1030,31 @@ pub async fn discover_managed_agent_prereqs( .map_err(|e| format!("spawn_blocking failed: {e}")) } -#[tauri::command] -pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - // Query kind:10100 agent profile events from the relay. - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [10100], - })], - ) - .await?; - - // The convert helper returns `{"agents": [...]}`. Extract and re-deserialize - // into the strongly-typed `Vec` the frontend expects. - let value = nostr_convert::agents_from_events(&events); - let agents = value - .get("agents") - .cloned() - .unwrap_or_else(|| serde_json::json!([])); - serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}")) -} +mod relay_directory; +#[cfg(test)] +use relay_directory::advance_relay_cursor; +pub use relay_directory::list_relay_agents; #[cfg(test)] mod tests { use super::*; + #[test] + fn relay_directory_cursor_uses_timestamp_and_event_id() { + use nostr::{EventBuilder, Keys, Kind, Timestamp}; + + let event = EventBuilder::new(Kind::Custom(30177), "{}") + .custom_created_at(Timestamp::from(42)) + .sign_with_keys(&Keys::generate()) + .expect("sign cursor event"); + let mut filter = serde_json::json!({"kinds": [30177]}); + + advance_relay_cursor(&mut filter, std::slice::from_ref(&event)); + + assert_eq!(filter["until"], 42); + assert_eq!(filter["before_id"], event.id.to_hex()); + } + // ── is_npm_global_install ───────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs new file mode 100644 index 0000000000..b97cefc0ee --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -0,0 +1,426 @@ +//! Relay-backed shared-agent directory discovery. + +use tauri::State; + +use crate::{ + app_state::AppState, commands::identity_archive, managed_agents::RelayAgentInfo, nostr_convert, + relay::query_relay, +}; + +const RELAY_DIRECTORY_PAGE_SIZE: usize = 500; +const RELAY_FILTER_BATCH_SIZE: usize = 10; + +fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec { + pubkeys + .iter() + .map(|pubkey| { + serde_json::json!({ + "authors": [pubkey], + "kinds": [kind], + "limit": 1, + }) + }) + .collect() +} + +fn managed_policy_filters( + candidate_pubkeys: &[String], + verified_owners: &std::collections::HashMap, +) -> Vec { + candidate_pubkeys + .iter() + .filter_map(|agent_pubkey| { + verified_owners.get(agent_pubkey).map(|owner_pubkey| { + serde_json::json!({ + "authors": [owner_pubkey], + "kinds": [30177], + "#d": [agent_pubkey], + "limit": 1, + }) + }) + }) + .collect() +} + +fn current_user_pubkey(state: &AppState) -> Result { + state + .keys + .lock() + .map(|keys| keys.public_key().to_hex()) + .map_err(|error| error.to_string()) +} + +pub(super) fn advance_relay_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { + let last = page + .last() + .expect("a full relay page always has a last event"); + filter["until"] = serde_json::json!(last.created_at.as_secs()); + filter["before_id"] = serde_json::json!(last.id.to_hex()); +} + +async fn query_all_relay_pages( + state: &AppState, + mut filter: serde_json::Value, +) -> Result, String> { + filter["limit"] = serde_json::json!(RELAY_DIRECTORY_PAGE_SIZE); + let mut events = Vec::new(); + loop { + let page = query_relay(state, &[filter.clone()]).await?; + let done = page.len() < RELAY_DIRECTORY_PAGE_SIZE; + if !done { + advance_relay_cursor(&mut filter, &page); + } + events.extend(page); + if done { + return Ok(events); + } + } +} + +pub(crate) async fn list_relay_agents_for_state( + state: &AppState, +) -> Result, String> { + let viewer_pubkey = current_user_pubkey(state)?; + let relay_pubkey = identity_archive::fetch_relay_self(state) + .await? + .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; + + // Membership is the authoritative and bounded candidate source. Only + // channels visible to this identity are read, and only bot-role p-tags can + // drive the downstream managed-policy and owner-profile lookups. + let membership_events = query_all_relay_pages( + state, + serde_json::json!({ + "kinds": [39002], + "authors": [&relay_pubkey], + "#p": [&viewer_pubkey], + }), + ) + .await + .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; + let member_agent_channel_ids = + nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey); + let candidate_pubkeys: Vec = member_agent_channel_ids.keys().cloned().collect(); + if candidate_pubkeys.is_empty() { + return Ok(Vec::new()); + } + + let mut directory_events = Vec::new(); + let mut profile_events = Vec::new(); + let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); + let profile_filters = exact_author_filters(&candidate_pubkeys, 0); + for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) { + let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len()); + let (directory, profiles) = tokio::join!( + query_relay(state, &directory_filters[filter_offset..filter_end]), + query_relay(state, &profile_filters[filter_offset..filter_end]), + ); + directory_events.extend( + directory + .map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?, + ); + profile_events.extend( + profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?, + ); + } + + // Only the agent's signed NIP-OA profile can name the owner coordinate to + // query. Each exact `(owner, d=agent)` filter returns at most one current + // replaceable event, so forged 30177 coordinates cannot amplify or crowd + // the authentic policy out of a bounded result page. + let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); + let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); + let mut managed_agent_events = Vec::new(); + for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) { + managed_agent_events.extend( + query_relay(state, filters) + .await + .map_err(|error| format!("relay agent managed-policy query failed: {error}"))?, + ); + } + + let mut agents = nostr_convert::relay_agents_from_directory_events( + &directory_events, + &managed_agent_events, + &profile_events, + ); + agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); + for agent in &mut agents { + agent.channel_ids = member_agent_channel_ids + .get(&agent.pubkey) + .cloned() + .unwrap_or_default(); + } + Ok(agents) +} + +#[tauri::command] +pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { + list_relay_agents_for_state(&state).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_author_queries_prevent_noisy_agent_crowd_out() { + let pubkeys = vec!["a".repeat(64), "b".repeat(64)]; + + let filters = exact_author_filters(&pubkeys, 10100); + + assert_eq!(filters.len(), 2); + for (filter, pubkey) in filters.iter().zip(pubkeys) { + assert_eq!(filter["authors"], serde_json::json!([pubkey])); + assert_eq!(filter["kinds"], serde_json::json!([10100])); + assert_eq!(filter["limit"], 1); + } + } + + #[test] + fn managed_policy_queries_are_exact_coordinates() { + let candidates = vec!["a".repeat(64), "b".repeat(64)]; + let owners = std::collections::HashMap::from([ + (candidates[0].clone(), "c".repeat(64)), + (candidates[1].clone(), "d".repeat(64)), + ]); + + let filters = managed_policy_filters(&candidates, &owners); + + assert_eq!(filters.len(), 2); + for (filter, candidate) in filters.iter().zip(candidates) { + assert_eq!(filter["authors"].as_array().map(Vec::len), Some(1)); + assert_eq!(filter["kinds"], serde_json::json!([30177])); + assert_eq!(filter["#d"], serde_json::json!([candidate])); + assert_eq!(filter["limit"], 1); + } + } + + #[test] + fn relay_filter_batches_do_not_exceed_protocol_limit() { + let pubkeys: Vec<_> = (0..25).map(|index| format!("{index:064x}")).collect(); + let filters = exact_author_filters(&pubkeys, 0); + + let batch_sizes: Vec<_> = filters + .chunks(RELAY_FILTER_BATCH_SIZE) + .map(<[_]>::len) + .collect(); + + assert_eq!(batch_sizes, vec![10, 10, 5]); + } +} + +#[cfg(all(test, not(target_os = "windows")))] +mod real_relay_tests { + use super::*; + use crate::{app_state::build_app_state, events, managed_agents, relay}; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; + + fn relay_ws_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3037".to_string()) + } + + fn state_for(keys: Keys) -> AppState { + let state = build_app_state(); + *state.keys.lock().unwrap() = keys; + *state.relay_url_override.lock().unwrap() = Some(relay_ws_url()); + state + } + + async fn publish(builder: EventBuilder, signer: &Keys, state: &AppState) { + relay::submit_event_with_keys(builder, state, signer, None) + .await + .expect("publish real-relay fixture"); + } + + #[tokio::test] + #[ignore] + async fn newly_retained_managed_policy_replaces_open_access_immediately_on_real_relay() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let state = state_for(owner.clone()); + let db_dir = tempfile::tempdir().unwrap(); + let db_path = db_dir.path().join("retention.sqlite3"); + let initial_content = serde_json::json!({ + "name": "Immediate Policy Probe", + "parallelism": 1, + "respond_to": "anyone" + }) + .to_string(); + let initial_event = + EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), initial_content) + .tags([Tag::parse(["d", &agent.public_key().to_hex()]).unwrap()]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs().saturating_sub(1), + )); + publish(initial_event, &owner, &state).await; + + let updated_content = serde_json::json!({ + "name": "Immediate Policy Probe", + "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(); + let event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), updated_content) + .tags([Tag::parse(["d", &agent.public_key().to_hex()]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + + { + use managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; + use nostr::JsonUtil; + + let conn = open_retention_db(&db_path).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner.public_key().to_hex(), + d_tag: agent.public_key().to_hex(), + content: event.content.clone(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .unwrap(); + } + + let flushed = managed_agents::persona_events::flush_pending_events_at( + &db_path, + &state, + &relay_ws_url(), + &owner, + ) + .await + .expect("create-path immediate policy flush"); + assert_eq!(flushed, 1); + + let queried = query_relay( + &state, + &[serde_json::json!({ + "kinds": [KIND_MANAGED_AGENT], + "authors": [owner.public_key().to_hex()], + "#d": [agent.public_key().to_hex()], + "limit": 1 + })], + ) + .await + .expect("query immediately flushed policy"); + assert_eq!(queried.len(), 1); + assert_eq!(queried[0].id, event.id); + assert!(queried[0].content.contains("\"respond_to\":\"owner-only\"")); + } + + #[tokio::test] + #[ignore] + async fn cross_identity_managed_agent_is_discovered_and_emits_exact_p_tag_from_real_relay() { + let owner = Keys::generate(); + let viewer = Keys::generate(); + let agent = Keys::generate(); + let owner_state = state_for(owner.clone()); + let viewer_state = state_for(viewer.clone()); + let channel_id = Uuid::new_v4(); + + publish( + events::build_create_channel( + channel_id, + &format!("agent-discovery-e2e-{channel_id}"), + "private", + "stream", + None, + None, + ) + .unwrap(), + &owner, + &owner_state, + ) + .await; + publish( + events::build_add_member(channel_id, &viewer.public_key().to_hex(), None).unwrap(), + &owner, + &owner_state, + ) + .await; + publish( + events::build_add_member(channel_id, &agent.public_key().to_hex(), Some("bot")) + .unwrap(), + &owner, + &owner_state, + ) + .await; + + let compat_owner = nostr::Keys::parse(&owner.secret_key().to_secret_hex()).unwrap(); + let compat_agent = nostr::PublicKey::from_hex(&agent.public_key().to_hex()).unwrap(); + let auth_tag = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "").unwrap(); + relay::sync_managed_agent_profile( + &owner_state, + &relay_ws_url(), + &agent, + "Agent Probe", + None, + Some(&auth_tag), + ) + .await + .expect("publish agent kind:0 profile"); + + let managed_content = serde_json::json!({ + "name": "Agent Probe", + "parallelism": 1, + "respond_to": "anyone" + }) + .to_string(); + publish( + EventBuilder::new(Kind::Custom(30177), managed_content).tags([Tag::parse([ + "d", + &agent.public_key().to_hex(), + ]) + .unwrap()]), + &owner, + &owner_state, + ) + .await; + + let agents = list_relay_agents_for_state(&viewer_state) + .await + .expect("query production relay directory"); + assert_eq!(agents.len(), 1, "real relay directory returned {agents:?}"); + assert_eq!(agents[0].pubkey, agent.public_key().to_hex()); + assert_eq!(agents[0].name, "Agent Probe"); + assert_eq!(agents[0].channel_ids, vec![channel_id.to_string()]); + + // Exercise the final protocol boundary, not merely the directory DTO: + // selecting this candidate must become the agent's exact lowercase + // `p` tag in the signed stream event. + let mention_pubkey = agents[0].pubkey.as_str(); + let signed_message = events::build_message( + channel_id, + "Ask @Agent Probe to reply", + None, + &[mention_pubkey], + &[], + &[], + &[], + &[], + None, + &relay_ws_url(), + ) + .unwrap() + .sign_with_keys(&viewer) + .unwrap(); + let emitted_mentions: Vec<_> = signed_message + .tags + .iter() + .filter_map(|tag| { + let tag = tag.as_slice(); + (tag.first().map(String::as_str) == Some("p")) + .then(|| tag.get(1).cloned()) + .flatten() + }) + .collect(); + assert_eq!(emitted_mentions, vec![agent.public_key().to_hex()]); + } +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 183f27dba1..85d9da4dfa 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -22,8 +22,8 @@ use crate::{ find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, - AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, - DEFAULT_ACP_COMMAND, + AgentModelInfo, AgentModelsResponse, ManagedAgentRecord, UpdateManagedAgentRequest, + UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -697,217 +697,10 @@ use databricks::{ }; use databricks::{discover_databricks_models, DatabricksAuthIntent}; -/// Update mutable fields on an existing managed agent record. -/// -/// Does NOT auto-restart the agent. Runtime config changes (system prompt, -/// parallelism, commands, toolsets) take effect on the next agent spawn. -/// Name changes are synced to the relay immediately via a kind:0 re-publish. -#[tauri::command] -pub async fn update_managed_agent( - input: UpdateManagedAgentRequest, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - // Phase 1: local save (synchronous, under lock) - let (summary, sync_params, rollback) = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let (_, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; - let previous_record = record.clone(); - - let mut name_changed = false; - if let Some(name_update) = input.name { - let trimmed = name_update.trim().to_string(); - if !trimmed.is_empty() && trimmed != record.name { - record.name = trimmed; - name_changed = true; - } - } - apply_model_provider_prompt_update( - record, - input.model, - input.provider, - input.system_prompt, - )?; - if let Some(parallelism) = input.parallelism { - record.parallelism = parallelism; - } - // turn_timeout_seconds is intentionally not applied here — - // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. - // Use idle_timeout_seconds or max_turn_duration_seconds instead. - // Store the relay override exactly as supplied (trimmed). An explicit - // value pins the agent; empty falls back to the workspace relay at - // read-time. A name-only edit (relay_url == None) leaves the pin intact. - if let Some(relay_url) = input.relay_url { - record.relay_url = relay_url.trim().to_string(); - } - if let Some(acp_command) = input.acp_command { - record.acp_command = acp_command; - } - // Harness edit: the persona's runtime is authoritative, so an explicit - // `agent_command_override` is persisted ONLY when the user picks a - // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit - // (`agent_command == None`) leaves the pin intact. `harness_override` - // threads the user's explicit intent — see `apply_agent_command_update` - // and `update_time_agent_command_override` for the full resolution - // rules. - if let Some(agent_command) = input.agent_command { - let personas = load_personas(&app).unwrap_or_default(); - crate::managed_agents::apply_agent_command_update( - record, - &personas, - &agent_command, - input.harness_override, - ); - } - if let Some(agent_args) = input.agent_args { - record.agent_args = agent_args; - } - // mcp_command is intentionally not applied here — the effective MCP - // command is always catalog-derived (known_acp_runtime at spawn time) - // and the per-record field is never read by the runtime. - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - record.env_vars = env_vars; - } - - // Native provider/model fields are authoritative. Keep the typed marker - // derived for new records while retaining legacy typed records for - // non-native providers. - if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { - let model_ref = record - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) - .to_string(); - record.model = Some(model_ref.clone()); - record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); - } - - // Inbound author gate: merge patch onto current values, then validate - // the merged state. This lets a single update switch to Allowlist AND - // supply pubkeys atomically. - let prospective_mode = input.respond_to.unwrap_or(record.respond_to); - let prospective_allowlist = match input.respond_to_allowlist.as_ref() { - Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?, - None => record.respond_to_allowlist.clone(), - }; - if prospective_mode == crate::managed_agents::RespondTo::Allowlist - && prospective_allowlist.is_empty() - { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" - .to_string(), - ); - } - record.respond_to = prospective_mode; - // Preserve the persisted allowlist across mode toggles — only replace - // when the caller explicitly supplied a new list. - if input.respond_to_allowlist.is_some() { - record.respond_to_allowlist = prospective_allowlist; - } - - record.updated_at = now_iso(); - - save_managed_agents(&app, &records)?; - - let record = records - .iter() - .find(|r| r.pubkey == input.pubkey) - .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - - // Publish the edit to the relay. After-save, inside the lock, before - // any .await. The retention upsert hashes the opt-IN projection, so an - // update that touched only runtime/local fields is a no-op publish. - super::agents::retain_managed_agent_pending(&app, &state, record); - - let sync_params = if name_changed { - let agent_keys = Keys::parse(&record.private_key_nsec) - .map_err(|e| format!("failed to parse agent keys: {e}"))?; - // Re-publish the renamed profile to the agent's effective relay: - // an explicit per-agent relay wins; empty falls back to workspace. - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &relay_ws_url_with_override(&state), - ); - let display_name = record.name.clone(); - // Avatar fallback derives from the EFFECTIVE harness (persona-wins), - // not the frozen snapshot, so an inherited harness picks the right - // default avatar. - let personas = load_personas(&app).unwrap_or_default(); - let effective_command = crate::managed_agents::record_agent_command(record, &personas); - let avatar_url = record - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_command)); - let auth_tag = record.auth_tag.clone(); - Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) - } else { - None - }; - - let summary = { - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - )? - }; - let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record)); - (summary, sync_params, rollback) - }; // lock dropped here - - try_regenerate_nest(&app); - - // Phase 2: relay profile sync (async, outside lock). A rename is committed - // only when this succeeds; otherwise restore the complete pre-edit record - // so Desktop and the relay keep one authoritative name. - if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { - if let Err(sync_error) = sync_managed_agent_profile( - &state, - &relay_url, - &agent_keys, - &display_name, - avatar_url.as_deref(), - auth_tag.as_deref(), - ) - .await - { - let rollback = rollback.ok_or_else(|| { - "missing local rollback state after relay profile sync failure".to_string() - })?; - rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?; - return Err(format!( - "Agent rename failed because its relay profile could not be updated. No changes were saved: {sync_error}" - )); - } - } - - Ok(UpdateManagedAgentResponse { - agent: summary, - profile_sync_error: None, - }) -} +#[path = "agent_models_update.rs"] +mod update; +pub use update::update_managed_agent; +pub(super) use update::{flush_managed_agent_policy, managed_agent_access_policy_changed}; // ── Model normalization ─────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 79dd7263c6..c00cb5cd2d 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -1,5 +1,32 @@ use super::*; +#[test] +fn access_policy_change_requires_runtime_refresh_for_effective_gate_changes() { + use crate::managed_agents::RespondTo; + + let allowlist_a = vec!["a".repeat(64)]; + let allowlist_b = vec!["b".repeat(64)]; + + assert!(managed_agent_access_policy_changed( + RespondTo::Anyone, + &[], + RespondTo::OwnerOnly, + &[], + )); + assert!(managed_agent_access_policy_changed( + RespondTo::Allowlist, + &allowlist_a, + RespondTo::Allowlist, + &allowlist_b, + )); + assert!(!managed_agent_access_policy_changed( + RespondTo::OwnerOnly, + &allowlist_a, + RespondTo::OwnerOnly, + &allowlist_b, + )); +} + #[test] fn openai_model_normalization_keeps_agent_text_models() { let models = normalize_openai_compatible_models( diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs new file mode 100644 index 0000000000..922bee2e3e --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -0,0 +1,360 @@ +use super::*; + +pub(crate) fn managed_agent_access_policy_changed( + current_mode: crate::managed_agents::RespondTo, + current_allowlist: &[String], + prospective_mode: crate::managed_agents::RespondTo, + prospective_allowlist: &[String], +) -> bool { + prospective_mode != current_mode + || (prospective_mode == crate::managed_agents::RespondTo::Allowlist + && prospective_allowlist != current_allowlist) +} + +fn ensure_access_policy_change_supported( + record: &ManagedAgentRecord, + access_policy_changed: bool, +) -> Result<(), String> { + if access_policy_changed + && record.backend != crate::managed_agents::BackendKind::Local + && record.backend_agent_id.is_some() + { + return Err( + "Access cannot be changed while this provider-backed agent is deployed because the provider protocol has no explicit stop or revocation acknowledgement. Stop or recreate the provider agent first." + .to_string(), + ); + } + Ok(()) +} + +/// Flush a retained managed-agent policy, preserving any earlier profile error. +pub(crate) async fn flush_managed_agent_policy( + app: &AppHandle, + state: &AppState, + existing_error: Option, +) -> Option { + match crate::managed_agents::persona_events::flush_active_pending_events(app, state).await { + Ok(_) => existing_error, + Err(error) => Some(match existing_error { + Some(profile_error) => { + format!("{profile_error}; managed policy sync failed: {error}") + } + None => format!("managed policy sync failed: {error}"), + }), + } +} + +/// Update mutable fields on an existing managed agent record. +/// +/// Most runtime config changes take effect on the next agent spawn. Access +/// policy changes stop active local pairs before saving and restart those exact +/// pairs after the relay policy is flushed. +#[tauri::command] +pub async fn update_managed_agent( + input: UpdateManagedAgentRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + // Phase 1: local save (synchronous, under lock) + let (mut summary, sync_params, rollback, access_policy_changed, access_restart_relays) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let (_, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + for pubkey in &exited_pubkeys { + state.clear_agent_session_caches(pubkey); + } + + let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + let previous_record = record.clone(); + + let mut name_changed = false; + if let Some(name_update) = input.name { + let trimmed = name_update.trim().to_string(); + if !trimmed.is_empty() && trimmed != record.name { + record.name = trimmed; + name_changed = true; + } + } + apply_model_provider_prompt_update( + record, + input.model, + input.provider, + input.system_prompt, + )?; + if let Some(parallelism) = input.parallelism { + record.parallelism = parallelism; + } + // turn_timeout_seconds is intentionally not applied here — + // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. + // Use idle_timeout_seconds or max_turn_duration_seconds instead. + // Store the relay override exactly as supplied (trimmed). An explicit + // value pins the agent; empty falls back to the workspace relay at + // read-time. A name-only edit (relay_url == None) leaves the pin intact. + if let Some(relay_url) = input.relay_url { + record.relay_url = relay_url.trim().to_string(); + } + if let Some(acp_command) = input.acp_command { + record.acp_command = acp_command; + } + // Harness edit: the persona's runtime is authoritative, so an explicit + // `agent_command_override` is persisted ONLY when the user picks a + // command that diverges from the persona, and the empty/whitespace + // "Inherit from persona" sentinel clears both the pin and the + // materialized record runtime. A name-only edit + // (`agent_command == None`) leaves the pin intact. `harness_override` + // threads the user's explicit intent — see `apply_agent_command_update` + // and `update_time_agent_command_override` for the full resolution + // rules. + if let Some(agent_command) = input.agent_command { + let personas = load_personas(&app).unwrap_or_default(); + crate::managed_agents::apply_agent_command_update( + record, + &personas, + &agent_command, + input.harness_override, + ); + } + if let Some(agent_args) = input.agent_args { + record.agent_args = agent_args; + } + // mcp_command is intentionally not applied here — the effective MCP + // command is always catalog-derived (known_acp_runtime at spawn time) + // and the per-record field is never read by the runtime. + if let Some(env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(&env_vars)?; + record.env_vars = env_vars; + } + + // Native provider/model fields are authoritative. Keep the typed marker + // derived for new records while retaining legacy typed records for + // non-native providers. + if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { + let model_ref = record + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) + .to_string(); + record.model = Some(model_ref.clone()); + record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); + } + + // Inbound author gate: merge patch onto current values, then validate + // the merged state. This lets a single update switch to Allowlist AND + // supply pubkeys atomically. + let prospective_mode = input.respond_to.unwrap_or(record.respond_to); + let prospective_allowlist = match input.respond_to_allowlist.as_ref() { + Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?, + None => record.respond_to_allowlist.clone(), + }; + if prospective_mode == crate::managed_agents::RespondTo::Allowlist + && prospective_allowlist.is_empty() + { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" + .to_string(), + ); + } + let access_policy_changed = managed_agent_access_policy_changed( + record.respond_to, + &record.respond_to_allowlist, + prospective_mode, + &prospective_allowlist, + ); + ensure_access_policy_change_supported(record, access_policy_changed)?; + + // Revoke the currently running local gate before persisting or + // advertising the replacement policy. Keeping this inside the same + // store/process critical section prevents another command or a status + // refresh from observing a saved narrow policy while the old broad + // process is still alive. A stop failure aborts before mutation. + let mut access_restart_relays = Vec::new(); + if access_policy_changed && record.backend == crate::managed_agents::BackendKind::Local { + access_restart_relays = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &record.pubkey) + .into_iter() + .map(|key| key.relay_url) + .collect(); + if access_restart_relays.is_empty() && record.runtime_pid.is_some() { + access_restart_relays.push(crate::relay::effective_agent_relay_url( + &record.relay_url, + &relay_ws_url_with_override(&state), + )); + } + if !access_restart_relays.is_empty() { + crate::managed_agents::stop_managed_agent_process(&app, record, &mut runtimes)?; + } + } + + record.respond_to = prospective_mode; + // Preserve the persisted allowlist across mode toggles — only replace + // when the caller explicitly supplied a new list. + if input.respond_to_allowlist.is_some() { + record.respond_to_allowlist = prospective_allowlist; + } + + record.updated_at = now_iso(); + + save_managed_agents(&app, &records)?; + + let record = records + .iter() + .find(|r| r.pubkey == input.pubkey) + .ok_or_else(|| format!("agent {} not found", input.pubkey))?; + + // Publish the edit to the relay. After-save, inside the lock, before + // any .await. The retention upsert hashes the opt-IN projection, so an + // update that touched only runtime/local fields is a no-op publish. + super::super::agents::retain_managed_agent_pending(&app, &state, record); + + let sync_params = if name_changed { + let agent_keys = Keys::parse(&record.private_key_nsec) + .map_err(|e| format!("failed to parse agent keys: {e}"))?; + // Re-publish the renamed profile to the agent's effective relay: + // an explicit per-agent relay wins; empty falls back to workspace. + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &relay_ws_url_with_override(&state), + ); + let display_name = record.name.clone(); + // Avatar fallback derives from the EFFECTIVE harness (persona-wins), + // not the frozen snapshot, so an inherited harness picks the right + // default avatar. + let personas = load_personas(&app).unwrap_or_default(); + let effective_command = crate::managed_agents::record_agent_command(record, &personas); + let avatar_url = record + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_command)); + let auth_tag = record.auth_tag.clone(); + Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) + } else { + None + }; + + let summary = { + let personas = load_personas(&app).unwrap_or_default(); + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + )? + }; + let rollback = name_changed + .then(|| AgentUpdateRollback::new(previous_record, record, access_policy_changed)); + ( + summary, + sync_params, + rollback, + access_policy_changed, + access_restart_relays, + ) + }; // lock dropped here + + try_regenerate_nest(&app); + + // Phase 2: relay sync (async, outside lock). The owner-signed managed + // policy is security-sensitive: an access reduction must replace the old + // relay head before this command returns rather than waiting for the + // 30-second retention sweep. The flush remains durable/best-effort; rows a + // relay does not accept stay pending for the background retry. + let mut profile_sync_error = + crate::managed_agents::persona_events::flush_active_pending_events(&app, &state) + .await + .err() + .map(|error| format!("managed policy sync failed: {error}")); + if profile_sync_error.is_none() + && crate::managed_agents::persona_events::active_pending_event( + &app, + &state, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + &summary.pubkey, + )? + { + profile_sync_error = Some( + "managed policy sync failed: relay did not accept the updated policy; retry queued" + .to_string(), + ); + } + + // A rename is committed only when profile sync succeeds; otherwise restore + // the complete pre-edit record so Desktop and the relay keep one + // authoritative name. + if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { + if let Err(sync_error) = sync_managed_agent_profile( + &state, + &relay_url, + &agent_keys, + &display_name, + avatar_url.as_deref(), + auth_tag.as_deref(), + ) + .await + { + let rollback = rollback.ok_or_else(|| { + "missing local rollback state after relay profile sync failure".to_string() + })?; + rollback_failed_agent_update(&app, &state, &summary.pubkey, rollback)?; + let restart_suffix = if access_restart_relays.is_empty() { + String::new() + } else { + match super::super::agents::start_local_agent_pairs_with_preflight( + &app, + &state, + &summary.pubkey, + &access_restart_relays, + ) + .await + { + Ok(_) => String::new(), + Err(error) => format!( + " The runtime also failed to restart with the kept access policy: {error}" + ), + } + }; + let rollback_message = if access_policy_changed { + "The access policy change was kept, but other edits were rolled back" + } else { + "No changes were saved" + }; + return Err(format!( + "Agent rename failed because its relay profile could not be updated. {rollback_message}: {sync_error}.{restart_suffix}" + )); + } + } + + if !access_restart_relays.is_empty() { + summary = super::super::agents::start_local_agent_pairs_with_preflight( + &app, + &state, + &summary.pubkey, + &access_restart_relays, + ) + .await + .map_err(|error| { + format!( + "Agent access was saved and published, but its runtime failed to restart with the new policy: {error}" + ) + })?; + } + + Ok(UpdateManagedAgentResponse { + agent: summary, + profile_sync_error: profile_sync_error.take(), + }) +} + +#[cfg(test)] +#[path = "agent_models_update_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_models_update_tests.rs b/desktop/src-tauri/src/commands/agent_models_update_tests.rs new file mode 100644 index 0000000000..b9fd0bd183 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_update_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +fn provider_record(deployed: bool) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = crate::managed_agents::BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + record.backend_agent_id = deployed.then(|| "deployment".to_string()); + record +} + +#[test] +fn deployed_provider_rejects_access_edits_that_cannot_be_revoked() { + let error = ensure_access_policy_change_supported(&provider_record(true), true) + .expect_err("deployed provider access edit must fail closed"); + assert!(error.contains("no explicit stop or revocation acknowledgement")); +} + +#[test] +fn undeployed_provider_accepts_access_edits() { + ensure_access_policy_change_supported(&provider_record(false), true) + .expect("no running provider deployment can retain stale access"); +} diff --git a/desktop/src-tauri/src/commands/agent_update_rollback.rs b/desktop/src-tauri/src/commands/agent_update_rollback.rs index 2745b3cd22..78734797f0 100644 --- a/desktop/src-tauri/src/commands/agent_update_rollback.rs +++ b/desktop/src-tauri/src/commands/agent_update_rollback.rs @@ -11,13 +11,19 @@ use crate::{ pub(super) struct AgentUpdateRollback { attempted_record: ManagedAgentRecord, previous_record: ManagedAgentRecord, + preserve_access_policy: bool, } impl AgentUpdateRollback { - pub(super) fn new(previous_record: ManagedAgentRecord, attempted: &ManagedAgentRecord) -> Self { + pub(super) fn new( + previous_record: ManagedAgentRecord, + attempted: &ManagedAgentRecord, + preserve_access_policy: bool, + ) -> Self { Self { attempted_record: attempted.clone(), previous_record, + preserve_access_policy, } } } @@ -64,6 +70,13 @@ fn restore_agent_update( attempted_with_current_runtime != rollback.attempted_record }; let mut restored = rollback.previous_record; + if rollback.preserve_access_policy { + restored.respond_to = current.respond_to; + restored + .respond_to_allowlist + .clone_from(¤t.respond_to_allowlist); + restored.updated_at.clone_from(¤t.updated_at); + } copy_runtime_state(current, &mut restored); if runtime_changed { restored.updated_at.clone_from(¤t.updated_at); @@ -137,7 +150,7 @@ mod tests { attempted.name = "New name".to_string(); attempted.model = Some("new-model".to_string()); attempted.updated_at = "attempt".to_string(); - let rollback = AgentUpdateRollback::new(previous, &attempted); + let rollback = AgentUpdateRollback::new(previous, &attempted, false); let mut records = vec![attempted]; restore_agent_update(&mut records, "abcd1234", rollback) @@ -148,13 +161,34 @@ mod tests { assert_eq!(records[0].updated_at, "before"); } + #[test] + fn failed_profile_sync_keeps_a_tightened_access_policy() { + let previous = record("Old name", "before"); + let mut attempted = previous.clone(); + attempted.name = "New name".to_string(); + attempted.respond_to = crate::managed_agents::RespondTo::OwnerOnly; + attempted.updated_at = "attempt".to_string(); + let rollback = AgentUpdateRollback::new(previous, &attempted, true); + let mut records = vec![attempted]; + + restore_agent_update(&mut records, "abcd1234", rollback) + .expect("matching attempted update rolls back non-access fields"); + + assert_eq!(records[0].name, "Old name"); + assert_eq!( + records[0].respond_to, + crate::managed_agents::RespondTo::OwnerOnly + ); + assert_eq!(records[0].updated_at, "attempt"); + } + #[test] fn failed_profile_sync_does_not_overwrite_a_newer_agent_update() { let previous = record("Old name", "before"); let mut attempted = previous.clone(); attempted.name = "New name".to_string(); attempted.updated_at = "attempt".to_string(); - let rollback = AgentUpdateRollback::new(previous, &attempted); + let rollback = AgentUpdateRollback::new(previous, &attempted, false); let mut newer = attempted; newer.name = "Newest name".to_string(); newer.updated_at = "newer".to_string(); @@ -175,7 +209,7 @@ mod tests { attempted.name = "New name".to_string(); attempted.model = Some("new-model".to_string()); attempted.updated_at = "attempt".to_string(); - let rollback = AgentUpdateRollback::new(previous, &attempted); + let rollback = AgentUpdateRollback::new(previous, &attempted, false); let mut churned = attempted; churned.runtime_pid = None; churned.last_stopped_at = Some("stopped".to_string()); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 2613671970..3d38f37432 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,15 +6,14 @@ use super::managed_agent_definition::validate_create_definition; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, discover_provider_candidates, - ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - 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, + build_managed_agent_summary, current_instance_id, ensure_persona_is_active, + find_managed_agent_mut, load_managed_agents, load_personas, load_teams, + managed_agent_avatar_url, normalize_agent_args, 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, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -441,72 +440,7 @@ pub(super) async fn start_local_agent_with_preflight( ) } -/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via -/// spawn_blocking, and persists the result (backend_agent_id or last_error). -/// -/// Idempotency: calling deploy on an already-deployed agent sends the same payload -/// again. Providers are expected to handle this as an update-in-place or no-op — -/// the protocol does not include an explicit `undeploy` operation (deferred to v2). -/// -/// Returns Ok(()) on success, Err(message) on failure. Either way the record is -/// updated and saved before returning. -async fn deploy_to_provider( - app: &AppHandle, - state: &AppState, - pubkey: &str, - provider_id: &str, - config: &serde_json::Value, - agent_json: serde_json::Value, - cached_binary_path: Option<&str>, -) -> Result<(), String> { - // Resolve via discovered candidates only. Cached path must match BOTH - // "is a discovered candidate" AND "belongs to this provider_id". A tampered - // record cannot redirect deploys to a different provider's binary. - let bin_path = cached_binary_path - .map(std::path::PathBuf::from) - .filter(|p| p.exists()) - .map(|p| p.canonicalize().unwrap_or(p)) - .filter(|canonical| { - discover_provider_candidates().iter().any(|(id, cp)| { - id == provider_id && cp.canonicalize().ok().as_ref() == Some(canonical) - }) - }) - .map_or_else(|| resolve_provider_binary(provider_id), Ok)?; - - let config_clone = config.clone(); - let deploy_result = - tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))?; - - // Persist result under lock. - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let rec = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - - match deploy_result { - Ok(backend_agent_id) => { - rec.backend_agent_id = Some(backend_agent_id); - rec.last_started_at = Some(now_iso()); - rec.updated_at = now_iso(); - rec.last_error = None; - } - Err(ref e) => { - rec.last_error = Some(e.clone()); - rec.updated_at = now_iso(); - save_managed_agents(app, &records)?; - return Err(e.clone()); - } - } - save_managed_agents(app, &records)?; - Ok(()) -} +pub(crate) use provider_deploy::deploy_to_provider; // Async so the blocking body (disk reads of agent/persona records, per-agent // process-liveness syscalls, and a possible save) runs on Tauri's worker pool @@ -870,6 +804,7 @@ pub async fn create_managed_agent( runtime_pid: None, backend: input.backend.clone(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path, persona_team_dir: None, persona_name_in_team: None, @@ -979,7 +914,7 @@ pub async fn create_managed_agent( &resolved_relay_url, &relay_ws_url_with_override(&state), ); - let profile_sync_error = (sync_managed_agent_profile( + let mut profile_sync_error = (sync_managed_agent_profile( &state, &profile_relay_url, &agent_keys, @@ -989,12 +924,11 @@ pub async fn create_managed_agent( ) .await) .err(); + profile_sync_error = + super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await; - // ── Phase 5: provider deploy (async, outside lock) ─────────────────────── let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { if let BackendKind::Provider { ref id, ref config } = input.backend { - // Read the saved record to build the deploy payload (record has the - // canonical field values after Phase 3 normalization). let agent_json = { let _g = state .managed_agents_store_lock @@ -1354,7 +1288,8 @@ pub async fn delete_managed_agent( #[path = "agents_deploy.rs"] mod deploy; pub(super) mod provider_access; -use deploy::build_deploy_payload; +mod provider_deploy; +pub(super) use deploy::build_deploy_payload; #[cfg(test)] use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 467230e56f..5b90500ee6 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -15,7 +15,9 @@ pub(super) fn needs_reconciliation_with_policy( record: &ManagedAgentRecord, owner_only_access: bool, ) -> bool { - owner_only_access && record.backend != BackendKind::Local && record.backend_agent_id.is_some() + (owner_only_access || record.provider_policy_pending) + && record.backend != BackendKind::Local + && record.backend_agent_id.is_some() } #[derive(Debug)] @@ -50,25 +52,23 @@ fn collect_targets_with( .collect() } -/// Redeploy every existing provider agent in an owner-only access build. +/// Redeploy existing provider agents whose access policy requires enforcement. /// -/// The saved `backend_agent_id` only proves that some provider deployment -/// exists. A marked build sends the current owner-only payload before each -/// community UI load. Workspace apply fails closed if any provider rejects it. +/// Owner-only builds refresh every existing deployment before each community UI +/// load. All builds also retry records whose saved policy has not yet been +/// acknowledged by a successful provider deployment. Workspace apply fails +/// closed if any selected provider rejects the current policy. pub(crate) async fn reconcile_on_workspace_apply( app: &AppHandle, state: &AppState, ) -> Result<(), String> { - if !crate::managed_agents::owner_only_access_build() { - return Ok(()); - } - + let owner_only_access = crate::managed_agents::owner_only_access_build(); let targets = { let _store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - collect_targets_with(load_managed_agents(app)?, true, |record| { + collect_targets_with(load_managed_agents(app)?, owner_only_access, |record| { super::build_deploy_payload(app, state, record) }) }; @@ -110,7 +110,7 @@ pub(crate) async fn reconcile_on_workspace_apply( Ok(()) } -fn persist_failure( +pub(crate) fn persist_failure( app: &AppHandle, state: &AppState, pubkey: &str, @@ -180,17 +180,53 @@ mod tests { } #[test] - fn unmarked_build_collects_no_upgrade_targets() { - let records = vec![record( + fn unmarked_build_collects_only_pending_targets() { + let mut pending = record( BackendKind::Provider { - id: "provider".into(), + id: "pending-provider".into(), config: serde_json::json!({}), }, - Some("existing"), - )]; + Some("existing-pending"), + ); + pending.pubkey = "pending-agent".into(); + pending.provider_policy_pending = true; + let ordinary = record( + BackendKind::Provider { + id: "ordinary-provider".into(), + config: serde_json::json!({}), + }, + Some("existing-ordinary"), + ); + + let targets = collect_targets_with(vec![ordinary, pending], false, |record| { + Ok(serde_json::json!({"pubkey": record.pubkey})) + }); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].pubkey, "pending-agent"); + assert_eq!(targets[0].provider_id, "pending-provider"); + assert_eq!( + targets[0].agent_json.as_ref().unwrap()["pubkey"], + "pending-agent" + ); + } - assert!( - collect_targets_with(records, false, |_| { Ok(serde_json::Value::Null) }).is_empty() + #[test] + fn pending_policy_requires_an_existing_provider_deployment() { + let mut undeployed = record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }, + None, ); + undeployed.provider_policy_pending = true; + let mut local = record(BackendKind::Local, Some("stale-provider-id")); + local.provider_policy_pending = true; + + assert!(collect_targets_with(vec![undeployed, local], false, |_| { + Ok(serde_json::Value::Null) + }) + .is_empty()); } } diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs new file mode 100644 index 0000000000..cdbdd787e7 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -0,0 +1,211 @@ +use std::sync::Arc; + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + discover_provider_candidates, load_managed_agents, provider_deploy, + resolve_provider_binary, save_managed_agents, BackendKind, + }, + util::now_iso, +}; + +use super::build_deploy_payload; + +/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via +/// spawn_blocking, and persists the result (backend_agent_id or last_error). +/// +/// Idempotency: calling deploy on an already-deployed agent sends the same payload +/// again. Providers are expected to handle this as an update-in-place or no-op. +/// The protocol has no explicit `undeploy` operation or acknowledgement that an +/// existing process stopped, so a successful redeploy delegates access-policy +/// revocation semantics to the provider implementation (deferred to v2). +/// Returns Ok(()) on success, Err(message) on failure. Either way the record is +/// updated and saved before returning. +pub(crate) async fn deploy_to_provider( + app: &AppHandle, + state: &AppState, + pubkey: &str, + _provider_id: &str, + _config: &serde_json::Value, + _agent_json: serde_json::Value, + _cached_binary_path: Option<&str>, +) -> Result<(), String> { + let deploy_lock = { + let mut locks = state + .provider_deploy_locks + .lock() + .map_err(|error| error.to_string())?; + Arc::clone( + locks + .entry(pubkey.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))), + ) + }; + let _deploy_guard = deploy_lock.lock().await; + // The payload may have waited behind another deployment. Rebuild it from + // the current record so the final provider invocation always carries the + // newest saved policy rather than the stale snapshot captured by its caller. + let (provider_id, config, cached_binary_path, agent_json) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let (provider_id, config) = match &record.backend { + BackendKind::Provider { id, config } => (id.clone(), config.clone()), + BackendKind::Local => return Err(format!("agent {pubkey} is not provider-backed")), + }; + ( + provider_id, + config, + record.provider_binary_path.clone(), + build_deploy_payload(app, state, record)?, + ) + }; + // Resolve via discovered candidates only. Cached path must match BOTH + // "is a discovered candidate" AND "belongs to this provider_id". A tampered + // record cannot redirect deploys to a different provider's binary. + let bin_path = cached_binary_path + .as_deref() + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .map(|p| p.canonicalize().unwrap_or(p)) + .filter(|canonical| { + discover_provider_candidates().iter().any(|(id, cp)| { + id == &provider_id && cp.canonicalize().ok().as_ref() == Some(canonical) + }) + }) + .map_or_else(|| resolve_provider_binary(&provider_id), Ok)?; + + let deployed_agent_json = agent_json.clone(); + let config_clone = config.clone(); + let deploy_result = + tokio::task::spawn_blocking(move || provider_deploy(&bin_path, &agent_json, &config_clone)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))?; + + // Persist result under lock. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let rec = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + + let result = apply_deploy_result(rec, deploy_result, &deployed_agent_json); + save_managed_agents(app, &records)?; + result +} + +fn policy_matches_payload( + record: &crate::managed_agents::ManagedAgentRecord, + deployed_agent_json: &serde_json::Value, +) -> bool { + deployed_agent_json + .get("respond_to") + .and_then(serde_json::Value::as_str) + == Some(record.respond_to.as_str()) + && deployed_agent_json.get("respond_to_allowlist") + == Some(&serde_json::json!(record.respond_to_allowlist)) +} + +fn apply_deploy_result( + record: &mut crate::managed_agents::ManagedAgentRecord, + deploy_result: Result, + deployed_agent_json: &serde_json::Value, +) -> Result<(), String> { + match deploy_result { + Ok(backend_agent_id) => { + record.backend_agent_id = Some(backend_agent_id); + if policy_matches_payload(record, deployed_agent_json) { + record.provider_policy_pending = false; + } + record.last_started_at = Some(now_iso()); + record.updated_at = now_iso(); + record.last_error = None; + Ok(()) + } + Err(error) => { + record.last_error = Some(error.clone()); + record.updated_at = now_iso(); + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> crate::managed_agents::ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null, + "provider_policy_pending": true + })) + .unwrap() + } + + fn policy_payload(respond_to: &str) -> serde_json::Value { + serde_json::json!({"respond_to": respond_to, "respond_to_allowlist": []}) + } + + #[test] + fn successful_deploy_acknowledges_pending_policy() { + let mut record = record(); + + apply_deploy_result( + &mut record, + Ok("provider-agent".into()), + &policy_payload("owner-only"), + ) + .unwrap(); + + assert!(!record.provider_policy_pending); + assert_eq!(record.backend_agent_id.as_deref(), Some("provider-agent")); + assert_eq!(record.last_error, None); + } + + #[test] + fn successful_stale_deploy_preserves_newer_pending_policy() { + let mut record = record(); + record.respond_to = crate::managed_agents::RespondTo::Anyone; + + apply_deploy_result( + &mut record, + Ok("provider-agent".into()), + &policy_payload("owner-only"), + ) + .unwrap(); + + assert!(record.provider_policy_pending); + } + + #[test] + fn failed_deploy_preserves_pending_policy() { + let mut record = record(); + + let error = apply_deploy_result( + &mut record, + Err("provider unavailable".into()), + &policy_payload("owner-only"), + ) + .expect_err("deployment should fail"); + + assert_eq!(error, "provider unavailable"); + assert!(record.provider_policy_pending); + assert_eq!(record.last_error.as_deref(), Some("provider unavailable")); + } +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d4..483eb60134 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -121,7 +121,7 @@ pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result } /// Build the standard agent JSON payload for provider deploy calls. -pub(super) fn build_deploy_payload( +pub(crate) fn build_deploy_payload( app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f550a72e0c..0176478bc5 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -34,6 +34,7 @@ fn bare_agent_record( runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -625,6 +626,11 @@ fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_b &record, false )); + record.provider_policy_pending = true; + assert!(provider_access::needs_reconciliation_with_policy( + &record, false + )); + record.backend_agent_id = None; assert!(!provider_access::needs_reconciliation_with_policy( &record, true diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..f4590f5d6e 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -42,6 +42,7 @@ fn make_agent( runtime_pid, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index cbb2314353..5c38373f7c 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -17,6 +17,21 @@ use crate::{ #[cfg(test)] mod inbound_tests; +#[derive(Debug)] +enum InboundRuntimeRefresh { + Local { + pubkey: String, + relay_urls: Vec, + }, + Provider { + pubkey: String, + provider_id: String, + config: serde_json::Value, + cached_binary_path: Option, + agent_json: Result, + }, +} + /// Apply an inbound kind:30175 persona event from the relay onto the local /// store. The frontend's live subscription invokes this per event for our own /// authored coordinate so Device B inherits Device A's edits. @@ -57,23 +72,84 @@ pub async fn reconcile_inbound_persona_event( arrival_relay_url: String, app: AppHandle, ) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, app) + let blocking_app = app.clone(); + let restart = tokio::task::spawn_blocking(move || { + reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, blocking_app) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + match restart { + Some(InboundRuntimeRefresh::Local { pubkey, relay_urls }) => { + let state = app.state::(); + super::super::agents::start_local_agent_pairs_with_preflight( + &app, + &state, + &pubkey, + &relay_urls, + ) + .await + .map_err(|error| { + format!( + "Inbound agent access was saved, but its runtime failed to restart with the new policy: {error}" + ) + })?; + } + Some(InboundRuntimeRefresh::Provider { + pubkey, + provider_id, + config, + cached_binary_path, + agent_json, + }) => { + let state = app.state::(); + let agent_json = match agent_json { + Ok(agent_json) => agent_json, + Err(error) => { + let message = format!( + "Inbound agent access was saved, but its provider deployment could not be refreshed safely: {error}" + ); + super::super::agents::provider_access::persist_failure( + &app, &state, &pubkey, &message, + )?; + let _ = app.emit("agents-data-changed", ()); + return Err(message); + } + }; + super::super::agents::deploy_to_provider( + &app, + &state, + &pubkey, + &provider_id, + &config, + agent_json, + cached_binary_path.as_deref(), + ) + .await + .map_err(|error| { + format!( + "Inbound agent access was saved, but its provider deployment failed to refresh with the new policy: {error}" + ) + })?; + } + None => {} + } + Ok(()) } fn reconcile_inbound_persona_event_blocking( event_json: String, arrival_relay_url: String, app: AppHandle, -) -> Result<(), String> { +) -> Result, String> { use crate::managed_agents::{ agent_events::managed_agent_content_from_event, load_managed_agents, load_teams, persona_events::persona_from_event, - retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, + retention::{ + inbound_event_outcome, open_retention_db, retain_inbound_event, InboundOutcome, + RetainedEvent, + }, save_managed_agents, save_teams, team_events::team_content_from_event, }; @@ -93,11 +169,12 @@ fn reconcile_inbound_persona_event_blocking( // in its `a` tag (`::`). Handled before the // upsert dispatch because its coordinate and retention key differ. if kind == KIND_DELETION { - return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state); + reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state)?; + return Ok(None); } if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { - return Ok(()); + return Ok(None); } // The d-tag identifies the record within its kind. Persona derives it from @@ -137,25 +214,35 @@ fn reconcile_inbound_persona_event_blocking( &arrival_relay_url, )? else { - return Ok(()); + return Ok(None); }; let conn = open_retention_db(&scope.db_path)?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind, - pubkey: event.pubkey.to_hex(), - d_tag: d_tag.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); + let inbound_retained_event = RetainedEvent { + kind, + pubkey: event.pubkey.to_hex(), + d_tag: d_tag.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }; + // Managed-agent access changes can fail while stopping a runtime. Preflight + // the retention decision now, but do not advance the durable head until the + // local store has been saved; otherwise replay sees the failed revocation as + // already consumed and can never retry it. Persona/team paths retain first + // as before because they have no fallible runtime transition. + if kind == KIND_MANAGED_AGENT + && inbound_event_outcome(&conn, &inbound_retained_event)? == InboundOutcome::Skipped + { + return Ok(None); + } + if kind != KIND_MANAGED_AGENT + && retain_inbound_event(&conn, &inbound_retained_event)? == InboundOutcome::Skipped + { + return Ok(None); } + let mut runtime_refresh = None; match kind { KIND_PERSONA => { let mut personas = load_personas(&app)?; @@ -176,8 +263,65 @@ fn reconcile_inbound_persona_event_blocking( let managed_agent = inbound_managed_agent.ok_or_else(|| { "managed-agent content was not parsed before retention".to_string() })?; - apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent); + let access_changed = apply_inbound_managed_agent(&mut agents, &d_tag, managed_agent); + if access_changed { + let record = agents + .iter_mut() + .find(|record| record.pubkey == d_tag) + .ok_or_else(|| format!("agent {d_tag} disappeared during inbound apply"))?; + match &record.backend { + crate::managed_agents::BackendKind::Local => { + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let mut relay_urls = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &d_tag) + .into_iter() + .map(|key| key.relay_url) + .collect::>(); + if relay_urls.is_empty() && record.runtime_pid.is_some() { + relay_urls.push(crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + )); + } + if !relay_urls.is_empty() { + crate::managed_agents::stop_managed_agent_process( + &app, + record, + &mut runtimes, + )?; + runtime_refresh = Some(InboundRuntimeRefresh::Local { + pubkey: d_tag.clone(), + relay_urls, + }); + } + } + crate::managed_agents::BackendKind::Provider { id, config } + if record.backend_agent_id.is_some() => + { + // Persist the unacknowledged policy transition in the + // same write as the narrowed policy. If the process + // exits before or during deployment, workspace apply + // can still recover it in every build. + record.provider_policy_pending = true; + runtime_refresh = Some(InboundRuntimeRefresh::Provider { + pubkey: d_tag.clone(), + provider_id: id.clone(), + config: config.clone(), + cached_binary_path: record.provider_binary_path.clone(), + agent_json: super::super::agents::build_deploy_payload( + &app, &state, record, + ), + }); + } + crate::managed_agents::BackendKind::Provider { .. } => {} + } + } save_managed_agents(&app, &agents)?; + let outcome = retain_inbound_event(&conn, &inbound_retained_event)?; + debug_assert_eq!(outcome, InboundOutcome::Applied); } _ => unreachable!("kind gated above"), } @@ -187,7 +331,7 @@ fn reconcile_inbound_persona_event_blocking( // land on disk silently, leaving the Agents tab stale until restart. let _ = app.emit("agents-data-changed", ()); - Ok(()) + Ok(runtime_refresh) } fn validate_inbound_persona_definition(persona: &AgentDefinition) -> Result<(), String> { @@ -409,8 +553,10 @@ fn apply_inbound_managed_agent( agents: &mut [ManagedAgentRecord], d_tag: &str, inbound: ManagedAgentEventContent, -) { +) -> bool { if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { + let previous_mode = local.respond_to; + let previous_allowlist = local.respond_to_allowlist.clone(); local.name = inbound.name; // Mirror of the slimmed writer (agent_event_content): a // definition-linked event omits the definition quad because those @@ -428,7 +574,14 @@ fn apply_inbound_managed_agent( local.parallelism = inbound.parallelism; local.respond_to = inbound.respond_to; local.respond_to_allowlist = inbound.respond_to_allowlist; + return super::super::agent_models::managed_agent_access_policy_changed( + previous_mode, + &previous_allowlist, + local.respond_to, + &local.respond_to_allowlist, + ); } + false } /// Merge an inbound kind:30176 team projection into the local set. diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e65973f149..e7834f8231 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -188,6 +188,7 @@ fn local_agent() -> ManagedAgentRecord { config: serde_json::json!({ "api_key": "localproviderkey" }), }, backend_agent_id: Some("local-remote-id".to_string()), + provider_policy_pending: false, provider_binary_path: Some("/local/bin".to_string()), team_id: None, persona_team_dir: None, @@ -262,8 +263,9 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() { let content = crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap(); let mut agents = vec![local_agent()]; - apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); + let access_changed = apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); + assert!(access_changed, "Anyone must trigger a runtime refresh"); let a = &agents[0]; // Secrets / harness / runtime — every one preserved from the local record. assert_eq!( diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7b..6d7a2e6264 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -39,6 +39,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..7d3fd95ff3 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -626,6 +626,7 @@ pub async fn confirm_agent_snapshot_import( runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..5e8cea52e6 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -48,6 +48,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..72bdca7de9 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -31,6 +31,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..8315f39a36 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -579,6 +579,7 @@ pub async fn confirm_team_snapshot_import( runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: Some(imported_team.id.clone()), persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..a466228160 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -206,6 +206,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: Some("t1".to_string()), persona_team_dir: None, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6f3f48f3a8..b00d61521b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -47,6 +47,8 @@ mod util; pub mod webkit_rendering; use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; +#[doc(hidden)] +pub use commands::print_agent_access_owner_only_probe_if_requested; use commands::*; use deep_link::{ acknowledge_pending_community_deep_link, acknowledge_pending_entity_deep_link, diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index ebcc127683..3606272e59 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,6 +2,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if buzz_lib::print_agent_access_owner_only_probe_if_requested() { + return; + } + // Before anything else: WebKitGTK reads its rendering environment once at // process start, and this is the only point where the process is still // single threaded and no GTK object exists yet, which is what makes diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 416b0c76c9..f70c714323 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -193,6 +193,7 @@ mod tests { config: serde_json::json!({ "api_key": "sk-provider-secret" }), }, backend_agent_id: Some("remote-id".to_string()), + provider_policy_pending: false, provider_binary_path: Some("/path/to/binary".to_string()), team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073..751452aa7b 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -389,6 +389,7 @@ mod tests { runtime_pid: None, backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..fca15111d0 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -47,6 +47,7 @@ fn minimal_record() -> ManagedAgentRecord { config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), }, backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear + provider_policy_pending: false, provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e..0e7070724d 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -88,6 +88,7 @@ fn test_record() -> ManagedAgentRecord { runtime_pid: None, backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..e53c9114ab 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -255,6 +255,7 @@ fn record_with( runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -316,8 +317,6 @@ fn record_agent_command_bare_record_defaults() { assert_eq!(record_agent_command(&record, &[]), default_agent_command()); } -// ── try_record_agent_command ───────────────────────────────────────────────── - /// When the record carries a dangling (unknown) runtime id, `try_record_agent_command` /// must return `Err` containing "DANGLING_HARNESS_ID" — NEVER the buzz-agent default. /// This test would fail if the function silently fell back to `default_agent_command()`. diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809c..ee18e554c3 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -64,6 +64,7 @@ fn record( runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..b2a56870c7 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -324,6 +324,7 @@ fn bare_record() -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6f..67cdb5fbaf 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -474,6 +474,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b1..a6a50540bb 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -89,6 +89,7 @@ mod tests { runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index de396f45c0..7a3ce35b03 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -247,7 +247,22 @@ pub async fn flush_active_pending_events( flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await } -async fn flush_pending_events_at( +pub fn active_pending_event( + app: &tauri::AppHandle, + state: &AppState, + kind: u32, + d_tag: &str, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = crate::managed_agents::retention::open_retention_db(&scope.db_path)?; + Ok( + crate::managed_agents::retention::get_retained_event(&conn, kind, &owner_pubkey, d_tag)? + .is_some_and(|event| event.pending_sync), + ) +} + +pub(crate) async fn flush_pending_events_at( db_path: &std::path::Path, state: &AppState, relay_url: &str, diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 0580b12ce2..682fbef62f 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -31,6 +31,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff1..c005510907 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1503,6 +1503,7 @@ mod tests { runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -1546,8 +1547,6 @@ mod tests { ); } - // ── provider-specific model fallback tests ──────────────────────────── - #[test] fn buzz_agent_databricks_v2_with_databricks_model_but_no_buzz_agent_model_is_ready() { // The baked buzz-releases env sets DATABRICKS_MODEL but not BUZZ_AGENT_MODEL. diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..e6231bbe42 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -261,21 +261,32 @@ pub enum InboundOutcome { /// pending row intact so the flush republishes and the relay resolves /// last-writer-wins. (A re-received echo at equal time is also a no-op.) /// - Inbound older: skip — nothing to change. -pub fn retain_inbound_event( +/// +/// Decide whether an inbound event is newer than the retained coordinate without +/// mutating retention. Callers that must update another durable store first use +/// this preflight, apply that store change, and only then commit with +/// [`retain_inbound_event`]. +pub fn inbound_event_outcome( conn: &Connection, event: &RetainedEvent, ) -> Result { let existing = get_retained_event(conn, event.kind, &event.pubkey, &event.d_tag)?; - - let apply = match &existing { - None => true, - Some(row) if event.created_at > row.created_at => true, + Ok(match existing { + None => InboundOutcome::Applied, + Some(row) if event.created_at > row.created_at => InboundOutcome::Applied, // Equal or older: skip. Equal time may collide with a pending local // edit, so we never clear its `pending_sync`; older is stale. - Some(_) => false, - }; + Some(_) => InboundOutcome::Skipped, + }) +} - if !apply { +pub fn retain_inbound_event( + conn: &Connection, + event: &RetainedEvent, +) -> Result { + let outcome = inbound_event_outcome(conn, event)?; + + if outcome == InboundOutcome::Skipped { return Ok(InboundOutcome::Skipped); } @@ -553,6 +564,37 @@ mod tests { } } + #[test] + fn inbound_preflight_does_not_consume_event_before_commit() { + let conn = test_db(); + let mut inbound = sample_event(); + inbound.pending_sync = false; + + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert!( + get_retained_event(&conn, inbound.kind, &inbound.pubkey, &inbound.d_tag) + .unwrap() + .is_none() + ); + // A failed store/runtime apply can replay the same head because the + // preflight did not advance retention. + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + retain_inbound_event(&conn, &inbound).unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + inbound_event_outcome(&conn, &inbound).unwrap(), + InboundOutcome::Skipped + ); + } + #[test] fn retain_and_retrieve() { let conn = test_db(); diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 9836d983ed..792a275b05 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -62,6 +62,7 @@ pub(super) fn fixture( runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, 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 1ceeee372f..20e02871eb 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -43,6 +43,7 @@ fn record() -> ManagedAgentRecord { runtime_pid: None, backend: Default::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..5073d9c407 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -283,6 +283,7 @@ mod tests { runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..d66e68979c 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -190,6 +190,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, persona_team_dir: None, persona_name_in_team: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed..3b0641cb67 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -125,6 +125,7 @@ impl AgentDefinition { runtime_pid: None, backend: BackendKind::default(), backend_agent_id: None, + provider_policy_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -196,6 +197,8 @@ impl ManagedAgentRecord { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayAgentInfo { pub pubkey: String, + #[serde(default)] + pub owner_pubkey: Option, pub name: String, pub agent_type: String, pub channels: Vec, @@ -245,13 +248,9 @@ pub struct ManagedAgentRecord { pub avatar_url: Option, pub acp_command: String, pub agent_command: String, - /// Explicit per-instance harness pin. `None` (the default) means inherit - /// the harness from the linked persona's `runtime`, so persona harness - /// edits propagate on the next spawn — mirroring the opt-in `model` - /// override. `Some` is set only when the user deliberately picks a harness - /// that diverges from the persona. Resolved via `effective_agent_command`; - /// `agent_command` above is the create-time snapshot kept for avatar/legacy - /// derivations and is not authoritative for spawn. + /// Explicit per-instance harness pin; `None` inherits the persona runtime. + /// The effective command is resolved at spawn; `agent_command` is a legacy + /// create-time snapshot. #[serde(default)] pub agent_command_override: Option, pub agent_args: Vec, @@ -321,6 +320,8 @@ pub struct ManagedAgentRecord { #[serde(default)] pub backend_agent_id: Option, #[serde(default)] + pub provider_policy_pending: bool, + #[serde(default)] pub provider_binary_path: Option, /// Installed team directory path (absolute). Set when agent was created from a team persona. #[serde( diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b524..0ae584e4ac 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -442,6 +442,21 @@ fn managed_agent_record_without_key_deserializes_empty() { .expect("keyring-backed record without inline key should deserialize"); assert_eq!(record.private_key_nsec, ""); + assert!( + !record.provider_policy_pending, + "pre-pending stores must deserialize as acknowledged" + ); +} + +#[test] +fn pending_provider_policy_round_trips() { + let mut record = sample_agent_record(); + record.provider_policy_pending = true; + + let json = serde_json::to_string(&record).expect("serialize pending policy"); + let reloaded: ManagedAgentRecord = serde_json::from_str(&json).expect("reload pending policy"); + + assert!(reloaded.provider_policy_pending); } fn sample_agent_record() -> ManagedAgentRecord { diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..64c8df05a7 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -495,6 +495,15 @@ pub fn agents_from_events(events: &[Event]) -> Value { json!({ "agents": arr }) } +// ── kind:0 + kind:30177 managed-agent directory ──────────────────────────── + +mod agent_directory; +pub use agent_directory::{ + managed_agent_pubkeys_from_events, member_agent_channel_ids_from_events, + relay_agents_from_directory_events, relay_agents_from_managed_agent_events, + verified_agent_owners_from_profiles, +}; + // ── kind:13534 (relay membership list) ────────────────────────────────────── /// Convert a kind:13534 relay membership list to the relay members format. @@ -578,434 +587,4 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) { } #[cfg(test)] -mod tests { - use super::*; - use nostr::{EventBuilder, Keys, Kind, Tag}; - - /// Build a signed event for testing with the given kind, content, and tags. - fn ev(kind: u16, content: &str, tags: Vec>) -> Event { - let keys = Keys::generate(); - let parsed: Vec = tags - .into_iter() - .map(|t| Tag::parse(t).expect("parse tag")) - .collect(); - EventBuilder::new(Kind::from_u16(kind), content) - .tags(parsed) - .sign_with_keys(&keys) - .expect("sign") - } - - /// Build a kind:0 profile with a valid NIP-OA auth tag. - fn oa_profile_event(content: &str) -> (Event, String) { - let agent_keys = Keys::generate(); - let owner_keys = Keys::generate(); - let agent_pubkey = agent_keys.public_key(); - let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, "") - .expect("compute auth tag"); - let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag json"); - let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); - - let event = EventBuilder::new(Kind::Metadata, content) - .tags(vec![auth_tag]) - .sign_with_keys(&agent_keys) - .expect("sign"); - (event, owner_keys.public_key().to_hex()) - } - - #[test] - fn channel_info_minimal() { - let e = ev( - 39000, - "", - vec![ - vec!["d", "chan-uuid-1"], - vec!["name", "general"], - vec!["about", "main channel"], - vec!["t", "stream"], - vec!["public"], - ], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.id, "chan-uuid-1"); - assert_eq!(info.name, "general"); - assert_eq!(info.description, "main channel"); - assert_eq!(info.channel_type, "stream"); - assert_eq!(info.visibility, "open"); - assert_eq!(info.member_count, 0); - assert!(info.is_member); - } - - #[test] - fn channel_info_private_when_visibility_tag_present() { - let e = ev( - 39000, - "", - vec![ - vec!["d", "u"], - vec!["name", "n"], - vec!["t", "forum"], - vec!["visibility", "private"], - vec!["ttl", "86400"], - ], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.visibility, "private"); - assert_eq!(info.channel_type, "forum"); - assert_eq!(info.ttl_seconds, Some(86400)); - } - - #[test] - fn channel_info_open_when_neither_public_nor_private() { - // Neither tag present → open (matches NIP-29 default). - let e = ev( - 39000, - "", - vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.visibility, "open"); - } - - #[test] - fn channel_info_dm_inferred_from_hidden_tag() { - // Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs. - let e = ev( - 39000, - "", - vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]], - ); - let info = channel_info_from_event(&e, None, None).unwrap(); - assert_eq!(info.channel_type, "dm"); - } - - #[test] - fn channel_info_merges_summary() { - let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]); - let summary = ev( - 40901, - r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#, - vec![vec!["d", "u"]], - ); - let info = channel_info_from_event(&chan, Some(&summary), None).unwrap(); - assert_eq!(info.member_count, 7); - assert_eq!( - info.last_message_at.as_deref(), - Some("2026-01-01T00:00:00Z") - ); - } - - #[test] - fn channel_info_missing_d_errors() { - let e = ev(39000, "", vec![vec!["name", "n"]]); - assert!(channel_info_from_event(&e, None, None).is_err()); - } - - #[test] - fn channel_detail_basic() { - let e = ev( - 39000, - "", - vec![ - vec!["d", "uuid"], - vec!["name", "n"], - vec!["about", "desc"], - vec!["topic", "tt"], - vec!["purpose", "pp"], - vec!["t", "dm"], - vec!["visibility", "private"], - vec!["ttl", "86400"], - vec!["ttl_deadline", "2026-06-11T00:00:00Z"], - ], - ); - let d = channel_detail_from_event(&e).unwrap(); - assert_eq!(d.id, "uuid"); - assert_eq!(d.topic.as_deref(), Some("tt")); - assert_eq!(d.purpose.as_deref(), Some("pp")); - assert_eq!(d.channel_type, "dm"); - assert_eq!(d.visibility, "private"); - assert_eq!(d.ttl_seconds, Some(86400)); - assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z")); - assert!(d.created_at.ends_with("Z")); - assert_eq!(d.created_by, e.pubkey.to_hex()); - } - - #[test] - fn channel_members_extracts_p_tags() { - let pk1 = "a".repeat(64); - let pk2 = "b".repeat(64); - let e = ev( - 39002, - "", - vec![ - vec!["d", "uuid"], - vec!["p", &pk1, "", "admin"], - vec!["p", &pk2], - // Duplicate must be deduped. - vec!["p", &pk1, "wss://x", "owner"], - ], - ); - let r = channel_members_from_event(&e).unwrap(); - assert_eq!(r.members.len(), 2); - assert_eq!(r.members[0].pubkey, pk1); - assert_eq!(r.members[0].role, "admin"); - assert!(r.members[0].joined_at.is_none()); - assert_eq!(r.members[1].role, "member"); // default - } - - #[test] - fn channel_members_missing_d_errors() { - let e = ev(39002, "", vec![]); - assert!(channel_members_from_event(&e).is_err()); - } - - #[test] - fn profile_info_parses_content() { - let e = ev( - 0, - r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#, - vec![], - ); - let p = profile_info_from_event(&e).unwrap(); - assert_eq!(p.display_name.as_deref(), Some("Alice")); - assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png")); - assert_eq!(p.about.as_deref(), Some("hi")); - assert_eq!(p.nip05_handle.as_deref(), Some("alice@x")); - assert_eq!(p.pubkey, e.pubkey.to_hex()); - assert!(p.owner_pubkey.is_none()); - } - - #[test] - fn profile_info_extracts_valid_nip_oa_owner() { - let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); - let p = profile_info_from_event(&event).unwrap(); - - assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); - } - - #[test] - fn profile_info_falls_back_to_name() { - let e = ev(0, r#"{"name":"bob"}"#, vec![]); - let p = profile_info_from_event(&e).unwrap(); - assert_eq!(p.display_name.as_deref(), Some("bob")); - } - - #[test] - fn profile_info_invalid_json_errors() { - let e = ev(0, "not-json", vec![]); - assert!(profile_info_from_event(&e).is_err()); - } - - #[test] - fn users_batch_keeps_latest_and_reports_missing() { - let e1 = ev(0, r#"{"name":"old"}"#, vec![]); - // Same author, newer event with display_name. - let keys = Keys::generate(); - let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#) - .custom_created_at(nostr::Timestamp::from(1000)) - .sign_with_keys(&keys) - .unwrap(); - let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#) - .custom_created_at(nostr::Timestamp::from(2000)) - .sign_with_keys(&keys) - .unwrap(); - let pk = keys.public_key().to_hex(); - let other_pk = e1.pubkey.to_hex(); - - let missing_pk = "f".repeat(64); - let resp = users_batch_from_events( - &[e1, e_old, e_new], - &[pk.clone(), other_pk.clone(), missing_pk.clone()], - ); - assert_eq!(resp.profiles.len(), 2); - assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New")); - assert_eq!(resp.missing, vec![missing_pk]); - } - - #[test] - fn users_batch_marks_valid_nip_oa_profiles_as_agents() { - let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); - let pubkey = agent.pubkey.to_hex(); - let resp = - users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey)); - - assert!(resp.profiles[&pubkey].is_agent); - assert_eq!( - resp.profiles[&pubkey].owner_pubkey.as_deref(), - Some(owner_pubkey.as_str()) - ); - } - - #[test] - fn user_notes_builds_cursor_from_last() { - let e1 = ev(1, "first", vec![]); - let e2 = ev(1, "second", vec![]); - let r = user_notes_from_events(&[e1, e2]); - assert_eq!(r.notes.len(), 2); - assert_eq!(r.notes[0].content, "first"); - let cursor = r.next_cursor.expect("cursor"); - assert_eq!(cursor.before_id, r.notes[1].id); - } - - #[test] - fn user_notes_empty_has_no_cursor() { - let r = user_notes_from_events(&[]); - assert!(r.notes.is_empty()); - assert!(r.next_cursor.is_none()); - } - - #[test] - fn contact_list_preserves_tags_and_content() { - let pk = "1".repeat(64); - let e = ev(3, "rel-json", vec![vec!["p", &pk]]); - let r = contact_list_from_event(&e).unwrap(); - assert_eq!(r.content, "rel-json"); - assert_eq!(r.tags.len(), 1); - assert_eq!(r.tags[0], vec!["p".to_string(), pk]); - } - - #[test] - fn search_response_assigns_descending_scores() { - let e1 = ev(1, "one", vec![vec!["h", "chan"]]); - let e2 = ev(1, "two", vec![]); - let r = search_response_from_events(&[e1, e2]); - assert_eq!(r.found, 2); - assert!(r.hits[0].score > r.hits[1].score); - assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan")); - assert!(r.hits[1].channel_id.is_none()); - } - - #[test] - fn search_response_single_hit_full_score() { - let e = ev(1, "only", vec![]); - let r = search_response_from_events(&[e]); - assert_eq!(r.hits.len(), 1); - assert_eq!(r.hits[0].score, 1.0); - } - - #[test] - fn agents_overwrites_pubkey_from_event_author() { - let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let arr = v.get("agents").and_then(Value::as_array).unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!( - arr[0].get("pubkey").and_then(Value::as_str).unwrap(), - e.pubkey.to_hex() - ); - assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1")); - } - - #[test] - fn agents_handles_invalid_content() { - let e = ev(10100, "not-json", vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let arr = v.get("agents").and_then(Value::as_array).unwrap(); - assert_eq!( - arr[0].get("pubkey").and_then(Value::as_str).unwrap(), - e.pubkey.to_hex() - ); - } - - #[test] - fn agents_default_sparse_agent_profiles_for_directory_parse() { - let e = ev( - 10100, - r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#, - vec![], - ); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].pubkey, e.pubkey.to_hex()); - assert_eq!(parsed[0].name, "Scout"); - assert_eq!(parsed[0].agent_type, "agent"); - assert_eq!(parsed[0].channels, Vec::::new()); - assert_eq!(parsed[0].capabilities, Vec::::new()); - assert_eq!(parsed[0].status, "offline"); - assert_eq!(parsed[0].respond_to, None); - } - - #[test] - fn agents_preserves_public_respond_to_mode_for_directory_parse() { - let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].respond_to, - Some(crate::managed_agents::RespondTo::Anyone) - ); - } - - #[test] - fn agents_preserves_allowlist_metadata_for_directory_parse() { - let e = ev( - 10100, - r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, - vec![], - ); - let v = agents_from_events(std::slice::from_ref(&e)); - let agents = v.get("agents").cloned().unwrap(); - let parsed: Vec = - serde_json::from_value(agents).unwrap(); - - assert_eq!(parsed.len(), 1); - assert_eq!( - parsed[0].respond_to, - Some(crate::managed_agents::RespondTo::Allowlist) - ); - assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]); - } - - #[test] - fn relay_members_dedupes_and_defaults_role() { - let pk1 = "a".repeat(64); - let pk2 = "b".repeat(64); - // Current relay format: ["member", pubkey, role] - let e = ev( - 13534, - "", - vec![ - vec!["member", &pk1, "owner"], - vec!["member", &pk2], - vec!["member", &pk1, "moderator"], // dupe — ignored - ], - ); - let v = relay_members_from_event(&e); - let arr = v.get("members").and_then(Value::as_array).unwrap(); - assert_eq!(arr.len(), 2); - assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner")); - assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); - } - - #[test] - fn relay_members_fallback_p_tags() { - let pk1 = "a".repeat(64); - let pk2 = "b".repeat(64); - // Legacy/fallback format: ["p", pubkey, relay_url?, role?] - let e = ev( - 13534, - "", - vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]], - ); - let v = relay_members_from_event(&e); - let arr = v.get("members").and_then(Value::as_array).unwrap(); - assert_eq!(arr.len(), 2); - assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin")); - assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); - } - - #[test] - fn timestamp_to_iso_known_value() { - // 2021-01-01T00:00:00Z = 1609459200 - assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z"); - // Epoch - assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); - } -} +mod tests; diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs new file mode 100644 index 0000000000..28604de5e5 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -0,0 +1,191 @@ +//! Conversion and verification for relay-discovered agents. + +use std::collections::{BTreeSet, HashMap}; + +use nostr::Event; + +use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo}; + +use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, tags_named}; + +/// Collect valid agent pubkeys from kind:30177 `d` tags for follow-up relay +/// queries. Malformed tags are ignored so one hostile event cannot invalidate +/// the whole directory request. +pub fn managed_agent_pubkeys_from_events(events: &[Event]) -> std::collections::HashSet { + events + .iter() + .filter_map(|event| first_tag_value(event, "d")) + .filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok()) + .map(|pubkey| pubkey.to_hex()) + .collect() +} + +fn event_is_newer(candidate: &Event, previous: &Event) -> bool { + candidate.created_at > previous.created_at + || (candidate.created_at == previous.created_at && candidate.id < previous.id) +} + +fn relay_agents_from_legacy_events(events: &[Event]) -> Vec { + let mut latest: HashMap = HashMap::new(); + for event in events { + let pubkey = event.pubkey.to_hex(); + if latest + .get(&pubkey) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest.insert(pubkey, event); + } + } + + latest + .into_values() + .filter_map(|event| { + let value = agents_from_events(std::slice::from_ref(event)); + let mut agent: RelayAgentInfo = + serde_json::from_value(value.get("agents")?.as_array()?.first()?.clone()).ok()?; + // Legacy directory entries are not authenticated managed-policy + // coordinates, so they must not drive the live 30177 watcher. + agent.owner_pubkey = None; + // Channel membership is authoritative only in relay-signed kind:39002. + agent.channel_ids.clear(); + Some(agent) + }) + .collect() +} + +/// Merge self-authored kind:10100 runtime profiles with verified Desktop-managed +/// policy records. A verified managed coordinate reserves the agent identity even +/// when its current policy is malformed, so stale legacy permissions cannot win. +pub fn relay_agents_from_directory_events( + directory_events: &[Event], + managed_agent_events: &[Event], + profile_events: &[Event], +) -> Vec { + let verified_policies = latest_verified_managed_policies(managed_agent_events, profile_events); + let mut agents: HashMap = + relay_agents_from_legacy_events(directory_events) + .into_iter() + .map(|agent| (agent.pubkey.clone(), agent)) + .collect(); + for agent_pubkey in verified_policies.keys() { + agents.remove(agent_pubkey); + } + for (agent_pubkey, event) in verified_policies { + if let Some(agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + agents.insert(agent_pubkey, agent); + } + } + + let mut agents: Vec<_> = agents.into_values().collect(); + agents.sort_by(|left, right| left.name.cmp(&right.name)); + agents +} + +/// Resolve each agent's owner from its latest signed NIP-OA profile. +pub fn verified_agent_owners_from_profiles(events: &[Event]) -> HashMap { + let mut latest_profiles: HashMap = HashMap::new(); + for profile in events { + let agent_pubkey = profile.pubkey.to_hex(); + if latest_profiles + .get(&agent_pubkey) + .is_none_or(|previous| event_is_newer(profile, previous)) + { + latest_profiles.insert(agent_pubkey, profile); + } + } + latest_profiles + .into_iter() + .filter_map(|(agent_pubkey, profile)| { + profile_valid_oa_owner_pubkey(profile).map(|owner| (agent_pubkey, owner)) + }) + .collect() +} + +fn latest_verified_managed_policies<'a>( + managed_agent_events: &'a [Event], + profile_events: &[Event], +) -> HashMap { + let verified_owners = verified_agent_owners_from_profiles(profile_events); + + let mut latest: HashMap = HashMap::new(); + for event in managed_agent_events { + let Some(agent_pubkey) = first_tag_value(event, "d") else { + continue; + }; + if verified_owners.get(agent_pubkey) != Some(&event.pubkey.to_hex()) { + continue; + } + if latest + .get(agent_pubkey) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest.insert(agent_pubkey.to_string(), event); + } + } + latest +} + +fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option { + let content = managed_agent_content_from_event(event).ok()?; + Some(RelayAgentInfo { + pubkey: agent_pubkey.to_string(), + owner_pubkey: Some(event.pubkey.to_hex()), + name: content.name, + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "offline".to_string(), + respond_to: Some(content.respond_to), + respond_to_allowlist: content.respond_to_allowlist, + }) +} + +/// Build the relay agent directory from owner-authenticated managed-agent +/// records. A kind:30177 event is accepted only when its author matches the +/// owner cryptographically declared by the agent's latest kind:0 NIP-OA tag. +pub fn relay_agents_from_managed_agent_events( + managed_agent_events: &[Event], + profile_events: &[Event], +) -> Vec { + let mut agents: Vec<_> = latest_verified_managed_policies(managed_agent_events, profile_events) + .into_iter() + .filter_map(|(agent_pubkey, event)| relay_agent_from_managed_policy(&agent_pubkey, event)) + .collect(); + agents.sort_by(|left, right| left.name.cmp(&right.name)); + agents +} + +/// Build a pubkey-to-channel-id candidate map from relay-signed membership +/// events. Only p-tags explicitly marked with the `bot` role are agents. +pub fn member_agent_channel_ids_from_events( + events: &[Event], + relay_pubkey: &str, +) -> HashMap> { + let mut channel_ids: HashMap> = HashMap::new(); + for event in events { + if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) { + continue; + } + let Some(channel_id) = first_tag_value(event, "d") else { + continue; + }; + for tag in tags_named(event, "p") { + let (Some(pubkey), Some(role)) = (tag.get(1), tag.get(3)) else { + continue; + }; + if role != "bot" || nostr::PublicKey::from_hex(pubkey).is_err() { + continue; + } + channel_ids + .entry(pubkey.clone()) + .or_default() + .insert(channel_id.to_string()); + } + } + + channel_ids + .into_iter() + .map(|(pubkey, ids)| (pubkey, ids.into_iter().collect())) + .collect() +} diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs new file mode 100644 index 0000000000..9401d19add --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -0,0 +1,762 @@ +//! Tests for the Nostr conversion surface. + +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag}; + +/// Build a signed event for testing with the given kind, content, and tags. +fn ev(kind: u16, content: &str, tags: Vec>) -> Event { + let keys = Keys::generate(); + let parsed: Vec = tags + .into_iter() + .map(|t| Tag::parse(t).expect("parse tag")) + .collect(); + EventBuilder::new(Kind::from_u16(kind), content) + .tags(parsed) + .sign_with_keys(&keys) + .expect("sign") +} + +/// Build a kind:0 profile with a valid NIP-OA auth tag. +fn oa_profile_event(content: &str) -> (Event, String) { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("compute auth tag"); + let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag json"); + let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); + + let event = EventBuilder::new(Kind::Metadata, content) + .tags(vec![auth_tag]) + .sign_with_keys(&agent_keys) + .expect("sign"); + (event, owner_keys.public_key().to_hex()) +} + +fn managed_agent_event( + owner_keys: &Keys, + agent_pubkey: &str, + name: &str, + respond_to: &str, + respond_to_allowlist: &[String], +) -> Event { + let content = serde_json::json!({ + "name": name, + "parallelism": 1, + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, + }) + .to_string(); + EventBuilder::new(Kind::Custom(30177), content) + .tags([Tag::parse(["d", agent_pubkey]).expect("parse d tag")]) + .sign_with_keys(owner_keys) + .expect("sign managed-agent event") +} + +#[test] +fn channel_info_minimal() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "chan-uuid-1"], + vec!["name", "general"], + vec!["about", "main channel"], + vec!["t", "stream"], + vec!["public"], + ], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.id, "chan-uuid-1"); + assert_eq!(info.name, "general"); + assert_eq!(info.description, "main channel"); + assert_eq!(info.channel_type, "stream"); + assert_eq!(info.visibility, "open"); + assert_eq!(info.member_count, 0); + assert!(info.is_member); +} + +#[test] +fn channel_info_private_when_visibility_tag_present() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "u"], + vec!["name", "n"], + vec!["t", "forum"], + vec!["visibility", "private"], + vec!["ttl", "86400"], + ], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.visibility, "private"); + assert_eq!(info.channel_type, "forum"); + assert_eq!(info.ttl_seconds, Some(86400)); +} + +#[test] +fn channel_info_open_when_neither_public_nor_private() { + // Neither tag present → open (matches NIP-29 default). + let e = ev( + 39000, + "", + vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.visibility, "open"); +} + +#[test] +fn channel_info_dm_inferred_from_hidden_tag() { + // Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs. + let e = ev( + 39000, + "", + vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.channel_type, "dm"); +} + +#[test] +fn channel_info_merges_summary() { + let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]); + let summary = ev( + 40901, + r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#, + vec![vec!["d", "u"]], + ); + let info = channel_info_from_event(&chan, Some(&summary), None).unwrap(); + assert_eq!(info.member_count, 7); + assert_eq!( + info.last_message_at.as_deref(), + Some("2026-01-01T00:00:00Z") + ); +} + +#[test] +fn channel_info_missing_d_errors() { + let e = ev(39000, "", vec![vec!["name", "n"]]); + assert!(channel_info_from_event(&e, None, None).is_err()); +} + +#[test] +fn channel_detail_basic() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "uuid"], + vec!["name", "n"], + vec!["about", "desc"], + vec!["topic", "tt"], + vec!["purpose", "pp"], + vec!["t", "dm"], + vec!["visibility", "private"], + vec!["ttl", "86400"], + vec!["ttl_deadline", "2026-06-11T00:00:00Z"], + ], + ); + let d = channel_detail_from_event(&e).unwrap(); + assert_eq!(d.id, "uuid"); + assert_eq!(d.topic.as_deref(), Some("tt")); + assert_eq!(d.purpose.as_deref(), Some("pp")); + assert_eq!(d.channel_type, "dm"); + assert_eq!(d.visibility, "private"); + assert_eq!(d.ttl_seconds, Some(86400)); + assert_eq!(d.ttl_deadline.as_deref(), Some("2026-06-11T00:00:00Z")); + assert!(d.created_at.ends_with("Z")); + assert_eq!(d.created_by, e.pubkey.to_hex()); +} + +#[test] +fn channel_members_extracts_p_tags() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + let e = ev( + 39002, + "", + vec![ + vec!["d", "uuid"], + vec!["p", &pk1, "", "admin"], + vec!["p", &pk2], + // Duplicate must be deduped. + vec!["p", &pk1, "wss://x", "owner"], + ], + ); + let r = channel_members_from_event(&e).unwrap(); + assert_eq!(r.members.len(), 2); + assert_eq!(r.members[0].pubkey, pk1); + assert_eq!(r.members[0].role, "admin"); + assert!(r.members[0].joined_at.is_none()); + assert_eq!(r.members[1].role, "member"); // default +} + +#[test] +fn channel_members_missing_d_errors() { + let e = ev(39002, "", vec![]); + assert!(channel_members_from_event(&e).is_err()); +} + +#[test] +fn profile_info_parses_content() { + let e = ev( + 0, + r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#, + vec![], + ); + let p = profile_info_from_event(&e).unwrap(); + assert_eq!(p.display_name.as_deref(), Some("Alice")); + assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png")); + assert_eq!(p.about.as_deref(), Some("hi")); + assert_eq!(p.nip05_handle.as_deref(), Some("alice@x")); + assert_eq!(p.pubkey, e.pubkey.to_hex()); + assert!(p.owner_pubkey.is_none()); +} + +#[test] +fn profile_info_extracts_valid_nip_oa_owner() { + let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); + let p = profile_info_from_event(&event).unwrap(); + + assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); +} + +#[test] +fn profile_info_falls_back_to_name() { + let e = ev(0, r#"{"name":"bob"}"#, vec![]); + let p = profile_info_from_event(&e).unwrap(); + assert_eq!(p.display_name.as_deref(), Some("bob")); +} + +#[test] +fn profile_info_invalid_json_errors() { + let e = ev(0, "not-json", vec![]); + assert!(profile_info_from_event(&e).is_err()); +} + +#[test] +fn users_batch_keeps_latest_and_reports_missing() { + let e1 = ev(0, r#"{"name":"old"}"#, vec![]); + // Same author, newer event with display_name. + let keys = Keys::generate(); + let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#) + .custom_created_at(nostr::Timestamp::from(1000)) + .sign_with_keys(&keys) + .unwrap(); + let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#) + .custom_created_at(nostr::Timestamp::from(2000)) + .sign_with_keys(&keys) + .unwrap(); + let pk = keys.public_key().to_hex(); + let other_pk = e1.pubkey.to_hex(); + + let missing_pk = "f".repeat(64); + let resp = users_batch_from_events( + &[e1, e_old, e_new], + &[pk.clone(), other_pk.clone(), missing_pk.clone()], + ); + assert_eq!(resp.profiles.len(), 2); + assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New")); + assert_eq!(resp.missing, vec![missing_pk]); +} + +#[test] +fn users_batch_marks_valid_nip_oa_profiles_as_agents() { + let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); + let pubkey = agent.pubkey.to_hex(); + let resp = users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey)); + + assert!(resp.profiles[&pubkey].is_agent); + assert_eq!( + resp.profiles[&pubkey].owner_pubkey.as_deref(), + Some(owner_pubkey.as_str()) + ); +} + +#[test] +fn user_notes_builds_cursor_from_last() { + let e1 = ev(1, "first", vec![]); + let e2 = ev(1, "second", vec![]); + let r = user_notes_from_events(&[e1, e2]); + assert_eq!(r.notes.len(), 2); + assert_eq!(r.notes[0].content, "first"); + let cursor = r.next_cursor.expect("cursor"); + assert_eq!(cursor.before_id, r.notes[1].id); +} + +#[test] +fn user_notes_empty_has_no_cursor() { + let r = user_notes_from_events(&[]); + assert!(r.notes.is_empty()); + assert!(r.next_cursor.is_none()); +} + +#[test] +fn contact_list_preserves_tags_and_content() { + let pk = "1".repeat(64); + let e = ev(3, "rel-json", vec![vec!["p", &pk]]); + let r = contact_list_from_event(&e).unwrap(); + assert_eq!(r.content, "rel-json"); + assert_eq!(r.tags.len(), 1); + assert_eq!(r.tags[0], vec!["p".to_string(), pk]); +} + +#[test] +fn search_response_assigns_descending_scores() { + let e1 = ev(1, "one", vec![vec!["h", "chan"]]); + let e2 = ev(1, "two", vec![]); + let r = search_response_from_events(&[e1, e2]); + assert_eq!(r.found, 2); + assert!(r.hits[0].score > r.hits[1].score); + assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan")); + assert!(r.hits[1].channel_id.is_none()); +} + +#[test] +fn search_response_single_hit_full_score() { + let e = ev(1, "only", vec![]); + let r = search_response_from_events(&[e]); + assert_eq!(r.hits.len(), 1); + assert_eq!(r.hits[0].score, 1.0); +} + +#[test] +fn agents_overwrites_pubkey_from_event_author() { + let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]); + let v = agents_from_events(std::slice::from_ref(&e)); + let arr = v.get("agents").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!( + arr[0].get("pubkey").and_then(Value::as_str).unwrap(), + e.pubkey.to_hex() + ); + assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1")); +} + +#[test] +fn agents_handles_invalid_content() { + let e = ev(10100, "not-json", vec![]); + let v = agents_from_events(std::slice::from_ref(&e)); + let arr = v.get("agents").and_then(Value::as_array).unwrap(); + assert_eq!( + arr[0].get("pubkey").and_then(Value::as_str).unwrap(), + e.pubkey.to_hex() + ); +} + +#[test] +fn agents_default_sparse_agent_profiles_for_directory_parse() { + let e = ev( + 10100, + r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#, + vec![], + ); + let v = agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].pubkey, e.pubkey.to_hex()); + assert_eq!(parsed[0].name, "Scout"); + assert_eq!(parsed[0].agent_type, "agent"); + assert_eq!(parsed[0].channels, Vec::::new()); + assert_eq!(parsed[0].capabilities, Vec::::new()); + assert_eq!(parsed[0].status, "offline"); + assert_eq!(parsed[0].respond_to, None); +} + +#[test] +fn agents_preserves_public_respond_to_mode_for_directory_parse() { + let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]); + let v = agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].respond_to, + Some(crate::managed_agents::RespondTo::Anyone) + ); +} + +#[test] +fn agents_preserves_allowlist_metadata_for_directory_parse() { + let e = ev( + 10100, + r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, + vec![], + ); + let v = agents_from_events(std::slice::from_ref(&e)); + let agents = v.get("agents").cloned().unwrap(); + let parsed: Vec = + serde_json::from_value(agents).unwrap(); + + assert_eq!(parsed.len(), 1); + assert_eq!( + parsed[0].respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]); +} + +#[test] +fn managed_agent_directory_accepts_only_the_verified_owner_policy() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let attacker_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let viewer_pubkey = "a".repeat(64); + + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .sign_with_keys(&agent_keys) + .expect("sign profile"); + let authentic = managed_agent_event( + &owner_keys, + &agent_pubkey, + "Codex", + "allowlist", + std::slice::from_ref(&viewer_pubkey), + ); + let forged = managed_agent_event(&attacker_keys, &agent_pubkey, "Fake Codex", "anyone", &[]); + + let agents = relay_agents_from_managed_agent_events( + &[forged, authentic], + std::slice::from_ref(&profile), + ); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].pubkey, agent_pubkey); + assert_eq!(agents[0].name, "Codex"); + assert_eq!( + agents[0].respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(agents[0].respond_to_allowlist, vec![viewer_pubkey]); +} + +#[test] +fn managed_agent_directory_rejects_agents_without_verified_owner_profiles() { + let owner_keys = Keys::generate(); + let unverified_agent_keys = Keys::generate(); + let agent_pubkey = unverified_agent_keys.public_key().to_hex(); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .sign_with_keys(&unverified_agent_keys) + .expect("sign profile"); + let managed = managed_agent_event(&owner_keys, &agent_pubkey, "Codex", "anyone", &[]); + + let agents = relay_agents_from_managed_agent_events( + std::slice::from_ref(&managed), + std::slice::from_ref(&profile), + ); + + assert!(agents.is_empty()); +} + +#[test] +fn managed_agent_directory_uses_the_latest_profile_head() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let verified_profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .custom_created_at(nostr::Timestamp::from(10)) + .sign_with_keys(&agent_keys) + .expect("sign verified profile"); + let revoked_profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .custom_created_at(nostr::Timestamp::from(20)) + .sign_with_keys(&agent_keys) + .expect("sign revoked profile"); + let managed = managed_agent_event(&owner_keys, &agent_pubkey, "Codex", "anyone", &[]); + + let agents = relay_agents_from_managed_agent_events( + std::slice::from_ref(&managed), + &[verified_profile, revoked_profile], + ); + + assert!(agents.is_empty()); +} + +#[test] +fn managed_agent_candidates_use_only_relay_signed_bot_membership() { + let relay_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key().to_hex(); + let stranger = Keys::generate().public_key().to_hex(); + let general = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "family"]).expect("parse d tag"), + Tag::parse(["p", &agent_pubkey, "", "bot"]).expect("parse agent tag"), + Tag::parse(["p", &stranger, "", "member"]).expect("parse member tag"), + ]) + .sign_with_keys(&relay_keys) + .expect("sign membership"); + let forged = ev( + 39002, + "", + vec![vec!["d", "forged"], vec!["p", &agent_pubkey, "", "bot"]], + ); + + let channel_ids = + member_agent_channel_ids_from_events(&[forged, general], &relay_keys.public_key().to_hex()); + + assert_eq!( + channel_ids.get(&agent_pubkey), + Some(&vec!["family".to_string()]) + ); + assert!(!channel_ids.contains_key(&stranger)); +} + +#[test] +fn managed_agent_directory_query_pubkeys_reject_malformed_d_tags() { + let valid_pubkey = Keys::generate().public_key().to_hex(); + let valid = ev(30177, "{}", vec![vec!["d", &valid_pubkey]]); + let malformed = ev(30177, "{}", vec![vec!["d", "not-a-pubkey"]]); + + let pubkeys = managed_agent_pubkeys_from_events(&[malformed, valid]); + + assert_eq!(pubkeys, [valid_pubkey].into_iter().collect()); +} + +#[test] +fn relay_agent_directory_preserves_headless_profiles_and_prefers_verified_managed_policy() { + let owner_keys = Keys::generate(); + let managed_agent_keys = Keys::generate(); + let managed_pubkey = managed_agent_keys.public_key().to_hex(); + let headless_keys = Keys::generate(); + let headless_pubkey = headless_keys.public_key().to_hex(); + let viewer_pubkey = "a".repeat(64); + + let headless_profile = EventBuilder::new( + Kind::Custom(10100), + serde_json::json!({ + "name": "Headless", + "respond_to": "anyone", + "channel_ids": ["untrusted-channel"] + }) + .to_string(), + ) + .sign_with_keys(&headless_keys) + .expect("sign headless directory profile"); + let stale_managed_profile = EventBuilder::new( + Kind::Custom(10100), + serde_json::json!({ + "name": "Stale Codex", + "respond_to": "anyone" + }) + .to_string(), + ) + .sign_with_keys(&managed_agent_keys) + .expect("sign managed directory profile"); + + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &managed_agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let managed_identity = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Codex"}"#) + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .sign_with_keys(&managed_agent_keys) + .expect("sign managed profile"); + let managed_policy = managed_agent_event( + &owner_keys, + &managed_pubkey, + "Codex", + "allowlist", + std::slice::from_ref(&viewer_pubkey), + ); + + let agents = relay_agents_from_directory_events( + &[headless_profile, stale_managed_profile], + std::slice::from_ref(&managed_policy), + std::slice::from_ref(&managed_identity), + ); + + assert_eq!(agents.len(), 2); + let headless = agents + .iter() + .find(|agent| agent.pubkey == headless_pubkey) + .expect("headless profile retained"); + assert_eq!( + headless.respond_to, + Some(crate::managed_agents::RespondTo::Anyone) + ); + assert!( + headless.channel_ids.is_empty(), + "claimed channel ids are not trusted" + ); + + let managed = agents + .iter() + .find(|agent| agent.pubkey == managed_pubkey) + .expect("managed profile retained"); + assert_eq!(managed.name, "Codex"); + assert_eq!( + managed.respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(managed.respond_to_allowlist, vec![viewer_pubkey]); +} + +#[test] +fn authenticated_malformed_managed_policy_does_not_fall_back_to_legacy_permissions() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let legacy = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Stale","respond_to":"anyone"}"#, + ) + .sign_with_keys(&agent_keys) + .expect("sign legacy profile"); + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .sign_with_keys(&agent_keys) + .expect("sign profile"); + let malformed = EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Current","parallelism":1,"respond_to":"future-mode"}"#, + ) + .tags([Tag::parse(["d", &agent_pubkey]).expect("parse d tag")]) + .sign_with_keys(&owner_keys) + .expect("sign managed policy"); + + let agents = relay_agents_from_directory_events(&[legacy], &[malformed], &[profile]); + + assert!(agents.is_empty()); +} + +#[test] +fn relay_agent_directory_resolves_equal_timestamp_heads_by_event_id() { + let keys = Keys::generate(); + let timestamp = nostr::Timestamp::from(42); + let first = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"First","respond_to":"anyone"}"#, + ) + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .expect("sign first directory head"); + let second = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Second","respond_to":"anyone"}"#, + ) + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .expect("sign second directory head"); + let expected_name = if first.id < second.id { + "First" + } else { + "Second" + }; + + let forward = relay_agents_from_directory_events(&[first.clone(), second.clone()], &[], &[]); + let reverse = relay_agents_from_directory_events(&[second, first], &[], &[]); + + assert_eq!(forward.len(), 1); + assert_eq!(reverse.len(), 1); + assert_eq!(forward[0].name, expected_name); + assert_eq!(reverse[0].name, expected_name); +} + +#[test] +fn forged_managed_policy_cannot_suppress_a_headless_directory_agent() { + let attacker_keys = Keys::generate(); + let targeted_agent_keys = Keys::generate(); + let targeted_pubkey = targeted_agent_keys.public_key().to_hex(); + let headless_keys = Keys::generate(); + let headless_pubkey = headless_keys.public_key().to_hex(); + let targeted_profile = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Targeted","respond_to":"anyone"}"#, + ) + .sign_with_keys(&targeted_agent_keys) + .expect("sign targeted profile"); + let headless = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Headless","respond_to":"anyone"}"#, + ) + .sign_with_keys(&headless_keys) + .expect("sign headless profile"); + let forged_policy = managed_agent_event( + &attacker_keys, + &targeted_pubkey, + "Codex", + "allowlist", + &["a".repeat(64)], + ); + + let agents = relay_agents_from_directory_events( + &[targeted_profile, headless], + std::slice::from_ref(&forged_policy), + &[], + ); + + assert_eq!(agents.len(), 2); + assert!(agents.iter().any(|agent| agent.pubkey == targeted_pubkey)); + assert!(agents.iter().any(|agent| agent.pubkey == headless_pubkey)); +} + +#[test] +fn relay_members_dedupes_and_defaults_role() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + // Current relay format: ["member", pubkey, role] + let e = ev( + 13534, + "", + vec![ + vec!["member", &pk1, "owner"], + vec!["member", &pk2], + vec!["member", &pk1, "moderator"], // dupe — ignored + ], + ); + let v = relay_members_from_event(&e); + let arr = v.get("members").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner")); + assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); +} + +#[test] +fn relay_members_fallback_p_tags() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + // Legacy/fallback format: ["p", pubkey, relay_url?, role?] + let e = ev( + 13534, + "", + vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]], + ); + let v = relay_members_from_event(&e); + let arr = v.get("members").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin")); + assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); +} + +#[test] +fn timestamp_to_iso_known_value() { + // 2021-01-01T00:00:00Z = 1609459200 + assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z"); + // Epoch + assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7accd54af6..53a39824c4 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -160,8 +160,12 @@ with a TypeScript lookup table or an id comparison in a component. computer, including files, accounts, and connected tools"; remote names "the server it runs on, including any accounts and tools available there" — deliberately *not* the owner's files, which aren't theirs to describe on a - host they don't own. **An unknown location falls back to the local wording — - never hedge with "computer or server".** A remote host requires an + host they don't own. **For a persona-linked deployed agent, the profile Edit + dialog seeds access from the exact clicked instance and saves access through + `update_managed_agent`; persona behavior remains the definition default, but + must never bypass the instance command's stop, persist, publish, and restart + boundary.** An unknown location falls back to the local wording — never hedge + with "computer or server". A remote host requires an installed `buzz-backend-*` provider, and without one `WhereToRunSection` never renders, so "server" would name a concept the owner has never been shown; when it *is* remote they picked that host from the selector diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index de3d2b9e83..0913582cab 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -341,14 +341,10 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) { return useQuery({ queryKey: relayAgentsQueryKey, queryFn: listRelayAgents, - // Relay agent profiles (kind:10100) are near-static and the backing - // `list_relay_agents` command is an unfiltered relay query for the whole - // profile set — mounted on ~13 always-live surfaces (channel screen, - // members bar, mentions, sidebar, profile popovers), so a tight interval - // re-pulls the full set app-wide. This poll is also the ONLY refresh path: - // the `agents-data-changed` event fires only for local persona/team/managed - // reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we - // keep polling but at a relaxed cadence and pause it while backgrounded. + // Relay agent discovery is scoped to the viewer's relay-signed channel + // memberships, then resolves exact agent/profile/policy coordinates in + // protocol-sized batches. Polling remains the only refresh path for remote + // changes, so keep it relaxed and pause while backgrounded. refetchInterval, enabled: options?.enabled, ...agentsFocusRefetchPolicy, diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index cdb47bccd3..8fbd7a3bfb 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { pickProfileAgent } from "./pickProfileAgent.ts"; +import { + pickDirectProfileAgent, + pickProfileAgent, +} from "./pickProfileAgent.ts"; test("the shared profile target prefers the active persona instance", () => { const stopped = { @@ -18,3 +21,57 @@ test("the shared profile target prefers the active persona instance", () => { assert.equal(pickProfileAgent([stopped, running]), running); assert.equal(pickProfileAgent([running, stopped]), running); }); + +test("a direct-opened active instance is never redirected to a sibling", () => { + // "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an + // access edit on Tyler would target the sibling. + const sibling = { + name: "Alpha Sibling", + pubkey: "a".repeat(64), + status: "running", + }; + const clicked = { + name: "Tyler Agent", + pubkey: "b".repeat(64), + status: "running", + }; + + assert.equal(pickDirectProfileAgent(clicked, [sibling, clicked]), clicked); +}); + +test("a direct-opened inactive instance redirects to the active sibling", () => { + const historical = { + name: "Earlier Parity Agent", + pubkey: "a".repeat(64), + status: "stopped", + }; + const current = { + name: "Current Parity Agent", + pubkey: "b".repeat(64), + status: "running", + }; + + assert.equal( + pickDirectProfileAgent(historical, [historical, current]), + current, + ); +}); + +test("a direct-opened inactive instance with no active sibling stays put", () => { + const clicked = { + name: "Only Instance", + pubkey: "a".repeat(64), + status: "stopped", + }; + const otherStopped = { + name: "Another Stopped", + pubkey: "b".repeat(64), + status: "stopped", + }; + + assert.equal( + pickDirectProfileAgent(clicked, [clicked, otherStopped]), + clicked, + ); + assert.equal(pickDirectProfileAgent(clicked, []), clicked); +}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index c845145495..cea746f10c 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -16,3 +16,23 @@ export function pickProfileAgent(agents: readonly ManagedAgent[]) { return left.name.localeCompare(right.name); })[0]; } + +/** + * Resolve which instance a profile panel opened for `directAgent` should + * show, given every instance of the same persona. + * + * Access edits must target the exact instance the user clicked — resolving a + * running sidebar member to an alphabetically-earlier sibling would let a + * "tighten access" save widen the wrong agent. But when the clicked instance + * is inactive and the persona has an active instance elsewhere (an avatar on + * an old message from a retired instance), redirect to the active one so the + * panel matches the Agents library. + */ +export function pickDirectProfileAgent( + directAgent: ManagedAgent, + personaInstances: readonly ManagedAgent[], +) { + if (isManagedAgentActive(directAgent)) return directAgent; + const canonical = pickProfileAgent(personaInstances); + return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; +} diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs new file mode 100644 index 0000000000..7a3643a308 --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; +import { startRelayAgentPolicyRefresh } from "./useAgentsDataRefresh.ts"; + +const coordinates = [ + { ownerPubkey: "owner-a", agentPubkey: "agent-a" }, + { ownerPubkey: "owner-b", agentPubkey: "agent-b" }, +]; + +function event(pubkey, dTag) { + return { + id: "id", + pubkey, + created_at: 1, + kind: KIND_MANAGED_AGENT, + tags: dTag ? [["d", dTag]] : [], + content: "{}", + sig: "sig", + }; +} + +test("remote managed policy refresh accepts only exact authenticated coordinates", async () => { + let onEvent; + let filter; + let unsubscribeCalls = 0; + mock.method(relayClient, "subscribeLive", (nextFilter, listener) => { + filter = nextFilter; + onEvent = listener; + return Promise.resolve(() => { + unsubscribeCalls += 1; + return Promise.resolve(); + }); + }); + + let refreshes = 0; + const stop = startRelayAgentPolicyRefresh(coordinates, () => { + refreshes += 1; + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(filter, { + kinds: [KIND_MANAGED_AGENT], + authors: ["owner-a", "owner-b"], + "#d": ["agent-a", "agent-b"], + limit: 0, + }); + onEvent(event("owner-a", "agent-a")); + assert.equal(refreshes, 1); + + for (const irrelevant of [ + event("owner-x", "agent-a"), + event("owner-a", "agent-x"), + event("owner-a", "agent-b"), // authors×d cross-product + event("owner-a", null), + ]) { + onEvent(irrelevant); + } + assert.equal(refreshes, 1, "irrelevant coordinates must not refresh"); + + stop(); + assert.equal(unsubscribeCalls, 1); + mock.reset(); +}); + +test("stopping before subscription readiness still closes the live query", async () => { + let resolveSubscription; + let unsubscribeCalls = 0; + mock.method( + relayClient, + "subscribeLive", + () => + new Promise((resolve) => { + resolveSubscription = resolve; + }), + ); + + const stop = startRelayAgentPolicyRefresh(coordinates, () => {}); + stop(); + resolveSubscription(() => { + unsubscribeCalls += 1; + return Promise.resolve(); + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(unsubscribeCalls, 1); + mock.reset(); +}); + +test("no authenticated coordinates creates no global subscription", () => { + let subscriptions = 0; + mock.method(relayClient, "subscribeLive", () => { + subscriptions += 1; + return Promise.resolve(() => Promise.resolve()); + }); + startRelayAgentPolicyRefresh([], () => {})(); + assert.equal(subscriptions, 0); + mock.reset(); +}); diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 174fb9c92c..0349618d11 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -2,6 +2,9 @@ import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayAgent, RelayEvent } from "@/shared/api/types"; +import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; import { managedAgentsQueryKey, personasQueryKey, @@ -10,18 +13,84 @@ import { } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; -// Trailing-coalesce window: a backfill burst (up to 500 inbound events fed -// one-by-one through reconcile) fires one `agents-data-changed` per event. -// Collapsing them into a single invalidate after the burst settles keeps the -// refetch off React Query's implicit in-flight dedup and avoids redundant -// disk-read IPC. const COALESCE_MS = 200; +export const RELAY_POLICY_REFRESH_MIN_INTERVAL_MS = 5_000; + +export type RelayAgentPolicyCoordinate = { + agentPubkey: string; + ownerPubkey: string; +}; + +function eventDTag(event: RelayEvent): string | null { + return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null; +} + +/** + * Subscribe only to authenticated managed-agent coordinates already returned by + * the relay directory. The callback repeats the exact owner+d check because a + * combined Nostr filter admits the authors×d cross-product. + */ +export function startRelayAgentPolicyRefresh( + coordinates: RelayAgentPolicyCoordinate[], + onChange: () => void, + onError: (error: unknown) => void = (error) => { + console.warn("Couldn’t subscribe to managed agent policy updates", error); + }, +): () => void { + if (coordinates.length === 0) return () => {}; + + const allowed = new Set( + coordinates.map( + ({ ownerPubkey, agentPubkey }) => + `${ownerPubkey.toLowerCase()}:${agentPubkey.toLowerCase()}`, + ), + ); + const authors = [ + ...new Set(coordinates.map(({ ownerPubkey }) => ownerPubkey)), + ]; + const agentPubkeys = [ + ...new Set(coordinates.map(({ agentPubkey }) => agentPubkey)), + ]; + let disposed = false; + let unsubscribe: (() => Promise) | null = null; + void relayClient + .subscribeLive( + { + kinds: [KIND_MANAGED_AGENT], + authors, + "#d": agentPubkeys, + limit: 0, + }, + (event) => { + const dTag = eventDTag(event); + if ( + dTag && + allowed.has(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) + ) { + onChange(); + } + }, + ) + .then((nextUnsubscribe) => { + if (disposed) void nextUnsubscribe(); + else unsubscribe = nextUnsubscribe; + }) + .catch(onError); + + return () => { + disposed = true; + void unsubscribe?.(); + }; +} + +function relayPolicyCoordinates(agents: RelayAgent[] | undefined) { + return (agents ?? []).flatMap((agent) => + agent.ownerPubkey + ? [{ agentPubkey: agent.pubkey, ownerPubkey: agent.ownerPubkey }] + : [], + ); +} -// Invalidate the live Agents-tab queries when the backend signals that inbound -// relay events changed the on-disk agents data. Mounted once at the app root -// with empty deps — invalidation is global and has no reason to be -// pubkey-scoped, so it must NOT live inside the pubkey-keyed `usePersonaSync` -// (re-registering per identity switch would leak a listener each time). export function useAgentsDataRefresh(): void { const queryClient = useQueryClient(); @@ -32,8 +101,6 @@ export function useAgentsDataRefresh(): void { void queryClient.invalidateQueries({ queryKey: managedAgentRuntimesQueryKey, }); - // Pair startup also changes the legacy managed-agent scalar status. - // Keep that cache synchronized for consumers outside pair-runtime UI. void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); }); @@ -47,10 +114,73 @@ export function useAgentsDataRefresh(): void { }, COALESCE_MS); }); + let policyStop = () => {}; + let policyTimer: ReturnType | undefined; + let policyDirty = false; + let policyRefreshInFlight = false; + let policyDisposed = false; + let coordinateKey = ""; + + const refreshPolicyDirectory = () => { + if (policyRefreshInFlight || policyTimer !== undefined) { + policyDirty = true; + return; + } + policyRefreshInFlight = true; + void queryClient + .invalidateQueries({ queryKey: relayAgentsQueryKey }) + .finally(() => { + policyRefreshInFlight = false; + if (policyDisposed) return; + policyTimer = setTimeout(() => { + policyTimer = undefined; + if (policyDirty) { + policyDirty = false; + refreshPolicyDirectory(); + } + }, RELAY_POLICY_REFRESH_MIN_INTERVAL_MS); + }); + }; + + const resubscribePolicy = () => { + const coordinates = relayPolicyCoordinates( + queryClient.getQueryData(relayAgentsQueryKey), + ); + const nextKey = coordinates + .map(({ ownerPubkey, agentPubkey }) => `${ownerPubkey}:${agentPubkey}`) + .sort() + .join("|"); + if (nextKey === coordinateKey) return; + coordinateKey = nextKey; + policyStop(); + policyStop = startRelayAgentPolicyRefresh( + coordinates, + refreshPolicyDirectory, + ); + }; + resubscribePolicy(); + const unsubscribeQueryCache = queryClient + .getQueryCache() + .subscribe((event) => { + if ( + event.query.queryKey.length === relayAgentsQueryKey.length && + event.query.queryKey.every( + (value: unknown, index: number) => + value === relayAgentsQueryKey[index], + ) + ) { + resubscribePolicy(); + } + }); + return () => { + policyDisposed = true; if (timer !== undefined) clearTimeout(timer); + if (policyTimer !== undefined) clearTimeout(policyTimer); void unlisten.then((fn) => fn()); void unlistenRuntime.then((fn) => fn()); + unsubscribeQueryCache(); + policyStop(); }; }, [queryClient]); } diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index 0dc12ddfd1..a67b68cc7f 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -108,3 +108,46 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async mock.reset(); delete globalThis.window; }); + +test("startPersonaSync serializes inbound reconciliation in relay order", async () => { + const resolvers = []; + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return new Promise((resolve) => resolvers.push(resolve)); + }, + }, + }; + + let onEvent; + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "subscribeLive", (_filter, listener) => { + onEvent = listener; + return Promise.resolve(() => Promise.resolve()); + }); + + startPersonaSync("owner-pubkey", "wss://community.example", () => false); + await new Promise((resolve) => setImmediate(resolve)); + onEvent({ id: "broad", pubkey: "owner-pubkey", kind: KIND_MANAGED_AGENT }); + onEvent({ + id: "restricted", + pubkey: "owner-pubkey", + kind: KIND_MANAGED_AGENT, + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual( + invokedIds, + ["broad"], + "newer event waits for prior deployment", + ); + resolvers.shift()(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(invokedIds, ["broad", "restricted"]); + resolvers.shift()(); + + mock.reset(); + delete globalThis.window; +}); diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index f18194c5c6..66ed679ad9 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -35,13 +35,19 @@ export function startPersonaSync( relayUrl: string, onCancelled: () => boolean, ): () => Promise { + // Reconcile in relay order. Managed-agent reconciliation can await a remote + // provider deployment after releasing the local store lock; firing commands + // independently lets an older broad policy finish after a newer restrictive + // one. One chain per owner/relay subscription makes the newest event the last + // deployment without serializing unrelated identities or communities. + let reconcileChain = Promise.resolve(); const reconcile = (event: RelayEvent) => { if (event.pubkey !== pubkey) return; - void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch( - (error) => { + reconcileChain = reconcileChain + .then(() => reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl)) + .catch((error) => { console.warn("[usePersonaSync] reconcile failed:", error); - }, - ); + }); }; // One-shot backfill of existing heads + tombstones (closes the fresh-start diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index b4606a0f68..06f41667b0 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -484,12 +484,6 @@ export function AgentDefinitionDialog({ const modelFieldVisible = runtime.trim().length > 0 || blankRuntimeModelProviderEditable; const isExplicitModelRequired = aiConfigurationMode === "custom"; - // Gate the provider requirement on the field's actual visibility, not the raw - // runtime capability. Codex/Claude hide the provider picker (they drive their - // own provider), so Customize must not require a provider there. But a - // runtime-less legacy/builtin definition still exposes the picker via - // blankRuntimeModelProviderEditable, so it must keep requiring a provider — - // otherwise Save could persist `provider: undefined` despite the visible field. const customAiPairSatisfied = agentAiConfigurationModeSatisfied( aiConfigurationMode, { provider, model }, @@ -739,7 +733,6 @@ export function AgentDefinitionDialog({ isPending={isPending} onCancel={() => handleOpenChange(false)} publishesCatalogUpdates={publishCatalogUpdatesOnSave && hasUserChanges} - submitBlockReason={null} submitLabel={submitLabel} /> ); diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx index 92428ad95c..6f15c8d860 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx @@ -6,7 +6,6 @@ type AgentDefinitionDialogFooterProps = { isPending: boolean; onCancel: () => void; publishesCatalogUpdates: boolean; - submitBlockReason: string | null; submitLabel: string; }; @@ -16,20 +15,11 @@ export function AgentDefinitionDialogFooter({ isPending, onCancel, publishesCatalogUpdates, - submitBlockReason, submitLabel, }: AgentDefinitionDialogFooterProps) { return (
- {submitBlockReason ? ( -

- {submitBlockReason} -

- ) : null} {publishesCatalogUpdates ? (

{ ); }); +test("incomplete Customize explains why Save remains disabled", () => { + assert.equal( + agentAiConfigurationSubmitBlockReason("custom", { + provider: "", + model: "", + }), + "Choose a provider to save custom AI configuration.", + ); + assert.equal( + agentAiConfigurationSubmitBlockReason("custom", { + provider: "anthropic", + model: "", + }), + "Choose a model to save custom AI configuration.", + ); + assert.equal( + agentAiConfigurationSubmitBlockReason( + "custom", + { provider: "", model: "" }, + false, + ), + "Choose a model to save custom AI configuration.", + ); + assert.equal( + agentAiConfigurationSubmitBlockReason("defaults", { + provider: "", + model: "", + }), + null, + ); +}); + test("Codex/Claude Customize needs only a model, not the hidden provider", () => { // needsProviderSelection=false → the intentionally hidden provider must not // gate Save (the create/edit "Save stays disabled" regression). diff --git a/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts b/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts index 897ad0e1f3..d39797cda0 100644 --- a/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts +++ b/desktop/src/features/agents/ui/agentAiConfigurationPolicy.ts @@ -47,6 +47,21 @@ export function agentAiConfigurationPairForMode({ * runtime capability, so the gate never diverges from the visible picker. It * defaults to `true` so existing callers keep the provider+model requirement. */ +export function agentAiConfigurationSubmitBlockReason( + mode: AgentAiConfigurationMode, + pair: AgentAiConfigurationPair, + needsProviderSelection = true, +): string | null { + if ( + mode !== "custom" || + agentAiConfigurationModeSatisfied(mode, pair, needsProviderSelection) + ) + return null; + return needsProviderSelection && !pair.provider.trim() + ? "Choose a provider to save custom AI configuration." + : "Choose a model to save custom AI configuration."; +} + export function agentAiConfigurationModeSatisfied( mode: AgentAiConfigurationMode, pair: AgentAiConfigurationPair, diff --git a/desktop/src/features/agents/ui/agentProfileSyncWarning.ts b/desktop/src/features/agents/ui/agentProfileSyncWarning.ts index 91be216fd6..914285df72 100644 --- a/desktop/src/features/agents/ui/agentProfileSyncWarning.ts +++ b/desktop/src/features/agents/ui/agentProfileSyncWarning.ts @@ -6,6 +6,6 @@ export function showAgentProfileSyncWarning( ) { if (!profileSyncError) return; toast.warning( - `${agentName} was saved, but relay profile sync failed: ${profileSyncError}. The relay may still show the old name — restart the agent to retry the sync.`, + `${agentName} was saved locally, but relay sync failed: ${profileSyncError}. Remote users may still see the previous name or access policy until Buzz retries the sync.`, ); } diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index e850c34775..b786bf5573 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -269,6 +269,36 @@ test("edit and duplicate seed the behavior group from a quad-bearing persona", ( ); }); +test("a linked instance overrides stale definition access in the edit dialog", () => { + const persona = { + id: "persona-instance-access", + displayName: "Shared", + avatarUrl: null, + systemPrompt: "Shared.", + runtime: null, + model: null, + provider: null, + isBuiltIn: false, + isActive: true, + respondTo: "owner-only", + respondToAllowlist: [], + parallelism: 2, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-02T00:00:00Z", + }; + + const state = editPersonaDialogState(persona, { + respondTo: "allowlist", + respondToAllowlist: ["c".repeat(64)], + }); + + assert.deepEqual(state.initialValues.behavior, { + respondTo: "allowlist", + respondToAllowlist: ["c".repeat(64)], + parallelism: 2, + }); +}); + test("a non-allowlist mode does not seed a stale allowlist into the dialog", () => { const state = editPersonaDialogState({ id: "persona-mode-flip", diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts index a553182ce8..e09e647b9f 100644 --- a/desktop/src/features/agents/ui/personaDialogState.ts +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -104,7 +104,15 @@ function behaviorEntry( export function editPersonaDialogState( persona: AgentPersona, + accessSource?: Pick, ): PersonaDialogState { + const behaviorSource = accessSource + ? { + ...persona, + respondTo: accessSource.respondTo, + respondToAllowlist: accessSource.respondToAllowlist, + } + : persona; return { title: "Edit agent", description: "", @@ -123,7 +131,7 @@ export function editPersonaDialogState( // the dialog must therefore round-trip the existing values.) namePool: persona.namePool ?? [], envVars: persona.envVars ?? {}, - ...behaviorEntry(persona), + ...behaviorEntry(behaviorSource), }, }; } diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx index d3c3df339b..078a4c6167 100644 --- a/desktop/src/features/channels/ui/EditRespondToDialog.tsx +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly"; import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning"; +import { showAgentProfileSyncWarning } from "@/features/agents/ui/agentProfileSyncWarning"; import { CreateAgentRespondToField, OWNER_ONLY_ACCESS_DISABLED_REASON, @@ -50,12 +51,13 @@ export function EditRespondToDialog({ async function handleSave() { if (!agent) return; - await updateMutation.mutateAsync({ + const result = await updateMutation.mutateAsync({ pubkey: agent.pubkey, respondTo, respondToAllowlist: respondTo === "allowlist" ? respondToAllowlist : undefined, }); + showAgentProfileSyncWarning(result.agent.name, result.profileSyncError); onOpenChange(false); } diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index b375649292..0257e52ebf 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -206,33 +206,36 @@ export function MembersSidebarMemberCard({

)} {managedAgentRuntime || managedAgent ? ( - + + {managedAgentRuntime + ? agentCommunityAvailability(managedAgentRuntime) : managedAgent && isManagedAgentActive(managedAgent) - ? "default" - : "secondary" - } - > - {managedAgentRuntime - ? agentCommunityAvailability(managedAgentRuntime) - : managedAgent && isManagedAgentActive(managedAgent) - ? "Running" - : "Stopped"} - - ) : null} - {managedAgent ? ( - - {formatRespondToLabel(managedAgent)} - + ? "Running" + : "Stopped"} + + {managedAgent ? ( + + {formatRespondToLabel(managedAgent)} + + ) : null} +
) : null} diff --git a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts index e900e123f5..6785640a7f 100644 --- a/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts +++ b/desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; +import { + pickDirectProfileAgent, + pickProfileAgent, +} from "@/features/agents/lib/pickProfileAgent"; import { useUserProfileQuery } from "@/features/profile/hooks"; import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId"; @@ -11,6 +14,7 @@ export function useCanonicalManagedAgentProfile(input: { currentPubkey: string | undefined; managedAgents: readonly ManagedAgent[] | undefined; personaId: string | undefined; + preferDirectManagedAgent?: boolean; preserveRequestedInstance?: boolean; pubkey: string | undefined; }) { @@ -18,6 +22,7 @@ export function useCanonicalManagedAgentProfile(input: { currentPubkey, managedAgents, personaId, + preferDirectManagedAgent = false, preserveRequestedInstance = false, pubkey, } = input; @@ -48,13 +53,20 @@ export function useCanonicalManagedAgentProfile(input: { (agent) => agent.personaId === linkedPersonaId, ); }, [directManagedAgent, linkedPersonaId, managedAgents]); - const managedAgent = React.useMemo( - () => - preserveRequestedInstance && directManagedAgent - ? directManagedAgent - : (pickProfileAgent(personaInstances) ?? directManagedAgent), - [directManagedAgent, personaInstances, preserveRequestedInstance], - ); + const managedAgent = React.useMemo(() => { + if (directManagedAgent) { + if (preserveRequestedInstance) return directManagedAgent; + if (preferDirectManagedAgent) { + return pickDirectProfileAgent(directManagedAgent, personaInstances); + } + } + return pickProfileAgent(personaInstances) ?? directManagedAgent; + }, [ + directManagedAgent, + personaInstances, + preferDirectManagedAgent, + preserveRequestedInstance, + ]); return { linkedPersonaId, managedAgent, personaInstances }; } diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 998ea3232a..7638b0cb36 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -187,7 +187,6 @@ export function UserProfilePanel({ requestedInstancePubkey && normalizePubkey(pubkey) === normalizePubkey(requestedInstancePubkey), ); - const personasQuery = usePersonasQuery(); const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); const { linkedPersonaId, managedAgent, personaInstances } = @@ -195,6 +194,7 @@ export function UserProfilePanel({ currentPubkey, managedAgents: managedAgentsQuery.data, personaId: persona?.id, + preferDirectManagedAgent: true, preserveRequestedInstance, pubkey, }); @@ -398,15 +398,17 @@ export function UserProfilePanel({ onClose, viewerIsOwner, }); - + const openResolvedPersonaEditor = React.useCallback(() => { + if (!resolvedPersona) return false; + setPersonaDialogState( + editPersonaDialogState(resolvedPersona, managedAgent), + ); + return true; + }, [managedAgent, resolvedPersona]); const handleEditAgent = React.useCallback(() => { - if (resolvedPersona) { - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); - return; - } + if (openResolvedPersonaEditor()) return; setEditAgentOpen(true); - }, [resolvedPersona, setEditAgentOpen]); - + }, [openResolvedPersonaEditor, setEditAgentOpen]); const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = useProfileAgentDeletion({ channels: channelsQuery.data, @@ -545,10 +547,7 @@ export function UserProfilePanel({ ], ); - const handleEditPersona = React.useCallback(() => { - if (!resolvedPersona) return; - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); - }, [resolvedPersona]); + const handleEditPersona = openResolvedPersonaEditor; const handleDuplicatePersona = React.useCallback(() => { if (!resolvedPersona) return; @@ -913,7 +912,7 @@ export function UserProfilePanel({ ? () => { setEditAgentOpen(false); setEditAgentFocus(undefined); - setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + openResolvedPersonaEditor(); } : undefined } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index c3ff723375..0c983fa3e8 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -58,6 +58,8 @@ function persona(overrides = {}) { namePool: [], isBuiltIn: false, isActive: true, + respondTo: "owner-only", + respondToAllowlist: [], envVars: { NEW_KEY: "2" }, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", @@ -93,6 +95,42 @@ test("personaManagedAgentUpdate syncs edited persona identity to linked agent", }); }); +test("personaManagedAgentUpdate syncs definition access to the linked agent", () => { + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "anyone" }), + persona({ respondTo: "owner-only" }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "owner-only", + }, + ); + + assert.deepEqual( + personaManagedAgentUpdate( + agent({ respondTo: "anyone" }), + persona({ + respondTo: "allowlist", + respondToAllowlist: ["a".repeat(64)], + }), + ), + { + pubkey: "deadbeef".repeat(8), + name: "Fizz Prime", + systemPrompt: "New prompt", + model: "new-model", + envVars: { NEW_KEY: "2" }, + respondTo: "allowlist", + respondToAllowlist: ["a".repeat(64)], + }, + ); +}); + test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { assert.equal( personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 16999816c3..be1a57c112 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -298,6 +298,22 @@ export function personaManagedAgentUpdate( hasChanges = true; } + // Definition edits expose the access policy in the same dialog as identity + // and runtime settings. Keep the exact linked instance in sync when the + // definition carries an explicit policy; otherwise the dialog reopens with + // the new value while the running agent and sidebar retain the old one. + if (persona.respondTo != null && persona.respondTo !== agent.respondTo) { + input.respondTo = persona.respondTo; + hasChanges = true; + } + if ( + persona.respondTo === "allowlist" && + !stringArrayEqual(persona.respondToAllowlist, agent.respondToAllowlist) + ) { + input.respondToAllowlist = [...persona.respondToAllowlist]; + hasChanges = true; + } + const runtimeChanged = options.previousPersona !== undefined && options.previousPersona.runtime !== persona.runtime; diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 3009076aa8..4cde503fde 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -102,6 +102,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { if (!agentsByPubkey.has(agent.pubkey)) { agentsByPubkey.set(agent.pubkey, { pubkey: agent.pubkey, + ownerPubkey: null, name: agent.name, agentType: agent.agentCommand, channels: [], diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8eb626a81d..038ae52714 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -100,6 +100,7 @@ type RawSearchResponse = { type RawRelayAgent = { pubkey: string; + owner_pubkey?: string | null; name: string; agent_type: string; channels: string[]; @@ -109,7 +110,6 @@ type RawRelayAgent = { respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; }; - import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; export type RawManagedAgent = { pubkey: string; @@ -652,10 +652,10 @@ export async function createAuthEvent(input: { const eventJson = await invokeTauri("create_auth_event", input); return JSON.parse(eventJson) as RelayEvent; } - function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { return { pubkey: agent.pubkey, + ownerPubkey: agent.owner_pubkey ?? null, name: agent.name, agentType: agent.agent_type, channels: agent.channels, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 24ef625783..dcf6d2e8bc 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -266,9 +266,9 @@ export type RelayMember = { addedBy: string | null; createdAt: string; }; - export type RelayAgent = { pubkey: string; + ownerPubkey: string | null; name: string; agentType: string; channels: string[]; diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts index 709c5fbbc6..adb9c6d58a 100644 --- a/desktop/tests/e2e/agent-access-warning.spec.ts +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -46,28 +46,30 @@ test("open agent access explains the available access before save", async ({ name: "Hack Day Helper", status: "running", channelNames: ["general"], - respondTo: "owner-only", + respondTo: "anyone", }, ], }); await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + const accessBadge = page.getByTestId( + `sidebar-managed-agent-respond-to-${agent.pubkey}`, + ); + await expect(accessBadge).toBeVisible(); + await expect(accessBadge).toHaveText("Anyone"); await openAgentAccessDialog(page, agent.pubkey); const accessSelect = page.getByTestId("agent-respond-to-select"); - await expect(accessSelect).toHaveValue("owner-only"); - await expect(page.getByTestId("agent-access-warning")).toHaveCount(0); + await expect(accessSelect).toHaveValue("anyone"); const saveAccess = page.getByRole("button", { name: "Save access" }); await expect(saveAccess).toBeVisible(); const commandsBeforeSave = await page.evaluate( () => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0, ); - await accessSelect.selectOption("anyone"); - const warning = page.getByTestId("agent-access-warning"); - await expect(warning).toBeVisible(); - await expect(warning).toContainText( - "Anyone can use this agent to access your computer, including files, accounts, and connected tools.", - ); + await accessSelect.selectOption("owner-only"); + await expect(page.getByTestId("agent-access-warning")).toHaveCount(0); await waitForAnimations(page); await page @@ -88,17 +90,19 @@ test("open agent access explains the available access before save", async ({ (entry) => entry.command === "update_managed_agent" && (entry.payload as { input?: { respondTo?: string } })?.input - ?.respondTo === "anyone", + ?.respondTo === "owner-only", ); }, commandsBeforeSave), ) .toBe(true); + await expect(accessBadge).toHaveText("Only me"); await openAgentAccessDialog(page, agent.pubkey); - await expect(accessSelect).toHaveValue("anyone"); + await expect(accessSelect).toHaveValue("owner-only"); // Selected people narrows the audience but not the access, so the warning // persists with its own audience phrase. await accessSelect.selectOption("allowlist"); + const warning = page.getByTestId("agent-access-warning"); await expect(warning).toBeVisible(); await expect(warning).toContainText( "Selected people can use this agent to access your computer, including files, accounts, and connected tools.", @@ -124,6 +128,134 @@ test("open agent access explains the available access before save", async ({ await expect(warning).toHaveCount(0); }); +test("full agent editor tightens the exact sidebar agent instance", async ({ + page, +}) => { + const agent = TEST_IDENTITIES.tyler; + const preferredSiblingPubkey = "d".repeat(64); + const personaId = "shared-sidebar-agent"; + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + availability: "available", + command: "goose", + default_args: [], + id: "goose", + install_hint: "", + label: "Goose", + mcp_command: "", + }, + ], + globalAgentConfig: { + env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" }, + model: "claude-opus-4-5", + preferred_runtime: "goose", + provider: "anthropic", + }, + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Tyler Agent", + personaId, + status: "running", + channelNames: ["general"], + respondTo: "anyone", + }, + { + pubkey: preferredSiblingPubkey, + name: "Preferred Sibling", + personaId, + status: "running", + channelNames: [], + respondTo: "anyone", + }, + ], + personas: [ + { + displayName: "Shared Sidebar Agent", + id: personaId, + isActive: true, + respondTo: "anyone", + runtime: "goose", + systemPrompt: "Test exact instance editing.", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + const accessBadge = page.getByTestId( + `sidebar-managed-agent-respond-to-${agent.pubkey}`, + ); + await expect(accessBadge).toHaveText("Anyone"); + + await page.getByTestId(`sidebar-member-${agent.pubkey}`).click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await page.getByTestId("user-profile-edit-agent").click(); + const dialog = page.getByRole("dialog", { name: "Edit agent" }); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Advanced" }).click(); + await choosePersonaAccess(page, "Only me (default)"); + await dialog.getByRole("tab", { name: "Customize for this agent" }).click(); + const saveChanges = dialog.getByRole("button", { name: "Save changes" }); + await expect(saveChanges).toBeEnabled(); + await saveChanges.click(); + await expect(dialog).not.toBeVisible(); + + const updateCommand = await page.evaluate( + (pubkey) => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => + entry.command === "update_managed_agent" && + (entry.payload as { input?: { pubkey?: string } })?.input?.pubkey === + pubkey, + ), + agent.pubkey, + ); + expect(updateCommand?.payload).toMatchObject({ + input: { pubkey: agent.pubkey, respondTo: "owner-only" }, + }); + expect(updateCommand?.payload).not.toMatchObject({ + input: { pubkey: preferredSiblingPubkey }, + }); + + await page.getByTestId("channel-members-trigger").click(); + await expect(accessBadge).toHaveText("Only me"); + + // The definition still says "anyone" after the instance-only save above. + // Reopening the same linked agent for an unrelated prompt edit must seed + // access from the exact instance, or the submit silently widens it again. + await page.getByTestId(`sidebar-member-${agent.pubkey}`).click(); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Advanced" }).click(); + await expect(page.locator("#agent-respond-to")).toHaveText( + "Only me (default)", + ); + await page + .locator("#persona-system-prompt") + .fill("Test unrelated prompt editing after tightening access."); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await expect(dialog).not.toBeVisible(); + + const unrelatedEditCommand = await page.evaluate( + (pubkey) => + window.__BUZZ_E2E_COMMAND_LOG__?.findLast( + (entry) => + entry.command === "update_managed_agent" && + (entry.payload as { input?: { pubkey?: string } })?.input?.pubkey === + pubkey, + ), + agent.pubkey, + ); + expect(unrelatedEditCommand?.payload).toMatchObject({ + input: { pubkey: agent.pubkey }, + }); + expect(unrelatedEditCommand?.payload).not.toMatchObject({ + input: { respondTo: "anyone" }, + }); +}); + test("a provider-backed agent's warning names the server, not this computer", async ({ page, }) => {