From 101bcf5b0e2f43c77576722bb3e8ea75125849c7 Mon Sep 17 00:00:00 2001 From: npub1yqxl8fhpqxfzquefhxee324mskczp6ygqftu6kvrt3hp4x2fzntqvmcw9s <200df3a6e10192207329b9b398aabb85b020e8880257cd59835c6e1a994914d6@sprout-oss.stage.blox.sqprod.co> Date: Thu, 23 Jul 2026 15:56:06 -0700 Subject: [PATCH] fix(desktop): fan out managed agent profiles Reconcile each successfully started managed-agent runtime pair's kind-0 profile on that community's requested relay authority. Query and publish as the agent, and detach profile repair so relay failures cannot block runtime startup. Co-authored-by: Smarty <200df3a6e10192207329b9b398aabb85b020e8880257cd59835c6e1a994914d6@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1yqxl8fhpqxfzquefhxee324mskczp6ygqftu6kvrt3hp4x2fzntqvmcw9s <200df3a6e10192207329b9b398aabb85b020e8880257cd59835c6e1a994914d6@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1yqxl8fhpqxfzquefhxee324mskczp6ygqftu6kvrt3hp4x2fzntqvmcw9s <200df3a6e10192207329b9b398aabb85b020e8880257cd59835c6e1a994914d6@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/agents.rs | 6 +- .../src-tauri/src/commands/agents_profile.rs | 131 ++++++++++++++---- .../src-tauri/src/commands/agents_tests.rs | 18 +++ .../src/managed_agents/runtime_commands.rs | 32 ++++- desktop/src-tauri/src/relay.rs | 66 +++++---- 5 files changed, 196 insertions(+), 57 deletions(-) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c65900b5de..fdaec1533b 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1329,8 +1329,10 @@ pub(crate) use deploy::resolve_deploy_model_provider; #[path = "agents_profile.rs"] mod profile; #[cfg(test)] -use profile::{profile_needs_sync, resolve_legacy_avatar}; -pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; +use profile::{profile_needs_sync, resolve_legacy_avatar, validate_profile_relay}; +pub(crate) use profile::{ + reconcile_agent_profile, reconcile_agent_profile_at_relay, ProfileReconcileData, +}; #[cfg(test)] #[path = "agents_tests.rs"] diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 0675d4c48f..bc6e3b351c 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -30,6 +30,24 @@ pub(crate) struct ProfileReconcileData { pub(crate) persona_id: Option, } +impl ProfileReconcileData { + pub(crate) fn from_record( + record: &crate::managed_agents::ManagedAgentRecord, + personas: &[crate::managed_agents::AgentDefinition], + ) -> Self { + Self { + private_key_nsec: record.private_key_nsec.clone(), + name: record.name.clone(), + relay_url: record.relay_url.clone(), + avatar_url: record.avatar_url.clone(), + auth_tag: record.auth_tag.clone(), + pubkey: record.pubkey.clone(), + agent_command: crate::managed_agents::record_agent_command(record, personas), + persona_id: record.persona_id.clone(), + } + } +} + /// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no /// stored `avatar_url`). /// @@ -49,37 +67,71 @@ pub(super) fn resolve_legacy_avatar( .unwrap_or_default() } -/// Reconcile an agent's kind:0 profile on the relay. -/// -/// Queries the relay for the agent's existing profile and re-publishes if missing -/// or stale (display_name or picture mismatch). This is fire-and-forget — errors -/// are returned to the caller for logging but never block agent startup. +/// Reconcile an agent's kind:0 profile on its legacy effective relay. /// -/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function -/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar -/// over the relay's `picture`, since the old code may have corrupted the relay -/// profile — and persists the updated record. After backfill, normal -/// reconciliation proceeds. -/// -/// Query and publish target the relay returned by `effective_agent_relay_url` -/// for every agent regardless of backend: an explicit per-agent `relay_url` -/// wins, and a blank one falls back to the active workspace relay. This keeps -/// reconciliation following the session's relay for never-pinned agents while -/// honoring a deliberate pin wherever it points. +/// UI-start and legacy restore callers still use the record/workspace relay +/// selection. Pair-scoped runtime reconciliation must instead call +/// [`reconcile_agent_profile_at_relay`] with the community relay explicitly. pub(crate) async fn reconcile_agent_profile( state: &AppState, app: &AppHandle, agent_pubkey: &str, data: &ProfileReconcileData, ) -> 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), ); + reconcile_agent_profile_at_relay(state, app, agent_pubkey, &relay_url, data, true).await +} + +pub(super) fn profile_query_uses_agent_keys(auth_tag: Option<&str>) -> bool { + auth_tag.is_some() +} + +pub(super) fn validate_profile_relay(relay_url: &str) -> Result { + let relay_url = relay_url.trim().trim_end_matches('/'); + let parsed = reqwest::Url::parse(relay_url).map_err(|e| format!("invalid relay URL: {e}"))?; + if parsed.scheme() != "ws" && parsed.scheme() != "wss" { + return Err("relay URL must use ws or wss".into()); + } + if parsed.host().is_none() || !parsed.username().is_empty() || parsed.password().is_some() { + return Err("invalid relay URL authority".into()); + } + let scheme_len = relay_url + .find("://") + .ok_or_else(|| "relay URL must include a scheme".to_string())?; + let mut normalized = relay_url.to_string(); + normalized.replace_range(..scheme_len, parsed.scheme()); + Ok(normalized) +} + +/// Reconcile an agent's kind:0 profile on one explicit relay. +/// +/// Queries that relay for the agent's existing profile and re-publishes if +/// missing or stale (display_name or picture mismatch). Errors are returned to +/// the caller so a multi-relay caller can isolate failures without blocking the +/// remaining relays or agent startup. +/// +/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function +/// resolves the expected avatar via `resolve_legacy_avatar`. Callers on the +/// agent's legacy effective relay may opt into persisting that backfill. Relay +/// fan-out callers must not persist it: concurrent destination-relay reads can +/// disagree, and a relay-local result is not authoritative for the single +/// stored agent record. +pub(crate) async fn reconcile_agent_profile_at_relay( + state: &AppState, + app: &AppHandle, + agent_pubkey: &str, + relay_url: &str, + data: &ProfileReconcileData, + persist_legacy_avatar: bool, +) -> Result<(), String> { + use crate::relay::{ + query_agent_profile, query_agent_profile_with_keys, sync_managed_agent_profile, + }; + + let relay_url = validate_profile_relay(relay_url)?; if !state .managed_agent_profile_reconcile_enabled @@ -88,8 +140,30 @@ pub(crate) async fn reconcile_agent_profile( return Ok(()); } - // Query the relay for the agent's existing kind:0 profile. - let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?; + let agent_keys = if profile_query_uses_agent_keys(data.auth_tag.as_deref()) { + Some( + Keys::parse(&data.private_key_nsec) + .map_err(|e| format!("failed to parse agent keys: {e}"))?, + ) + } else { + None + }; + + // NIP-OA agents query as themselves with owner delegation. Pre-NIP-OA + // records have no auth tag and retain the legacy owner-authenticated read: + // on closed relays the owner may be admitted while the old agent is not. + let existing = if let Some(agent_keys) = agent_keys.as_ref() { + query_agent_profile_with_keys( + state, + &relay_url, + agent_pubkey, + agent_keys, + data.auth_tag.as_deref(), + ) + .await? + } else { + query_agent_profile(state, &relay_url, agent_pubkey).await? + }; // Resolve the expected avatar — backfilling for legacy records that have no // stored avatar_url yet. @@ -113,8 +187,10 @@ pub(crate) async fn reconcile_agent_profile( &data.agent_command, ); - // Persist the backfilled avatar so this migration only runs once. - if !backfilled.is_empty() { + // Only the legacy single-relay path may persist this migration. + // Fan-out tasks can observe different relay-local pictures and must + // not race to update the one global agent record. + if persist_legacy_avatar && !backfilled.is_empty() { let _store_guard = state .managed_agents_store_lock .lock() @@ -140,8 +216,11 @@ pub(crate) async fn reconcile_agent_profile( return Ok(()); } - let agent_keys = Keys::parse(&data.private_key_nsec) - .map_err(|e| format!("failed to parse agent keys: {e}"))?; + let agent_keys = match agent_keys { + Some(keys) => keys, + None => Keys::parse(&data.private_key_nsec) + .map_err(|e| format!("failed to parse agent keys: {e}"))?, + }; if !state .managed_agent_profile_reconcile_enabled diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179..229498eb9e 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -268,6 +268,24 @@ fn profile(name: Option<&str>, picture: Option<&str>) -> crate::relay::AgentProf } } +#[test] +fn profile_query_preserves_owner_auth_for_pre_nip_oa_agents() { + assert!(!profile::profile_query_uses_agent_keys(None)); + assert!(profile::profile_query_uses_agent_keys(Some("auth-tag"))); +} + +#[test] +fn explicit_profile_relay_preserves_loopback_authority() { + let relay = validate_profile_relay(" WS://localhost:3000/ ").unwrap(); + assert_eq!(relay, "ws://localhost:3000"); +} + +#[test] +fn explicit_profile_relay_rejects_invalid_target() { + assert!(validate_profile_relay("not a relay").is_err()); + assert!(validate_profile_relay("https://relay.example").is_err()); +} + #[test] fn profile_needs_sync_when_missing() { assert!(profile_needs_sync(None, "Duncan", Some("https://x/a.png"))); diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..f13b145fbe 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -510,8 +510,38 @@ pub async fn reconcile_managed_agent_runtimes( app.clone(), ) { Ok(mut status) => { - status.requested_relay_url = Some(requested); + status.requested_relay_url = Some(requested.clone()); rows.push(status); + + // Profile reconciliation is detached from runtime + // startup: a slow or failed relay request must never + // delay this pair or the rest of the batch. + let reconcile_app = app.clone(); + let reconcile_pubkey = record.pubkey.clone(); + let reconcile_relay = requested.clone(); + let reconcile_data = + crate::commands::ProfileReconcileData::from_record( + &record, + &personas, + ); + tauri::async_runtime::spawn(async move { + let state = reconcile_app.state::(); + if let Err(error) = + crate::commands::reconcile_agent_profile_at_relay( + &state, + &reconcile_app, + &reconcile_pubkey, + &reconcile_relay, + &reconcile_data, + false, + ) + .await + { + eprintln!( + "buzz-desktop: profile reconciliation failed for agent {reconcile_pubkey} on {reconcile_relay}: {error}" + ); + } + }); } Err(error) => { let mut status = status_for_with( diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095a..9aba32191c 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -480,15 +480,21 @@ pub async fn sync_managed_agent_profile( // ── Agent profile query ───────────────────────────────────────────────────── -/// Query the relay for an agent's kind:0 profile event. -/// -/// Queries the relay identified by `relay_url`. Callers uniformly pass the -/// relay resolved by `effective_agent_relay_url` for every agent regardless of -/// backend — always the active workspace relay — so the query targets the host -/// the profile is actually published to. -/// -/// Returns the parsed profile content (display_name, picture) if a kind:0 event -/// exists for the given pubkey, or `None` if no profile is published. +fn parse_agent_profile_events(events: &[nostr::Event]) -> Option { + let event = events.first()?; + let content = serde_json::from_str::(&event.content).ok()?; + Some(AgentProfileInfo { + display_name: content + .get("display_name") + .and_then(|v| v.as_str()) + .map(str::to_string), + picture: content + .get("picture") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) +} + pub async fn query_agent_profile( state: &AppState, relay_url: &str, @@ -499,27 +505,31 @@ pub async fn query_agent_profile( "kinds": [0], "limit": 1 }); - let events = query_relay_at(state, &relay_http_base_url(relay_url), &[filter]).await?; + Ok(parse_agent_profile_events(&events)) +} - let Some(event) = events.first() else { - return Ok(None); - }; - - let Ok(content) = serde_json::from_str::(&event.content) else { - return Ok(None); - }; - - Ok(Some(AgentProfileInfo { - display_name: content - .get("display_name") - .and_then(|v| v.as_str()) - .map(str::to_string), - picture: content - .get("picture") - .and_then(|v| v.as_str()) - .map(str::to_string), - })) +pub async fn query_agent_profile_with_keys( + state: &AppState, + relay_url: &str, + agent_pubkey: &str, + keys: &Keys, + auth_tag: Option<&str>, +) -> Result, String> { + let filter = serde_json::json!({ + "authors": [agent_pubkey], + "kinds": [0], + "limit": 1 + }); + let events = query_relay_at_with_keys( + state, + &relay_http_base_url(relay_url), + &[filter], + keys, + auth_tag, + ) + .await?; + Ok(parse_agent_profile_events(&events)) } /// Parsed fields from a kind:0 profile event.