diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 64c80991b..b49c9c1d3 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -87,7 +87,8 @@ const overrides = new Map([ // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for // retry-safety. Load-bearing reviewer-required change; queued to split. // Consolidation removed the legacy persona-card import/export codecs. - ["src-tauri/src/commands/personas/mod.rs", 984], + // internal-local-relay-lockdown round 5: preflight linked-agent profile updates. + ["src-tauri/src/commands/personas/mod.rs", 1018], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so // the two p-gate filters can't drift) plus two guard unit tests. The file was diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 2cbf6a4b3..40a0eddce 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1083,6 +1083,7 @@ pub async fn start_managed_agent( let reconcile = ProfileReconcileData { private_key_nsec: record.private_key_nsec.clone(), + backend: record.backend.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(), @@ -1325,7 +1326,7 @@ pub(crate) use deploy::resolve_deploy_model_provider; #[path = "agents_profile.rs"] mod profile; #[cfg(test)] -use profile::{profile_needs_sync, resolve_legacy_avatar}; +use profile::{profile_needs_sync, resolve_legacy_avatar, validate_profile_reconcile_relay_with}; pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 0675d4c48..cab45ec3f 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -11,6 +11,7 @@ use super::*; pub(crate) struct ProfileReconcileData { pub(crate) private_key_nsec: String, + pub(crate) backend: BackendKind, pub(crate) name: String, pub(crate) relay_url: String, /// Expected avatar URL for the published profile. `None` for legacy records @@ -49,6 +50,17 @@ pub(super) fn resolve_legacy_avatar( .unwrap_or_default() } +pub(super) fn validate_profile_reconcile_relay_with( + data: &ProfileReconcileData, + workspace_relay_url: &str, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&BackendKind, &str, &str) -> Result<(), String>, +{ + validate(&data.backend, &data.relay_url, workspace_relay_url) +} + /// Reconcile an agent's kind:0 profile on the relay. /// /// Queries the relay for the agent's existing profile and re-publishes if missing @@ -74,13 +86,6 @@ pub(crate) async fn reconcile_agent_profile( ) -> Result<(), String> { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; - // An explicit per-agent relay wins; an empty one falls back to the active - // workspace relay. Resolved once and used for both the read and write-back. - let relay_url = crate::relay::effective_agent_relay_url( - &data.relay_url, - &relay_ws_url_with_override(state), - ); - if !state .managed_agent_profile_reconcile_enabled .load(std::sync::atomic::Ordering::Acquire) @@ -88,6 +93,19 @@ pub(crate) async fn reconcile_agent_profile( return Ok(()); } + let workspace_relay_url = relay_ws_url_with_override(state); + validate_profile_reconcile_relay_with( + data, + &workspace_relay_url, + |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + }, + )?; + + // An explicit per-agent relay wins; an empty one falls back to the active + // workspace relay. Resolved once and used for both the read and write-back. + let relay_url = crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay_url); + // Query the relay for the agent's existing kind:0 profile. let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?; diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed17..ca443615e 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -414,3 +414,32 @@ fn deploy_payload_carries_the_full_behavioral_quad() { assert_eq!(payload["provider"], "openai"); assert_eq!(payload["relay_url"], "wss://relay.example"); } + +#[test] +fn profile_reconcile_preflights_legacy_empty_pin_before_relay_io() { + let data = ProfileReconcileData { + private_key_nsec: String::new(), + backend: BackendKind::Local, + name: "legacy".into(), + relay_url: String::new(), + avatar_url: None, + auth_tag: None, + pubkey: "pubkey".into(), + agent_command: "goose".into(), + persona_id: None, + }; + let calls = std::cell::Cell::new(0); + let result = validate_profile_reconcile_relay_with( + &data, + "wss://public.example", + |backend, pin, workspace| { + calls.set(calls.get() + 1); + assert_eq!(backend, &BackendKind::Local); + assert!(pin.is_empty()); + assert_eq!(workspace, "wss://public.example"); + Err("blocked before query".into()) + }, + ); + assert_eq!(result.unwrap_err(), "blocked before query"); + assert_eq!(calls.get(), 1); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 7752d5567..9e8c5f978 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -144,6 +144,24 @@ fn propagate_persona_name_rename( renamed } +fn validate_persona_profile_updates_with( + records: &[ManagedAgentRecord], + persona_id: &str, + old_display_name: &str, + name_changed: bool, + avatar_changed: bool, + mut validate: F, +) -> Result<(), String> +where + F: FnMut(&ManagedAgentRecord) -> Result<(), String>, +{ + records + .iter() + .filter(|record| record.persona_id.as_deref() == Some(persona_id)) + .filter(|record| avatar_changed || (name_changed && record.name == old_display_name)) + .try_for_each(&mut validate) +} + #[tauri::command] pub async fn update_persona( input: UpdatePersonaRequest, @@ -176,10 +194,32 @@ pub async fn update_persona( .find(|record| record.id == input.id) .ok_or_else(|| format!("agent {} not found", input.id))?; - // Track what changed so we can propagate to linked agent records. + // Track what changed so we can preflight every linked agent before + // mutating either store or enqueueing agent-keyed profile I/O. let avatar_changed = persona.avatar_url != avatar_url; let name_changed = persona.display_name != display_name; let old_display_name = persona.display_name.clone(); + let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + let mut linked_records = if avatar_changed || name_changed { + let records = load_managed_agents(&app)?; + validate_persona_profile_updates_with( + &records, + &persona.id, + &old_display_name, + name_changed, + avatar_changed, + |record| { + crate::managed_agents::validate_effective_local_agent_relay( + &record.backend, + &record.relay_url, + &workspace_relay, + ) + }, + )?; + Some(records) + } else { + None + }; persona.display_name = display_name; persona.avatar_url = avatar_url; @@ -209,11 +249,9 @@ pub async fn update_persona( // If the avatar or display_name changed, propagate to linked agent // records and collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed || name_changed { - let mut records = load_managed_agents(&app)?; + let sync_params: ProfileSyncParams = if let Some(mut records) = linked_records.take() { let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; - let workspace_relay = crate::relay::relay_ws_url_with_override(&state); // Propagate the display_name rename to instances that still // carry the old definition display_name (pool-named instances diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs index ba855ccbd..732b8291f 100644 --- a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs @@ -156,3 +156,44 @@ fn test_rename_renames_all_matching_instances_in_one_pass() { assert_eq!(records[1].name, "Duncan Idaho"); assert_eq!(records[2].name, "Birch", "pool-named instance untouched"); } + +#[test] +fn persona_profile_updates_preflight_only_records_that_would_mutate_or_publish() { + let mut records = vec![ + agent("persona-1", "Paul", Some("Paul")), + agent("persona-1", "Birch", Some("Birch")), + agent("persona-2", "Paul", Some("Paul")), + ]; + records[0].backend = crate::managed_agents::BackendKind::Local; + records[1].backend = crate::managed_agents::BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }; + let visited = std::cell::RefCell::new(Vec::new()); + + validate_persona_profile_updates_with(&records, "persona-1", "Paul", true, false, |record| { + visited.borrow_mut().push(record.pubkey.clone()); + Ok(()) + }) + .unwrap(); + assert_eq!(*visited.borrow(), vec!["pubkey-Paul"]); + + visited.borrow_mut().clear(); + validate_persona_profile_updates_with(&records, "persona-1", "Paul", false, true, |record| { + visited.borrow_mut().push(record.pubkey.clone()); + Ok(()) + }) + .unwrap(); + assert_eq!(*visited.borrow(), vec!["pubkey-Paul", "pubkey-Birch"]); +} + +#[test] +fn persona_profile_preflight_failure_happens_before_name_mutation() { + let records = vec![agent("persona-1", "Paul", Some("Paul"))]; + let result = + validate_persona_profile_updates_with(&records, "persona-1", "Paul", true, false, |_| { + Err("blocked".into()) + }); + assert_eq!(result.unwrap_err(), "blocked"); + assert_eq!(records[0].name, "Paul"); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index a9674cf6d..d0e0f658c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -288,6 +288,20 @@ pub async fn preview_agent_snapshot_import( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +pub(super) fn validate_snapshot_import_relay_with( + workspace_relay_url: &str, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>, +{ + validate( + &crate::managed_agents::BackendKind::Local, + "", + workspace_relay_url, + ) +} + // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── /// Import a `buzz-agent-snapshot v1` file as a brand-new agent. @@ -321,6 +335,13 @@ pub async fn confirm_agent_snapshot_import( return Err("Snapshot display name is empty.".to_string()); } + // Snapshot imports always mint a local agent with an empty relay pin. Reject + // a disallowed effective workspace relay before key generation or store I/O. + let workspace_relay_url = relay_ws_url_with_override(&state); + validate_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + })?; + // ── Resolve behavioral defaults ────────────────────────────────────────── let minted = resolve_snapshot_import_behavior( snapshot.definition.respond_to.as_deref(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b..f3d7bd828 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -1,6 +1,7 @@ use super::import::{ decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, - AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + validate_snapshot_import_relay_with, AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, + MAX_SNAPSHOT_PNG_BYTES, }; use super::*; use crate::managed_agents::{ @@ -969,3 +970,18 @@ fn validate_encode_size_png_over_boundary_is_rejected() { "error must mention size limit, got: {err}" ); } + +#[test] +fn individual_snapshot_import_preflights_empty_pin_before_mint_or_store() { + let calls = std::cell::Cell::new(0); + let result = + validate_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| { + calls.set(calls.get() + 1); + assert_eq!(backend, &BackendKind::Local); + assert!(pin.is_empty()); + assert_eq!(relay, "wss://public.example"); + Err("blocked before mutation".into()) + }); + assert_eq!(result.unwrap_err(), "blocked before mutation"); + assert_eq!(calls.get(), 1); +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 268b31583..2b3ab4fc6 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -496,6 +496,20 @@ pub async fn preview_team_snapshot_import( /// /// Importing the same file twice yields two distinct teams with different /// agent keypairs (same as individual agent import). +fn validate_team_snapshot_import_relay_with( + workspace_relay_url: &str, + validate: F, +) -> Result<(), String> +where + F: FnOnce(&crate::managed_agents::BackendKind, &str, &str) -> Result<(), String>, +{ + validate( + &crate::managed_agents::BackendKind::Local, + "", + workspace_relay_url, + ) +} + #[tauri::command] pub async fn confirm_team_snapshot_import( input: TeamSnapshotImportConfirm, @@ -506,6 +520,13 @@ pub async fn confirm_team_snapshot_import( let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?; let now = now_iso(); + // Team snapshots mint only empty-pin local agents. Preflight the workspace + // relay once before generating any member key or mutating any store. + let workspace_relay_url = relay_ws_url_with_override(&state); + validate_team_snapshot_import_relay_with(&workspace_relay_url, |backend, pin, workspace| { + crate::managed_agents::validate_effective_local_agent_relay(backend, pin, workspace) + })?; + // Resolve behavioral defaults for every member before any key generation. let definitions = build_import_definitions(&snapshot, input.keep_allowlist, &now)?; let persona_ids: Vec = definitions.iter().map(|d| d.id.clone()).collect(); diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc6183..dd085f048 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -724,3 +724,18 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { assert!(!teams_path.exists()); assert_eq!(errors.len(), 1, "only the teams-write error"); } + +#[test] +fn team_snapshot_import_preflights_empty_pin_once_before_mint_or_store() { + let calls = std::cell::Cell::new(0); + let result = + validate_team_snapshot_import_relay_with("wss://public.example", |backend, pin, relay| { + calls.set(calls.get() + 1); + assert_eq!(backend, &crate::managed_agents::BackendKind::Local); + assert!(pin.is_empty()); + assert_eq!(relay, "wss://public.example"); + Err("blocked before mutation".into()) + }); + assert_eq!(result.unwrap_err(), "blocked before mutation"); + assert_eq!(calls.get(), 1); +} diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index fab481e12..8b2d601d0 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -422,6 +422,7 @@ pub async fn restore_managed_agents_on_launch( pubkey.clone(), crate::commands::ProfileReconcileData { private_key_nsec: record.private_key_nsec.clone(), + backend: record.backend.clone(), name: record.name.clone(), relay_url: record.relay_url.clone(), avatar_url: record.avatar_url.clone(),