diff --git a/desktop/src-tauri/src/commands/agent_registration.rs b/desktop/src-tauri/src/commands/agent_registration.rs new file mode 100644 index 00000000000..3a9cd257e90 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_registration.rs @@ -0,0 +1,664 @@ +//! Register an independently operated agent under the current owner. +//! +//! This path publishes only the owner-authored kind:30177 directory/policy +//! record. It never generates, imports, or persists the agent's private key, +//! and it never creates a local managed-agent runtime record. + +use buzz_core_pkg::kind::KIND_MANAGED_AGENT; +use nostr::{JsonUtil, PublicKey}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_events::{build_agent_event_from_content, ManagedAgentEventContent}, + persona_events::monotonic_created_at, + retention::{ + get_retained_event, mark_synced, open_retention_db, retain_event, RetainedEvent, + RetentionScope, + }, + validate_respond_to_allowlist, RespondTo, + }, + relay::{query_relay_at_with_keys, relay_http_base_url, submit_signed_event_at_with_keys}, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterExistingAgentRequest { + pub agent_pubkey: String, + #[serde(default)] + pub respond_to: RespondTo, + #[serde(default)] + pub respond_to_allowlist: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExistingAgentPublicationStatus { + Published, + Queued, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterExistingAgentResult { + pub agent_pubkey: String, + pub display_name: String, + pub publication_status: ExistingAgentPublicationStatus, + pub already_registered: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_message: Option, +} + +struct PreparedExistingAgentRegistration { + scope: RetentionScope, + event: nostr::Event, + retained: RetainedEvent, + display_name: String, +} + +fn has_valid_existing_registration( + events: &[nostr::Event], + profile: &nostr::Event, + agent_pubkey: &str, + owner_pubkey: &str, + display_name: &str, + respond_to: RespondTo, + respond_to_allowlist: &[String], +) -> bool { + crate::nostr_convert::relay_agents_from_managed_agent_events( + events, + std::slice::from_ref(profile), + ) + .iter() + .any(|agent| { + agent.pubkey == agent_pubkey + && agent.owner_pubkey.as_deref() == Some(owner_pubkey) + && agent.name == display_name + && agent.respond_to == Some(respond_to) + && agent.respond_to_allowlist == respond_to_allowlist + }) +} + +fn normalize_registration_policy( + respond_to: RespondTo, + respond_to_allowlist: &[String], +) -> Result<(RespondTo, Vec), String> { + let normalized = validate_respond_to_allowlist(respond_to_allowlist)?; + if respond_to == RespondTo::Allowlist && normalized.is_empty() { + return Err("Selected people requires at least one 64-character public key.".to_string()); + } + + Ok(( + respond_to, + if respond_to == RespondTo::Allowlist { + normalized + } else { + Vec::new() + }, + )) +} + +fn ensure_not_locally_managed<'a>( + managed_pubkeys: impl IntoIterator, + agent_pubkey: &str, +) -> Result<(), String> { + if managed_pubkeys + .into_iter() + .any(|pubkey| pubkey.eq_ignore_ascii_case(agent_pubkey)) + { + return Err( + "This agent is already managed by this Desktop. Edit its access policy instead." + .to_string(), + ); + } + Ok(()) +} + +fn latest_registration_created_at( + events: &[nostr::Event], + agent_pubkey: &str, + owner_pubkey: &str, +) -> Option { + events + .iter() + .filter(|event| { + event.kind.as_u16() as u32 == KIND_MANAGED_AGENT + && event.pubkey.to_hex() == owner_pubkey + && event.tags.iter().any(|tag| { + let tag = tag.as_slice(); + tag.first().map(String::as_str) == Some("d") + && tag.get(1).map(String::as_str) == Some(agent_pubkey) + }) + }) + .map(|event| event.created_at.as_secs() as i64) + .max() +} + +fn normalize_agent_pubkey(value: &str) -> Result { + PublicKey::from_hex(value.trim()) + .map(|pubkey| pubkey.to_hex()) + .map_err(|_| "Enter a valid 64-character agent public key.".to_string()) +} + +fn verified_profile_name( + profile: &nostr::Event, + agent_pubkey: &str, + owner_pubkey: &str, +) -> Result { + if profile.kind.as_u16() != 0 || profile.pubkey.to_hex() != agent_pubkey { + return Err("The relay returned the wrong agent profile.".to_string()); + } + let verified_owner = crate::nostr_convert::profile_valid_oa_owner_pubkey(profile) + .ok_or_else(|| { + "This profile does not contain a valid owner attestation. Ask the agent to publish its NIP-OA profile first." + .to_string() + })?; + if verified_owner != owner_pubkey { + return Err("This agent is attested to a different owner.".to_string()); + } + + let profile_info = crate::nostr_convert::profile_info_from_event(profile)?; + let display_name = profile_info + .display_name + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| "The agent profile needs a display name before registration.".to_string())?; + crate::managed_agents::validate_managed_agent_definition_text(&display_name, None, None) + .map_err(|error| format!("The agent profile name is unsafe: {error}"))?; + Ok(display_name) +} + +fn prepare_registration_at( + scope: RetentionScope, + profile: &nostr::Event, + agent_pubkey: &str, + relay_created_at: Option, + respond_to: RespondTo, + respond_to_allowlist: Vec, +) -> Result { + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let display_name = verified_profile_name(profile, agent_pubkey, &owner_pubkey)?; + let content = ManagedAgentEventContent { + name: display_name.clone(), + persona_id: None, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + parallelism: 1, + respond_to, + respond_to_allowlist, + }; + + let conn = open_retention_db(&scope.db_path)?; + let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; + let previous_created_at = existing + .as_ref() + .map(|row| row.created_at) + .into_iter() + .chain(relay_created_at) + .max(); + let event = build_agent_event_from_content(agent_pubkey, &content)? + .custom_created_at(monotonic_created_at(previous_created_at)) + .sign_with_keys(&scope.owner_keys) + .map_err(|error| format!("failed to sign existing-agent registration: {error}"))?; + let retained = RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: agent_pubkey.to_string(), + content: event.content.clone(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + retain_event(&conn, &retained) + .map_err(|error| format!("failed to queue existing-agent registration: {error}"))?; + + Ok(PreparedExistingAgentRegistration { + scope, + event, + retained, + display_name, + }) +} + +#[tauri::command] +pub async fn register_existing_agent( + input: RegisterExistingAgentRequest, + app: AppHandle, +) -> Result { + let agent_pubkey = normalize_agent_pubkey(&input.agent_pubkey)?; + let (respond_to, respond_to_allowlist) = + normalize_registration_policy(input.respond_to, &input.respond_to_allowlist)?; + let state = app.state::(); + let scope = crate::managed_agents::retention::active_retention_scope(&app, &state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let api_base_url = relay_http_base_url(&scope.relay_url); + let events = query_relay_at_with_keys( + &state, + &api_base_url, + &[ + serde_json::json!({ + "kinds": [0], + "authors": [&agent_pubkey], + "limit": 1, + }), + serde_json::json!({ + "kinds": [KIND_MANAGED_AGENT], + "authors": [&owner_pubkey], + "#d": [&agent_pubkey], + "limit": 1, + }), + ], + &scope.owner_keys, + None, + ) + .await?; + + let profile = events + .iter() + .find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == agent_pubkey) + .cloned() + .ok_or_else(|| { + "No profile was found for this agent. Ask the agent to publish its profile first." + .to_string() + })?; + let display_name = verified_profile_name(&profile, &agent_pubkey, &owner_pubkey)?; + + if has_valid_existing_registration( + &events, + &profile, + &agent_pubkey, + &owner_pubkey, + &display_name, + respond_to, + &respond_to_allowlist, + ) { + return Ok(RegisterExistingAgentResult { + agent_pubkey, + display_name, + publication_status: ExistingAgentPublicationStatus::Published, + already_registered: true, + relay_message: None, + }); + } + + let relay_created_at = latest_registration_created_at(&events, &agent_pubkey, &owner_pubkey); + + let prepared = tokio::task::spawn_blocking({ + let app = app.clone(); + let agent_pubkey = agent_pubkey.clone(); + let respond_to_allowlist = respond_to_allowlist.clone(); + move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let managed_agents = crate::managed_agents::load_managed_agents(&app)?; + ensure_not_locally_managed( + managed_agents.iter().map(|record| record.pubkey.as_str()), + &agent_pubkey, + )?; + prepare_registration_at( + scope, + &profile, + &agent_pubkey, + relay_created_at, + respond_to, + respond_to_allowlist, + ) + } + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))??; + + let publish_result = submit_signed_event_at_with_keys( + &prepared.event, + &state, + &api_base_url, + &prepared.scope.owner_keys, + ) + .await; + + match publish_result { + Ok(_) => { + let conn = open_retention_db(&prepared.scope.db_path)?; + mark_synced( + &conn, + prepared.retained.kind, + &prepared.retained.pubkey, + &prepared.retained.d_tag, + prepared.retained.created_at, + &prepared.retained.content, + )?; + let _ = app.emit("agents-data-changed", ()); + Ok(RegisterExistingAgentResult { + agent_pubkey, + display_name: prepared.display_name, + publication_status: ExistingAgentPublicationStatus::Published, + already_registered: false, + relay_message: None, + }) + } + Err(error) => Ok(RegisterExistingAgentResult { + agent_pubkey, + display_name: prepared.display_name, + publication_status: ExistingAgentPublicationStatus::Queued, + already_registered: false, + relay_message: Some(error), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Kind, Tag}; + + fn profile_for(owner: &nostr::Keys, agent: &nostr::Keys, name: &str) -> nostr::Event { + 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_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "").unwrap(); + let auth_parts: Vec = serde_json::from_str(&auth_json).unwrap(); + let auth_tag = Tag::parse(auth_parts).unwrap(); + EventBuilder::new( + Kind::Metadata, + serde_json::json!({"display_name": name}).to_string(), + ) + .tags([auth_tag]) + .sign_with_keys(agent) + .unwrap() + } + + fn scope(dir: &std::path::Path, owner: nostr::Keys) -> RetentionScope { + RetentionScope { + db_path: dir.join("retention.db"), + relay_url: "ws://relay.invalid".to_string(), + owner_keys: owner, + } + } + + #[test] + fn registration_uses_existing_pubkey_and_owner_signature_without_local_key_material() { + let dir = tempfile::tempdir().unwrap(); + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let viewer = nostr::Keys::generate().public_key().to_hex(); + let agent_pubkey = agent.public_key().to_hex(); + let profile = profile_for(&owner, &agent, "Tess"); + + let prepared = prepare_registration_at( + scope(dir.path(), owner.clone()), + &profile, + &agent_pubkey, + None, + RespondTo::Allowlist, + vec![owner.public_key().to_hex(), viewer.clone()], + ) + .unwrap(); + + assert_eq!(prepared.event.pubkey, owner.public_key()); + assert_eq!(prepared.retained.d_tag, agent_pubkey); + assert!(prepared.event.tags.iter().any(|tag| { + let tag = tag.as_slice(); + tag.first().map(String::as_str) == Some("d") + && tag.get(1).map(String::as_str) == Some(prepared.retained.d_tag.as_str()) + })); + let content: serde_json::Value = serde_json::from_str(&prepared.event.content).unwrap(); + assert_eq!(content["name"], "Tess"); + assert_eq!(content["respond_to"], "allowlist"); + assert_eq!( + content["respond_to_allowlist"], + serde_json::json!([owner.public_key().to_hex(), viewer]) + ); + assert_eq!(content["parallelism"], 1); + assert!(content.get("private_key_nsec").is_none()); + assert!(content.get("auth_tag").is_none()); + assert!(content.get("env_vars").is_none()); + } + + #[test] + fn registration_rejects_an_agent_attested_to_another_owner() { + let dir = tempfile::tempdir().unwrap(); + let owner = nostr::Keys::generate(); + let other_owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let profile = profile_for(&other_owner, &agent, "Wrong owner"); + + let error = prepare_registration_at( + scope(dir.path(), owner), + &profile, + &agent.public_key().to_hex(), + None, + RespondTo::OwnerOnly, + Vec::new(), + ) + .err() + .unwrap(); + + assert_eq!(error, "This agent is attested to a different owner."); + assert!(!dir.path().join("retention.db").exists()); + } + + #[test] + fn registration_rejects_an_agent_without_an_owner_attestation() { + let dir = tempfile::tempdir().unwrap(); + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let profile = EventBuilder::new( + Kind::Metadata, + serde_json::json!({"display_name": "Unattested"}).to_string(), + ) + .sign_with_keys(&agent) + .unwrap(); + + let error = prepare_registration_at( + scope(dir.path(), owner), + &profile, + &agent.public_key().to_hex(), + None, + RespondTo::OwnerOnly, + Vec::new(), + ) + .err() + .unwrap(); + + assert_eq!( + error, + "This profile does not contain a valid owner attestation. Ask the agent to publish its NIP-OA profile first." + ); + assert!(!dir.path().join("retention.db").exists()); + } + + #[test] + fn registration_rejects_malformed_pubkeys_before_network_or_storage() { + assert_eq!( + normalize_agent_pubkey("not-a-pubkey").unwrap_err(), + "Enter a valid 64-character agent public key." + ); + } + + #[test] + fn registration_policy_normalizes_allowlist_and_requires_a_selected_person() { + let upper = "A".repeat(64); + let lower = "a".repeat(64); + assert_eq!( + normalize_registration_policy( + RespondTo::Allowlist, + &[format!(" {upper} "), lower.clone()], + ) + .unwrap(), + (RespondTo::Allowlist, vec![lower]) + ); + assert_eq!( + normalize_registration_policy(RespondTo::Allowlist, &[]).unwrap_err(), + "Selected people requires at least one 64-character public key." + ); + } + + #[test] + fn registration_policy_clears_irrelevant_allowlist_entries() { + let pubkey = "a".repeat(64); + assert_eq!( + normalize_registration_policy(RespondTo::OwnerOnly, std::slice::from_ref(&pubkey)) + .unwrap(), + (RespondTo::OwnerOnly, Vec::new()) + ); + assert_eq!( + normalize_registration_policy(RespondTo::Anyone, &[pubkey]).unwrap(), + (RespondTo::Anyone, Vec::new()) + ); + } + + #[test] + fn registration_rejects_a_pubkey_already_managed_by_this_desktop() { + let managed_pubkey = "a".repeat(64); + assert_eq!( + ensure_not_locally_managed([managed_pubkey.as_str()], &managed_pubkey).unwrap_err(), + "This agent is already managed by this Desktop. Edit its access policy instead." + ); + assert!(ensure_not_locally_managed([managed_pubkey.as_str()], &"b".repeat(64),).is_ok()); + } + + #[test] + fn existing_registration_requires_the_exact_agent_coordinate() { + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let other_agent = nostr::Keys::generate(); + let profile = profile_for(&owner, &agent, "Tess"); + let content = ManagedAgentEventContent { + name: "Other agent".to_string(), + persona_id: None, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + parallelism: 1, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: Vec::new(), + }; + let wrong_coordinate = + build_agent_event_from_content(&other_agent.public_key().to_hex(), &content) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + + assert!(!has_valid_existing_registration( + &[wrong_coordinate], + &profile, + &agent.public_key().to_hex(), + &owner.public_key().to_hex(), + "Tess", + RespondTo::OwnerOnly, + &[], + )); + } + + #[test] + fn existing_registration_requires_requested_policy_allowlist_and_current_name() { + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let agent_pubkey = agent.public_key().to_hex(); + let owner_pubkey = owner.public_key().to_hex(); + let profile = profile_for(&owner, &agent, "Tess"); + + let content = ManagedAgentEventContent { + name: "Tess".to_string(), + persona_id: None, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + parallelism: 1, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: Vec::new(), + }; + let owner_only = build_agent_event_from_content(&agent_pubkey, &content) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + + assert!(has_valid_existing_registration( + std::slice::from_ref(&owner_only), + &profile, + &agent_pubkey, + &owner_pubkey, + "Tess", + RespondTo::OwnerOnly, + &[], + )); + assert!(!has_valid_existing_registration( + std::slice::from_ref(&owner_only), + &profile, + &agent_pubkey, + &owner_pubkey, + "Renamed Tess", + RespondTo::OwnerOnly, + &[], + )); + + let anyone = ManagedAgentEventContent { + respond_to: RespondTo::Anyone, + ..content.clone() + }; + let anyone = build_agent_event_from_content(&agent_pubkey, &anyone) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + assert!(!has_valid_existing_registration( + &[anyone], + &profile, + &agent_pubkey, + &owner_pubkey, + "Tess", + RespondTo::OwnerOnly, + &[], + )); + + let allowlisted_pubkey = nostr::Keys::generate().public_key().to_hex(); + let allowlist = ManagedAgentEventContent { + respond_to: RespondTo::Allowlist, + respond_to_allowlist: vec![allowlisted_pubkey.clone()], + ..content + }; + let allowlist = build_agent_event_from_content(&agent_pubkey, &allowlist) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + assert!(has_valid_existing_registration( + &[allowlist], + &profile, + &agent_pubkey, + &owner_pubkey, + "Tess", + RespondTo::Allowlist, + &[allowlisted_pubkey], + )); + } + + #[test] + fn registration_supersedes_a_future_relay_head() { + let dir = tempfile::tempdir().unwrap(); + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let agent_pubkey = agent.public_key().to_hex(); + let profile = profile_for(&owner, &agent, "Tess"); + let future_created_at = nostr::Timestamp::now().as_secs() as i64 + 3_600; + + let prepared = prepare_registration_at( + scope(dir.path(), owner), + &profile, + &agent_pubkey, + Some(future_created_at), + RespondTo::OwnerOnly, + Vec::new(), + ) + .unwrap(); + + assert!(prepared.event.created_at.as_secs() as i64 > future_created_at); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 324e13e68a5..d18406e3483 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ mod agent_model_process; mod agent_models; mod agent_models_env; mod agent_providers; +mod agent_registration; mod agent_settings; mod agent_update_rollback; mod agents; @@ -82,6 +83,7 @@ pub use agent_logs::*; pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; +pub use agent_registration::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fe2bba5024b..e3befa20c7c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -691,6 +691,7 @@ pub fn run() { resolve_oa_owner, list_relay_agents, revalidate_relay_agents, + register_existing_agent, list_managed_agents, list_managed_agent_runtimes, start_managed_agent_runtime, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 85f34260ce7..73ba4a19676 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -111,16 +111,27 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont /// Returns an unsigned `EventBuilder` — the caller signs and submits. The /// `d_tag` is the agent's pubkey. pub fn build_agent_event(record: &ManagedAgentRecord) -> Result { + build_agent_event_from_content(&record.pubkey, &agent_event_content(record)) +} + +/// Build a kind:30177 event from an already-sanitized public projection. +/// +/// This is also used when an owner registers an independently operated agent: +/// Desktop knows the public identity and access policy, but intentionally does +/// not possess or create a local [`ManagedAgentRecord`] or the agent's key. +pub fn build_agent_event_from_content( + agent_pubkey: &str, + content: &ManagedAgentEventContent, +) -> Result { super::validate_managed_agent_definition_text( - &record.name, - record.persona_id.as_deref(), - record.system_prompt.as_deref(), + &content.name, + content.persona_id.as_deref(), + content.system_prompt.as_deref(), ) .map_err(|error| format!("Managed agent definition is unsafe to publish: {error}"))?; - let content = serde_json::to_string(&agent_event_content(record)) + let content = serde_json::to_string(content) .map_err(|e| format!("failed to serialize managed-agent content: {e}"))?; - let tags = - vec![Tag::parse(["d", record.pubkey.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?]; + let tags = vec![Tag::parse(["d", agent_pubkey]).map_err(|e| format!("invalid d-tag: {e}"))?]; Ok(EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content).tags(tags)) } diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 64509387feb..a96da51ef45 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -283,6 +283,20 @@ with a TypeScript lookup table or an id comparison in a component. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +18. **Registering an existing agent never takes custody of its identity.** The + registration path accepts only an existing pubkey whose live kind:0 profile + contains a valid NIP-OA attestation to the current owner. It publishes an + owner-signed kind:30177 directory record with the explicitly selected + `respond_to` policy and allowlist, and does not generate, import, replace, + persist, or request the agent's private key. Allowlist mode does not + implicitly admit the owner: the UI must tell the owner to include their own + pubkey when they want discovery, and the published allowlist must match the + requested normalized values exactly. This directory policy controls Desktop + discovery only; an independently operated agent's runtime gate remains + external and must be configured to admit the same audience. Registration + also must not create a secretless local `ManagedAgentRecord`: independently + operated agents remain relay agents, not startable Desktop runtimes. + ## Channel-only runtime controls Desktop observer controls identify a channel, not a thread session. The harness @@ -307,7 +321,6 @@ the CLI with the channel and target thread root: buzz messages send --channel --reply-to \ --mention --content '!cancel' ``` - ## The tests that enforce this - `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 3daf4fa78cc..be3bd1ca514 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -68,6 +68,10 @@ import { setPersonaActive, updatePersona, } from "@/shared/api/tauriPersonas"; +import { + registerExistingAgent, + type RegisterExistingAgentInput, +} from "@/shared/api/tauriAgentRegistration"; import { teamsQueryKey } from "@/features/agents/teamHooks"; import type { AcpRuntime, @@ -515,6 +519,18 @@ export function useCreatePersonaMutation() { }); } +export function useRegisterExistingAgentMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: RegisterExistingAgentInput) => + registerExistingAgent(input), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, + }); +} + export function useUpdatePersonaMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 22b5326223c..38fa592172f 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -24,7 +24,10 @@ import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; -import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; +import { + useBakedBuildEnvQuery, + useRegisterExistingAgentMutation, +} from "@/features/agents/hooks"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { Button } from "@/shared/ui/button"; @@ -44,6 +47,7 @@ export function AgentsView() { const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv); const agents = useManagedAgentActions(); const personas = usePersonaActions(); + const registerExistingAgentMutation = useRegisterExistingAgentMutation(); const teamImportInputRef = React.useRef(null); const aiDefaultsTriggerRef = React.useRef(null); const fullAiDefaultsTriggerRef = React.useRef(null); @@ -94,6 +98,7 @@ export function AgentsView() { const isActionPending = agents.isPending || personas.isPending || + registerExistingAgentMutation.isPending || teamActions.createTeamMutation.isPending || teamActions.updateTeamMutation.isPending || teamActions.deleteTeamMutation.isPending; @@ -510,12 +515,16 @@ export function AgentsView() { ? personas.personaNoticeMessage : null } + isRegistrationPending={registerExistingAgentMutation.isPending} onClearFeedback={() => { personas.clearFeedback("catalog"); }} onImportFile={(fileBytes, fileName) => { void personas.handleImportSnapshotFile(fileBytes, fileName); }} + onRegisterExistingAgent={(input) => + registerExistingAgentMutation.mutateAsync(input) + } onSelectPersona={async (persona, active) => { const addedPersona = await personas.handleSetActive( persona, diff --git a/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx b/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx index a89cb355a12..fd92b7e8398 100644 --- a/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ChevronDown, Plus, Upload } from "lucide-react"; +import { ChevronDown, Link2, Plus, Upload } from "lucide-react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; import { effectiveAgentDescription } from "@/features/agents/lib/agentDescription"; @@ -8,6 +8,10 @@ import type { CatalogTeam } from "@/features/agents/lib/teamCatalogRelay"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; +import type { + ExistingAgentRegistrationResult, + RegisterExistingAgentInput, +} from "@/shared/api/tauriAgentRegistration"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { cn } from "@/shared/lib/cn"; import { @@ -27,16 +31,17 @@ import { Skeleton } from "@/shared/ui/skeleton"; import agentOutlineUrl from "../assets/agent-outline.svg"; import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; +import { RegisterExistingAgentPane } from "./ExistingAgentRegistrationPane"; import { PersonaAddedBy } from "./PersonaAddedBy"; import { resolveCatalogOwnerLabel } from "./catalogOwnerLabel"; // ── Type-tagged selection keys ──────────────────────────────────────────────── -// The detail pane is a single-select surface across four kinds of content: -// the "create" and "import" panes carried from the unified add-agent dialog -// (#5015), plus a persona or team browsed from the community catalog. Persona -// IDs and team coordinates are prefixed so they cannot collide with each other -// or with the fixed "create"/"import" keys. +// The detail pane is a single-select surface across five kinds of content: +// the "create", "register", and "import" panes carried from the unified +// add-agent dialog (#5015), plus a persona or team browsed from the community +// catalog. Persona IDs and team coordinates are prefixed so they cannot collide +// with each other or with the fixed navigation keys. type CatalogSelectionKey = | { kind: "persona"; id: string } | { kind: "team"; key: string }; @@ -68,6 +73,10 @@ type CommunityCatalogDialogProps = { onRequestClose: () => void; }) => React.ReactNode; onImportFile: (fileBytes: number[], fileName: string) => void; + isRegistrationPending: boolean; + onRegisterExistingAgent: ( + input: RegisterExistingAgentInput, + ) => Promise; // Persona side personas: AgentPersona[]; @@ -101,6 +110,8 @@ type PendingNavigation = export function CommunityCatalogDialog({ createContent, onImportFile, + isRegistrationPending, + onRegisterExistingAgent, personas, personasError, personasLoading, @@ -278,7 +289,7 @@ export function CommunityCatalogDialog({ <> { - if (!nextOpen && personasPending) return; + if (!nextOpen && (personasPending || isRegistrationPending)) return; if (!nextOpen) { requestClose(); return; @@ -355,6 +366,13 @@ export function CommunityCatalogDialog({ onClick={() => requestSelection("create")} testId="agent-catalog-create" /> + } + isCurrent={selection === "register"} + label="Register existing" + onClick={() => requestSelection("register")} + testId="agent-catalog-register-existing" + /> } isCurrent={isImportSelected} @@ -481,6 +499,13 @@ export function CommunityCatalogDialog({ /> ) : null} + {selection === "register" ? ( + + ) : null} + {selectedPersona || selectedTeam ? ( <>
Promise; +}) { + const [agentPubkey, setAgentPubkey] = React.useState(""); + const [respondTo, setRespondTo] = React.useState("owner-only"); + const [allowlistInput, setAllowlistInput] = React.useState(""); + const [error, setError] = React.useState(null); + const [result, setResult] = + React.useState(null); + const normalizedPubkey = agentPubkey.trim(); + const parsedAllowlist = React.useMemo( + () => parsePubkeyInput(allowlistInput), + [allowlistInput], + ); + const allowlistIsValid = + respondTo !== "allowlist" || + (parsedAllowlist.valid.length > 0 && parsedAllowlist.invalid.length === 0); + const canSubmit = + HEX_PUBLIC_KEY_PATTERN.test(normalizedPubkey) && + allowlistIsValid && + !isPending; + + const policySummary = + respondTo === "owner-only" + ? "Only you can find this agent in mention suggestions." + : respondTo === "allowlist" + ? `${parsedAllowlist.valid.length} selected ${parsedAllowlist.valid.length === 1 ? "person" : "people"} can find this agent in mention suggestions.` + : "Anyone who shares a channel with this agent can find it in mention suggestions."; + + return ( +
{ + event.preventDefault(); + if (!canSubmit) return; + setError(null); + setResult(null); + void onRegister({ + agentPubkey: normalizedPubkey, + respondTo, + respondToAllowlist: + respondTo === "allowlist" ? parsedAllowlist.valid : [], + }) + .then(setResult) + .catch((cause: unknown) => { + setError( + cause instanceof Error + ? cause.message + : "Could not register this agent.", + ); + }); + }} + > +
+
+

Register an existing agent

+

+ Connect an agent that already has its own key and runtime. Its + published profile must prove that you are the owner. +

+
+ + + + + + {respondTo === "allowlist" ? ( +