Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
131 changes: 105 additions & 26 deletions desktop/src-tauri/src/commands/agents_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ pub(crate) struct ProfileReconcileData {
pub(crate) persona_id: Option<String>,
}

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`).
///
Expand All @@ -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<String, String> {
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
Expand All @@ -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.
Expand All @@ -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()
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
Expand Down
32 changes: 31 additions & 1 deletion desktop/src-tauri/src/managed_agents/runtime_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<AppState>();
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(
Expand Down
66 changes: 38 additions & 28 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentProfileInfo> {
let event = events.first()?;
let content = serde_json::from_str::<serde_json::Value>(&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,
Expand All @@ -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::<serde_json::Value>(&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<Option<AgentProfileInfo>, 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.
Expand Down
Loading