diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..a9e01d97ea 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1188,11 +1188,15 @@ pub async fn start_managed_agent( // profile is published on the relay. This self-heals cases where the initial // profile sync at creation time failed silently. For legacy records (pre-PR-921) // with no persisted avatar, this also backfills the avatar from the relay. - if result.is_ok() - && state - .managed_agent_profile_reconcile_enabled - .load(std::sync::atomic::Ordering::Acquire) - { + // Called whenever the start succeeded, regardless of the + // `agentManagedProfiles` experiment. `reconcile_agent_profile` publishes the + // kind:10100 directory record first and *then* re-checks that flag before + // touching kind:0, so the experiment still decides profile-metadata + // ownership while agent discovery keeps working either way. Gating here + // instead would leave remote agents unmentionable for exactly the users who + // enabled the experiment — and nothing else in the product publishes that + // record. + if result.is_ok() { let reconcile_pubkey = pubkey.clone(); let reconcile_app = app.clone(); tauri::async_runtime::spawn(async move { @@ -1218,7 +1222,9 @@ pub async fn stop_managed_agent( app: AppHandle, ) -> Result { use tauri::Manager; - tokio::task::spawn_blocking(move || { + let directory_app = app.clone(); + let directory_pubkey = pubkey.clone(); + let summary = tokio::task::spawn_blocking(move || { let state = app.state::(); let _store_guard = state .managed_agents_store_lock @@ -1267,7 +1273,15 @@ pub async fn stop_managed_agent( ) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + // The agent is no longer running, so refresh its directory record to + // `offline`. Other clients read that record to decide whether to offer the + // agent in mentions; without this they would keep offering an agent that + // cannot answer. + profile::spawn_offline_directory_update(&directory_app, &directory_pubkey); + + Ok(summary) } // Async so the blocking body (disk reads/writes, process termination, keyring @@ -1367,8 +1381,11 @@ 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::{ + build_agent_directory_content, channel_ids_from_member_events, channel_names_from_meta_events, + existing_channel_add_policy, profile_needs_sync, resolve_legacy_avatar, +}; +pub(crate) use profile::{publish_agent_directory, reconcile_agent_profile, 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..f2438d0b50 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -81,6 +81,27 @@ pub(crate) async fn reconcile_agent_profile( &relay_ws_url_with_override(state), ); + // ── kind:10100 agent directory record ─────────────────────────────────── + // Published *before* both gates below, deliberately. + // + // The `managed_agent_profile_reconcile_enabled` guard tracks the + // `agentManagedProfiles` experiment, which decides whether the desktop or + // the agent itself owns the agent's kind:0 profile metadata. The directory + // record is a different concern — discovery, not metadata — and nothing + // else in the product publishes it. Gating it on that experiment would + // leave remote agents undiscoverable for exactly the users who opted in. + // + // It also precedes the kind:0 staleness check further down: that check asks + // whether profile metadata diverged, and for a steady-state agent the + // answer is no, so anything behind it never runs for the agents that most + // need announcing. The directory record must refresh on every start. + // + // Failures are logged, not propagated: a directory publish that cannot + // reach the relay must not prevent kind:0 reconciliation or block startup. + if let Err(e) = publish_agent_directory(state, app, &relay_url, agent_pubkey, "online").await { + eprintln!("buzz-desktop: agent directory publish failed for {agent_pubkey}: {e}"); + } + if !state .managed_agent_profile_reconcile_enabled .load(std::sync::atomic::Ordering::Acquire) @@ -161,6 +182,264 @@ pub(crate) async fn reconcile_agent_profile( .await } +/// Publish the agent's kind:10100 directory record for the current state. +/// +/// Reads the agent's channel memberships from the relay, projects the +/// publishable subset of its record, and publishes the result signed by the +/// agent's own key. `status` is the liveness the record should advertise — +/// `"online"` when the agent is starting, `"offline"` when it has stopped. +/// +/// Both existing `reconcile_agent_profile` call sites (agent create/start and +/// boot restore) reach this through that function, so no new call site is +/// needed for an agent to appear in the directory. +pub(crate) async fn publish_agent_directory( + state: &AppState, + app: &AppHandle, + relay_url: &str, + agent_pubkey: &str, + status: &str, +) -> Result<(), String> { + let record = load_managed_agents(app)? + .into_iter() + .find(|r| r.pubkey == agent_pubkey) + .ok_or_else(|| format!("no managed agent record for {agent_pubkey}"))?; + + // The record must be signed by the agent, not its owner — see + // `relay::publish_agent_directory_record`. `load_managed_agents` rehydrates + // the nsec from the keyring, so this is populated even though it is blanked + // in the on-disk JSON. + let agent_keys = Keys::parse(&record.private_key_nsec) + .map_err(|e| format!("failed to parse agent keys: {e}"))?; + + let api_base = crate::relay::relay_http_base_url(relay_url); + let auth_tag = record.auth_tag.as_deref(); + + // Channels the agent belongs to: kind:39002 membership events tagging it, + // whose `d` tag carries the channel id. Queried as the agent so the read is + // scoped to what the agent itself can see. + let member_events = crate::relay::query_relay_at_with_keys( + state, + &api_base, + &[serde_json::json!({"kinds": [39002], "#p": [agent_pubkey]})], + &agent_keys, + auth_tag, + ) + .await?; + let channel_ids = channel_ids_from_member_events(&member_events); + + // kind:39000 is addressable — exactly one event per `d` tag — so a limit + // equal to the id count is both necessary and sufficient. Mirrors + // `get_channels`. + let channel_names = if channel_ids.is_empty() { + Vec::new() + } else { + let meta_events = crate::relay::query_relay_at_with_keys( + state, + &api_base, + &[serde_json::json!({ + "kinds": [39000], + "#d": channel_ids, + "limit": channel_ids.len(), + })], + &agent_keys, + auth_tag, + ) + .await?; + channel_names_from_meta_events(&meta_events, &channel_ids) + }; + + // `channel_add_policy` is required by the relay's side-effect handler for + // this kind, and that handler writes the value straight into + // `users.channel_add_policy`. So the agent's existing policy is read back + // and re-sent verbatim: hardcoding a value here would reset a deliberate + // `owner_only` or `nobody` to `anyone` on every single agent start. + let prior_records = crate::relay::query_relay_at_with_keys( + state, + &api_base, + &[serde_json::json!({ + "kinds": [buzz_sdk_pkg::kind::KIND_AGENT_PROFILE], + "authors": [agent_pubkey], + "limit": 1, + })], + &agent_keys, + auth_tag, + ) + .await + .unwrap_or_default(); + let channel_add_policy = existing_channel_add_policy(&prior_records); + + let content = build_agent_directory_content( + &record, + &channel_ids, + &channel_names, + status, + &channel_add_policy, + ); + + crate::relay::publish_agent_directory_record(state, relay_url, &agent_keys, &content, auth_tag) + .await +} + +/// Refresh a stopped agent's directory record so other clients stop offering +/// it in their mention autocomplete. +/// +/// Fire-and-forget, mirroring the profile-reconcile pattern on the start path: +/// a directory refresh that cannot reach the relay must never fail the stop the +/// user asked for. kind:10100 is replaceable, so this is a plain re-publish +/// with the status flipped — no tombstone. +pub(crate) fn spawn_offline_directory_update(app: &AppHandle, agent_pubkey: &str) { + let app = app.clone(); + let agent_pubkey = agent_pubkey.to_string(); + tauri::async_runtime::spawn(async move { + use tauri::Manager; + let state = app.state::(); + let relay_url = relay_ws_url_with_override(&state); + if let Err(e) = + publish_agent_directory(&state, &app, &relay_url, &agent_pubkey, "offline").await + { + eprintln!( + "buzz-desktop: offline directory update failed for agent {agent_pubkey}: {e}" + ); + } + }); +} + +/// The relay's default `channel_add_policy`, matching the schema default on +/// `users.channel_add_policy`. Sending this for an agent that has never +/// published a directory record leaves the stored policy exactly as it was. +const DEFAULT_CHANNEL_ADD_POLICY: &str = "anyone"; + +/// Recover the `channel_add_policy` an agent already published, if any. +/// +/// This must be preserved rather than hardcoded. The relay's side effect for +/// kind:10100 writes whatever the record carries straight into +/// `users.channel_add_policy`, so publishing a fixed value would silently +/// rewrite an operator's deliberate `owner_only` or `nobody` choice every time +/// the agent starts. A missing or malformed prior record falls back to the +/// schema default, which is a no-op against a never-configured agent. +pub(super) fn existing_channel_add_policy(events: &[nostr::Event]) -> String { + events + .iter() + .find_map(|ev| { + serde_json::from_str::(&ev.content) + .ok()? + .get("channel_add_policy")? + .as_str() + .map(str::to_string) + }) + .unwrap_or_else(|| DEFAULT_CHANNEL_ADD_POLICY.to_string()) +} + +/// Extract channel ids from kind:39002 membership events. +/// +/// The channel id lives in the event's `d` tag. An event without one is skipped +/// rather than failing the batch — one malformed membership record must not +/// stop an agent from announcing the channels it *is* in. +pub(super) fn channel_ids_from_member_events(events: &[nostr::Event]) -> Vec { + let mut ids: Vec = events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let slice = t.as_slice(); + if slice.len() >= 2 && slice[0] == "d" { + Some(slice[1].clone()) + } else { + None + } + }) + }) + .collect(); + ids.sort(); + ids.dedup(); + ids +} + +/// Resolve display names for `channel_ids` from kind:39000 metadata events, +/// falling back to the id when a channel has no readable metadata. Positional: +/// the returned vector lines up with `channel_ids`. +/// +/// kind:39000 carries the channel id in its `d` tag and the human-readable name +/// in a `name` tag; `content` is empty on these events, so the name must be read +/// from the tags rather than parsed out of the body. +pub(super) fn channel_names_from_meta_events( + events: &[nostr::Event], + channel_ids: &[String], +) -> Vec { + let names: std::collections::HashMap = events + .iter() + .filter_map(|ev| { + let mut channel_id = None; + let mut name = None; + for tag in ev.tags.iter() { + let slice = tag.as_slice(); + if slice.len() < 2 { + continue; + } + match slice[0].as_str() { + "d" => channel_id = Some(slice[1].clone()), + "name" => name = Some(slice[1].clone()), + _ => {} + } + } + Some((channel_id?, name?)) + }) + .collect(); + + channel_ids + .iter() + .map(|id| names.get(id).cloned().unwrap_or_else(|| id.clone())) + .collect() +} + +/// Build the world-readable content for an agent's kind:10100 directory record. +/// +/// The relay serves kind:10100 to any authenticated member, so this is an +/// explicit allowlist of publishable fields rather than a projection of the +/// managed-agent record. Anything absent here is absent deliberately — in +/// particular the agent's nsec, its NIP-OA auth tag, its environment +/// variables, and its runtime/harness configuration must never leave the host. +/// +/// `pubkey` is carried for readability only. `agents_from_events` overwrites it +/// with the event author, which is what makes a record's identity derive from +/// its signature rather than from a self-declared field — and is why only the +/// hosting machine, which holds the agent's key, can publish one. +/// +/// `capabilities` is currently always empty: a managed agent record declares no +/// capability set, and the client already defaults the field when it is absent. +pub(super) fn build_agent_directory_content( + record: &ManagedAgentRecord, + channel_ids: &[String], + channel_names: &[String], + status: &str, + channel_add_policy: &str, +) -> serde_json::Value { + let name = record + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&record.name); + + let agent_type = if record.agent_command.trim().is_empty() { + "agent" + } else { + record.agent_command.trim() + }; + + serde_json::json!({ + "pubkey": record.pubkey, + "name": name, + "agent_type": agent_type, + "channels": channel_names, + "channel_ids": channel_ids, + "capabilities": Vec::::new(), + "status": status, + "respond_to": record.respond_to.as_str(), + "respond_to_allowlist": record.respond_to_allowlist, + "channel_add_policy": channel_add_policy, + }) +} + /// Decide whether a published profile is missing or stale relative to the /// expected name and avatar. A missing profile always needs sync; a present /// one is stale when either the display name or picture diverges. diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 03389d1d18..7c7009ea36 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -445,3 +445,208 @@ fn deploy_payload_carries_the_full_behavioral_quad() { assert_eq!(payload["provider"], "openai"); assert_eq!(payload["relay_url"], "wss://relay.example"); } + +// ── kind:10100 agent directory record ─────────────────────────────────────── +// +// The record is world-readable, so its content is an explicit allowlist of +// publishable fields rather than a dump of the agent record. These tests pin +// both halves of that contract: the fields a remote client needs to decide +// mentionability, and the fields that must never leave the host. + +fn directory_agent_record() -> ManagedAgentRecord { + use crate::managed_agents::RespondTo; + let mut record = bare_agent_record(None, Some("gpt-5"), Some("openai")); + record.pubkey = "a".repeat(64); + record.name = "Tester".to_string(); + record.display_name = Some("Tester".to_string()); + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec![]; + record.private_key_nsec = "nsec1supersecret".to_string(); + record.auth_tag = Some(r#"{"tag":"secret-attestation"}"#.to_string()); + record.system_prompt = Some("you are a helpful agent".to_string()); + record + .env_vars + .insert("OPENAI_API_KEY".to_string(), "sk-secret".to_string()); + record +} + +#[test] +fn directory_record_carries_the_fields_a_remote_client_needs() { + let record = directory_agent_record(); + let content = build_agent_directory_content( + &record, + &["chan-1".to_string(), "chan-2".to_string()], + &["general".to_string(), "agents".to_string()], + "online", + "anyone", + ); + + assert_eq!(content["name"], "Tester"); + assert_eq!(content["status"], "online"); + assert_eq!(content["respond_to"], "anyone"); + assert_eq!(content["channel_add_policy"], "anyone"); + assert_eq!( + content["channel_ids"], + serde_json::json!(["chan-1", "chan-2"]) + ); + assert_eq!( + content["channels"], + serde_json::json!(["general", "agents"]) + ); + assert!(content["agent_type"].is_string()); + assert!(content["capabilities"].is_array()); + assert!(content["respond_to_allowlist"].is_array()); +} + +#[test] +fn directory_record_carries_the_allowlist_when_respond_to_is_allowlist() { + use crate::managed_agents::RespondTo; + let mut record = directory_agent_record(); + record.respond_to = RespondTo::Allowlist; + record.respond_to_allowlist = vec!["b".repeat(64)]; + + let content = build_agent_directory_content(&record, &[], &[], "online", "anyone"); + + assert_eq!(content["respond_to"], "allowlist"); + assert_eq!( + content["respond_to_allowlist"], + serde_json::json!([&"b".repeat(64)]) + ); +} + +#[test] +fn directory_record_reports_a_stopped_agent_as_offline() { + let record = directory_agent_record(); + let content = build_agent_directory_content(&record, &[], &[], "offline", "anyone"); + + assert_eq!(content["status"], "offline"); +} + +#[test] +fn directory_record_never_leaks_the_agents_secret_key() { + let record = directory_agent_record(); + let serialized = + build_agent_directory_content(&record, &[], &[], "online", "anyone").to_string(); + + assert!(!serialized.contains("nsec1supersecret")); + assert!(!serialized.contains("private_key")); +} + +#[test] +fn directory_record_never_leaks_the_auth_attestation() { + let record = directory_agent_record(); + let serialized = + build_agent_directory_content(&record, &[], &[], "online", "anyone").to_string(); + + assert!(!serialized.contains("secret-attestation")); + assert!(!serialized.contains("auth_tag")); +} + +#[test] +fn directory_record_never_leaks_environment_variables() { + let record = directory_agent_record(); + let serialized = + build_agent_directory_content(&record, &[], &[], "online", "anyone").to_string(); + + assert!(!serialized.contains("OPENAI_API_KEY")); + assert!(!serialized.contains("sk-secret")); +} + +#[test] +fn directory_record_never_leaks_runtime_configuration() { + let record = directory_agent_record(); + let serialized = + build_agent_directory_content(&record, &[], &[], "online", "anyone").to_string(); + + assert!(!serialized.contains("you are a helpful agent")); + assert!(!serialized.contains("system_prompt")); + assert!(!serialized.contains("acp_command")); + assert!(!serialized.contains("agent_args")); +} + +#[test] +fn channel_ids_come_from_the_d_tags_of_membership_events() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let keys = Keys::generate(); + let build = |d: &str| { + EventBuilder::new(Kind::Custom(39002), "") + .tags([Tag::parse(["d", d]).expect("d tag")]) + .sign_with_keys(&keys) + .expect("sign") + }; + + // Two distinct channels, one duplicated, and one event with no `d` tag at + // all — a malformed record must be skipped, not abort the whole publish. + let untagged = EventBuilder::new(Kind::Custom(39002), "") + .sign_with_keys(&keys) + .expect("sign"); + let events = vec![build("chan-b"), build("chan-a"), build("chan-b"), untagged]; + + assert_eq!( + channel_ids_from_member_events(&events), + vec!["chan-a".to_string(), "chan-b".to_string()] + ); +} + +#[test] +fn existing_channel_add_policy_is_preserved_from_the_agents_prior_record() { + use nostr::{EventBuilder, Keys, Kind}; + + let keys = Keys::generate(); + let prior = EventBuilder::new( + Kind::Custom(10100), + r#"{"name":"Tester","channel_add_policy":"owner_only"}"#, + ) + .sign_with_keys(&keys) + .expect("sign"); + + assert_eq!(existing_channel_add_policy(&[prior]), "owner_only"); +} + +#[test] +fn channel_add_policy_falls_back_to_the_schema_default_when_never_published() { + // The relay column defaults to 'anyone'; sending that for an agent with no + // prior record leaves the stored policy exactly as it already was. + assert_eq!(existing_channel_add_policy(&[]), "anyone"); +} + +#[test] +fn channel_add_policy_falls_back_when_the_prior_record_is_malformed() { + use nostr::{EventBuilder, Keys, Kind}; + + let keys = Keys::generate(); + let junk = EventBuilder::new(Kind::Custom(10100), "not json") + .sign_with_keys(&keys) + .expect("sign"); + + assert_eq!(existing_channel_add_policy(&[junk]), "anyone"); +} + +#[test] +fn channel_names_come_from_the_name_tag_of_metadata_events() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let keys = Keys::generate(); + // kind:39000 carries the channel name in a `name` tag; `content` is empty. + let meta = EventBuilder::new(Kind::Custom(39000), "") + .tags([ + Tag::parse(["d", "chan-a"]).expect("d"), + Tag::parse(["name", "general"]).expect("name"), + ]) + .sign_with_keys(&keys) + .expect("sign"); + + assert_eq!( + channel_names_from_meta_events(&[meta], &["chan-a".to_string()]), + vec!["general".to_string()] + ); +} + +#[test] +fn channel_name_falls_back_to_the_id_when_metadata_is_missing() { + assert_eq!( + channel_names_from_meta_events(&[], &["chan-zz".to_string()]), + vec!["chan-zz".to_string()] + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..37bc930a91 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -306,6 +306,35 @@ fn start_pair( drop(runtimes); save_managed_agents(&app, &records)?; emit_status(&app, &status); + + // ── kind:10100 agent directory record ─────────────────────────────────── + // This is the runtime-level start path the renderer drives, and it does not + // go through `start_local_agent_with_preflight`, so it never reaches + // `reconcile_agent_profile`. Without publishing here, an agent started this + // way stays invisible to every other client on the relay — which is the + // common case, not an edge case. + // + // Fire-and-forget on the async runtime: `start_pair` is sync and has + // released its locks by this point, and a directory publish must never fail + // or delay the start the user asked for. + let directory_app = app.clone(); + let directory_pubkey = key.pubkey.clone(); + let directory_relay = key.relay_url.clone(); + tauri::async_runtime::spawn(async move { + let state = directory_app.state::(); + if let Err(e) = crate::commands::publish_agent_directory( + &state, + &directory_app, + &directory_relay, + &directory_pubkey, + "online", + ) + .await + { + eprintln!("buzz-desktop: agent directory publish failed for {directory_pubkey}: {e}"); + } + }); + Ok(status) } diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f896695624..6b9f0c67b6 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -478,6 +478,62 @@ pub async fn sync_managed_agent_profile( Ok(()) } +/// Publish an agent's kind:10100 directory record, signed by the agent's own +/// key. +/// +/// The signature is the point: `agents_from_events` overwrites the pubkey in +/// the content with the event author, so a record can only speak for the +/// identity that signed it. That is why this can run only on the machine +/// holding the agent's key, and why one agent cannot publish a directory entry +/// impersonating another. +/// +/// kind:10100 is replaceable and keyed by pubkey, so refreshing is a plain +/// re-publish with no tombstone. +pub async fn publish_agent_directory_record( + state: &AppState, + relay_url: &str, + agent_keys: &Keys, + content: &serde_json::Value, + auth_tag: Option<&str>, +) -> Result<(), String> { + crate::relay_admission::wait_for_rate_limit().await; + + let event = EventBuilder::new( + Kind::Custom(buzz_sdk_pkg::kind::KIND_AGENT_PROFILE as u16), + content.to_string(), + ) + .tags([]) + .sign_with_keys(agent_keys) + .map_err(|e| format!("failed to sign agent directory event: {e}"))?; + + let body_bytes = event.as_json().into_bytes(); + let url = format!("{}/events", relay_http_base_url(relay_url)); + let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; + + let mut request = state + .http_client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } + let response = request + .body(body_bytes) + .send() + .await + .map_err(|e| classify_request_error(&e))?; + + if !response.status().is_success() { + let msg = relay_error_message(response).await; + return Err(format!( + "Could not publish the agent directory record: {msg}" + )); + } + + Ok(()) +} + // ── Agent profile query ───────────────────────────────────────────────────── /// Query the relay for an agent's kind:0 profile event.