diff --git a/crates/buzz-cli/src/agent_management.rs b/crates/buzz-cli/src/agent_management.rs index ce4059f8217..eaf8740c60f 100644 --- a/crates/buzz-cli/src/agent_management.rs +++ b/crates/buzz-cli/src/agent_management.rs @@ -37,6 +37,14 @@ pub struct UpdateAgentDraft { pub respond_to: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdoptAgentDraft { + pub channel_id: String, + pub agent_pubkey: String, + pub display_name: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ManagementRequest { @@ -185,6 +193,25 @@ pub fn build_update( build(keys, owner, channel_id, "update", request) } +pub fn build_adopt( + keys: &Keys, + owner: &PublicKey, + draft: AdoptAgentDraft, +) -> Result { + let channel_id = required(draft.channel_id, "channel", 128)?; + uuid::Uuid::parse_str(&channel_id) + .map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?; + let agent_pubkey = PublicKey::parse(draft.agent_pubkey.trim()) + .map_err(|_| CliError::Usage("agent pubkey must be valid hex or npub".into()))? + .to_hex(); + let request = AdoptAgentDraft { + channel_id: channel_id.clone(), + agent_pubkey, + display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?, + }; + build(keys, owner, channel_id, "adopt", request) +} + #[cfg(test)] mod tests { use super::*; @@ -274,4 +301,47 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("invalid channel UUID")); } + + #[test] + fn adopt_is_owner_encrypted_and_contains_only_public_registration_fields() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let external = Keys::generate(); + let built = build_adopt( + &agent, + &owner.public_key(), + AdoptAgentDraft { + channel_id: CHANNEL.into(), + agent_pubkey: external.public_key().to_hex(), + display_name: "Remote helper".into(), + }, + ) + .unwrap(); + + let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap(); + assert_eq!(payload["payload"]["action"], "adopt"); + assert_eq!( + payload["payload"]["request"], + serde_json::json!({ + "channelId": CHANNEL, + "agentPubkey": external.public_key().to_hex(), + "displayName": "Remote helper" + }) + ); + } + + #[test] + fn adopt_rejects_an_invalid_agent_pubkey() { + let error = build_adopt( + &Keys::generate(), + &Keys::generate().public_key(), + AdoptAgentDraft { + channel_id: CHANNEL.into(), + agent_pubkey: "not-a-pubkey".into(), + display_name: "Remote helper".into(), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("agent pubkey")); + } } diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 568249d0829..e390c4b40e2 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -3,7 +3,9 @@ use buzz_sdk::builders::{build_archive_identity_request, build_unarchive_identit use nostr::PublicKey; use serde_json::json; -use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft}; +use crate::agent_management::{ + build_adopt, build_create, build_update, AdoptAgentDraft, CreateAgentDraft, UpdateAgentDraft, +}; use crate::client::BuzzClient; use crate::error::CliError; use crate::validate::{read_or_stdin, validate_hex64}; @@ -85,6 +87,38 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli Ok(()) } + AgentsCmd::DraftAdopt { + channel, + agent_pubkey, + display_name, + } => { + let owner = require_owner(client)?; + let built = build_adopt( + client.keys(), + &owner, + AdoptAgentDraft { + channel_id: channel, + agent_pubkey, + display_name, + }, + )?; + let response = client.publish_ephemeral_event(built.event).await?; + let mut output: serde_json::Value = serde_json::from_str(&response) + .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; + if let Some(obj) = output.as_object_mut() { + obj.insert("request_id".into(), built.request_id.into()); + obj.insert("action".into(), built.action.into()); + obj.insert("saved".into(), false.into()); + obj.insert( + "message".into(), + "Registration draft sent to Buzz Desktop for owner review. The existing agent is unchanged until the owner saves it." + .into(), + ); + } + println!("{output}"); + Ok(()) + } + AgentsCmd::Archive { target_pubkey, reason, @@ -168,7 +202,7 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } /// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by -/// the `draft-create` and `draft-update` paths. +/// the owner-reviewed agent draft paths. fn require_owner(client: &BuzzClient) -> Result { let hex = client .auth_tag_owner_hex() diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 5cac8c941e1..962d62d99f3 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -296,6 +296,18 @@ pub enum AgentsCmd { #[arg(long, value_enum)] respond_to: Option, }, + /// Open an owner-reviewed form to register an existing remote agent identity + DraftAdopt { + /// Current channel UUID; used only to authorize the review request + #[arg(long)] + channel: String, + /// Existing agent public key (hex or npub); no private key is imported + #[arg(long)] + agent_pubkey: String, + /// Proposed directory display name + #[arg(long)] + display_name: String, + }, /// Submit a NIP-IA archive request for an identity (kind 9035) #[command( after_help = "Auth flow: when target != signer, the CLI fetches the target's kind:0 and \ @@ -2262,6 +2274,7 @@ mod tests { vec![ "archive", "archived", + "draft-adopt", "draft-create", "draft-update", "unarchive" @@ -2402,7 +2415,7 @@ mod tests { #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ - ("agents", 5), + ("agents", 6), ("canvas", 2), ("channels", 16), ("dms", 4), diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..8ce9e7f2346 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -57,6 +57,7 @@ export default defineConfig({ "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", "**/edit-agent.spec.ts", + "**/register-existing-agent.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 95e9759f10e..6cc127a3e43 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1022,12 +1022,12 @@ pub async fn discover_managed_agent_prereqs( .await .map_err(|e| format!("spawn_blocking failed: {e}")) } - mod relay_directory; #[cfg(test)] use relay_directory::advance_relay_cursor; -pub use relay_directory::{list_relay_agents, revalidate_relay_agents}; - +pub use relay_directory::{ + list_relay_agents, register_existing_relay_agent, revalidate_relay_agents, +}; #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index db0573acd7c..d29e2fd294a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -1,10 +1,14 @@ //! Relay-backed shared-agent directory discovery. -use tauri::State; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; use crate::{ - app_state::AppState, commands::identity_archive, managed_agents::RelayAgentInfo, nostr_convert, - relay::query_relay, + app_state::AppState, + commands::identity_archive, + managed_agents::RelayAgentInfo, + nostr_convert, + relay::{query_relay, query_relay_at_with_keys, submit_event_at_with_keys}, }; const RELAY_DIRECTORY_PAGE_SIZE: usize = 500; @@ -242,6 +246,142 @@ pub async fn revalidate_relay_agents( list_relay_agents_for_selection(&state, Some(&requested_pubkeys), channel_id.as_deref()).await } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegisterExistingRelayAgentInput { + agent_pubkey: String, + display_name: String, + expected_owner_pubkey: String, + expected_relay_url: String, + expected_signer_pubkey: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterExistingRelayAgentResult { + event_id: String, + agent_pubkey: String, + owner_pubkey: String, +} + +fn verified_registration_owner( + profile: &nostr::Event, + agent_pubkey: &str, +) -> Result { + identity_archive::verified_oa_owner(profile, agent_pubkey) + .map(|(owner, _)| owner) + .ok_or_else(|| "agent profile or owner attestation failed verification".to_string()) +} + +/// Publish the owner-reviewed directory policy for an existing remote agent. +/// No private key, runtime, provider, prompt, process, or local agent record is +/// accepted or written by this command. +#[tauri::command] +pub async fn register_existing_relay_agent( + input: RegisterExistingRelayAgentInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let agent_pubkey = nostr::PublicKey::from_hex(input.agent_pubkey.trim()) + .map_err(|error| format!("invalid agent pubkey: {error}"))? + .to_hex(); + let display_name = input.display_name.trim(); + if display_name.is_empty() || display_name.chars().count() > 120 { + return Err("display name must be between 1 and 120 characters".to_string()); + } + crate::managed_agents::validate_managed_agent_definition_text(display_name, None, None) + .map_err(|error| format!("Agent name is unsafe to publish: {error}"))?; + + let target = identity_archive::capture_relay_target(&state); + crate::relay::assert_expected_relay_scope( + Some(&input.expected_relay_url), + &target.api_base_url, + )?; + let signer = state.signing_keys()?; + let signer_pubkey = signer.public_key().to_hex(); + crate::relay::assert_expected_signer(Some(&input.expected_signer_pubkey), &signer_pubkey)?; + if !input + .expected_owner_pubkey + .eq_ignore_ascii_case(&signer_pubkey) + { + return Err("verified agent owner does not match the active identity".to_string()); + } + if agent_pubkey.eq_ignore_ascii_case(&signer_pubkey) { + return Err("an owner identity cannot be registered as its own agent".to_string()); + } + + let profiles = query_relay_at_with_keys( + &state, + &target.api_base_url, + &[serde_json::json!({ + "kinds": [0], + "authors": [&agent_pubkey], + "limit": 1, + })], + &signer, + None, + ) + .await?; + let profile = profiles + .first() + .ok_or_else(|| "agent has no signed profile on this relay".to_string())?; + let verified_owner = verified_registration_owner(profile, &agent_pubkey)?; + if !verified_owner.eq_ignore_ascii_case(&signer_pubkey) + || !verified_owner.eq_ignore_ascii_case(&input.expected_owner_pubkey) + { + return Err("agent profile is attested to a different owner".to_string()); + } + + let archived = + identity_archive::fetch_archived_pubkeys_strict_at(&state, &target, &signer).await?; + if archived.iter().any(|pubkey| pubkey == &agent_pubkey) { + return Err("archived agent identities cannot be registered".to_string()); + } + + // Re-check both mutable workspace boundaries after the network preflight, + // then use those exact snapshots for the only side effect. + let publish_target = identity_archive::capture_relay_target(&state); + crate::relay::assert_expected_relay_scope( + Some(&input.expected_relay_url), + &publish_target.api_base_url, + )?; + let publish_signer = state.signing_keys()?; + crate::relay::assert_expected_signer( + Some(&input.expected_signer_pubkey), + &publish_signer.public_key().to_hex(), + )?; + + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + if crate::managed_agents::load_managed_agents(&app)? + .iter() + .any(|agent| agent.pubkey.eq_ignore_ascii_case(&agent_pubkey)) + { + return Err(format!("agent {agent_pubkey} is already managed locally")); + } + } + + let builder = crate::managed_agents::agent_events::build_existing_agent_registration( + &agent_pubkey, + display_name, + )?; + let response = submit_event_at_with_keys( + builder, + &state, + &publish_target.api_base_url, + &publish_signer, + ) + .await?; + Ok(RegisterExistingRelayAgentResult { + event_id: response.event_id, + agent_pubkey, + owner_pubkey: verified_owner, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -352,6 +492,40 @@ mod tests { assert_eq!(batch_sizes, vec![10, 10, 5]); } + + #[test] + fn registration_input_rejects_secret_or_runtime_fields() { + for field in ["privateKeyNsec", "runtime", "provider", "systemPrompt"] { + let mut input = serde_json::json!({ + "agentPubkey": "a".repeat(64), + "displayName": "Remote helper", + "expectedOwnerPubkey": "b".repeat(64), + "expectedRelayUrl": "wss://relay.example", + "expectedSignerPubkey": "b".repeat(64), + }); + input[field] = serde_json::json!("forbidden"); + assert!(serde_json::from_value::(input).is_err()); + } + } + + #[test] + fn registration_profile_requires_the_agent_signature_and_owner_attestation() { + let owner = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let target = nostr::PublicKey::from_hex(&agent.public_key().to_hex()).unwrap(); + let auth_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &target, "").unwrap(); + let auth_parts: Vec = serde_json::from_str(&auth_json).unwrap(); + let profile = nostr::EventBuilder::new(nostr::Kind::Metadata, "{}") + .tags([nostr::Tag::parse(auth_parts).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + + assert_eq!( + verified_registration_owner(&profile, &agent.public_key().to_hex()).unwrap(), + owner.public_key().to_hex() + ); + assert!(verified_registration_owner(&profile, &owner.public_key().to_hex()).is_err()); + } } #[cfg(all(test, not(target_os = "windows")))] diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index 0cc5679bf7b..54a8216a439 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -91,6 +91,24 @@ pub(crate) fn extract_oa_owner(target_kind0: &nostr::Event) -> Option<(String, [ None } +/// Verify the profile event itself before trusting its embedded owner proof. +pub(crate) fn verified_oa_owner( + target_kind0: &nostr::Event, + expected_target_pubkey: &str, +) -> Option<(String, [String; 4])> { + if target_kind0.kind != nostr::Kind::Metadata + || !target_kind0.verify_id() + || !target_kind0.verify_signature() + || !target_kind0 + .pubkey + .to_hex() + .eq_ignore_ascii_case(expected_target_pubkey) + { + return None; + } + extract_oa_owner(target_kind0) +} + pub(crate) async fn fetch_kind0( state: &AppState, pubkey: &str, @@ -133,7 +151,7 @@ pub async fn resolve_oa_owner( return Ok(None); }; - let Some((owner_hex, _tag)) = extract_oa_owner(&kind0) else { + let Some((owner_hex, _tag)) = verified_oa_owner(&kind0, &target_pubkey) else { return Ok(None); }; @@ -447,6 +465,41 @@ pub(crate) async fn fetch_archived_pubkeys_at( archived_pubkeys_from_snapshot(&snapshot) } +/// Strict archive read for trust-boundary mutations. Unlike the display-only +/// helper above, any missing relay identity, query failure, or invalid signed +/// snapshot aborts the caller instead of treating the archive as empty. +pub(crate) async fn fetch_archived_pubkeys_strict_at( + state: &AppState, + target: &RelayTarget, + signer: &nostr::Keys, +) -> Result, String> { + let relay_self = fetch_relay_self_at(state, &target.ws_url) + .await? + .ok_or_else(|| "relay archive authority is unavailable".to_string())?; + let events = crate::relay::query_relay_at_with_keys( + state, + &target.api_base_url, + &[serde_json::json!({ + "authors": [relay_self.clone()], + "kinds": [13535], + "limit": 1, + })], + signer, + None, + ) + .await?; + let Some(snapshot) = events.into_iter().next() else { + return Ok(Vec::new()); + }; + if !snapshot.verify_id() + || !snapshot.verify_signature() + || !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) + { + return Err("relay archive snapshot failed signature verification".to_string()); + } + Ok(archived_pubkeys_from_snapshot(&snapshot)) +} + /// Read the relay's latest valid `kind:13535` archive snapshot. The frontend /// caches this and tests membership client-side to drive the "Archived" flair. #[tauri::command] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 71a5eb3806e..3dcacc090df 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -684,6 +684,7 @@ pub fn run() { get_relay_self, resolve_oa_owner, list_relay_agents, + register_existing_relay_agent, revalidate_relay_agents, list_managed_agents, list_managed_agent_runtimes, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index f0a4fabfed8..20da3ba42f9 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -124,6 +124,35 @@ pub fn build_agent_event(record: &ManagedAgentRecord) -> Result Result { + super::validate_managed_agent_definition_text(name, None, None) + .map_err(|error| format!("Managed agent definition is unsafe to publish: {error}"))?; + let agent_pubkey = nostr::PublicKey::from_hex(agent_pubkey) + .map_err(|error| format!("invalid agent pubkey: {error}"))? + .to_hex(); + let content = serde_json::to_string(&ManagedAgentEventContent { + name: name.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(), + }) + .map_err(|error| format!("failed to serialize managed-agent content: {error}"))?; + let tag = Tag::parse(["d", agent_pubkey.as_str()]) + .map_err(|error| format!("invalid d-tag: {error}"))?; + Ok(EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content).tags([tag])) +} + /// Parse a kind:30177 event's content into the projection — the inbound /// counterpart of [`agent_event_content`]. /// @@ -235,6 +264,28 @@ mod tests { assert_eq!(event.kind.as_u16() as u32, KIND_MANAGED_AGENT); } + #[test] + fn existing_agent_registration_is_the_minimal_owner_only_policy() { + let external = nostr::Keys::generate(); + let owner = nostr::Keys::generate(); + let event = + build_existing_agent_registration(&external.public_key().to_hex(), "Remote helper") + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + + assert_eq!(event.kind.as_u16() as u32, KIND_MANAGED_AGENT); + assert_eq!( + event.content, + r#"{"name":"Remote helper","parallelism":1,"respond_to":"owner-only"}"# + ); + assert_eq!(event.tags.len(), 1); + assert_eq!( + event.tags.iter().next().unwrap().as_slice(), + &["d".to_string(), external.public_key().to_hex()] + ); + } + #[test] fn publication_rejects_unsafe_definition_less_name_and_prompt() { let mut unsafe_name = sample_agent(); diff --git a/desktop/src/features/agents/agentManagement.test.mjs b/desktop/src/features/agents/agentManagement.test.mjs index 0fa9176c740..470b861895d 100644 --- a/desktop/src/features/agents/agentManagement.test.mjs +++ b/desktop/src/features/agents/agentManagement.test.mjs @@ -104,3 +104,40 @@ test("allows agents to update only personal, editable profiles", () => { false, ); }); + +test("parses a narrow existing-identity adoption request", () => { + const payload = { + type: AGENT_MANAGEMENT_REQUEST, + action: "adopt", + requestId: "request-adopt", + request: { + channelId: CHANNEL_ID, + agentPubkey: "a".repeat(64), + displayName: "Luci", + }, + }; + + assert.deepEqual(parseAgentManagementRequest(payload), payload); +}); + +test("rejects adoption requests carrying runtime or secret fields", () => { + for (const [field, value] of [ + ["privateKeyNsec", "nsec1secret"], + ["runtime", "hermes"], + ["provider", "remote"], + ["systemPrompt", "hidden instructions"], + ]) { + const payload = { + type: AGENT_MANAGEMENT_REQUEST, + action: "adopt", + requestId: "request-adopt", + request: { + channelId: CHANNEL_ID, + agentPubkey: "a".repeat(64), + displayName: "Luci", + [field]: value, + }, + }; + assert.equal(parseAgentManagementRequest(payload), null); + } +}); diff --git a/desktop/src/features/agents/agentManagement.ts b/desktop/src/features/agents/agentManagement.ts index 5b5e18d8727..70cc987246b 100644 --- a/desktop/src/features/agents/agentManagement.ts +++ b/desktop/src/features/agents/agentManagement.ts @@ -33,9 +33,21 @@ export type AgentManagementUpdateRequest = { }; }; +export type AgentManagementAdoptRequest = { + type: typeof AGENT_MANAGEMENT_REQUEST; + action: "adopt"; + requestId: string; + request: { + channelId: string; + agentPubkey: string; + displayName: string; + }; +}; + export type AgentManagementRequest = | AgentManagementCreateRequest - | AgentManagementUpdateRequest; + | AgentManagementUpdateRequest + | AgentManagementAdoptRequest; function isText(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -61,7 +73,7 @@ export function parseAgentManagementRequest( if ( payload.type !== AGENT_MANAGEMENT_REQUEST || !isText(payload.requestId) || - (payload.action !== "create" && payload.action !== "update") || + !["create", "update", "adopt"].includes(String(payload.action)) || typeof payload.request !== "object" || payload.request === null ) { @@ -92,6 +104,28 @@ export function parseAgentManagementRequest( }; } + if (payload.action === "adopt") { + if ( + !hasOnlyKeys(request, ["channelId", "agentPubkey", "displayName"]) || + !isText(request.channelId) || + !isText(request.agentPubkey) || + !/^[0-9a-f]{64}$/i.test(request.agentPubkey) || + !isText(request.displayName) + ) { + return null; + } + return { + type: AGENT_MANAGEMENT_REQUEST, + action: "adopt", + requestId: payload.requestId, + request: { + channelId: request.channelId, + agentPubkey: request.agentPubkey.toLowerCase(), + displayName: request.displayName, + }, + }; + } + if ( !isRespondTo(request.respondTo) || !hasOnlyKeys(request, [ diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 68fa290ad25..9d95667c3e0 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -883,10 +883,7 @@ export function injectObserverEventsForE2E( agentPubkey: string, events: ObserverEvent[], ) { - const added = appendAgentEvents(agentPubkey, events); - if (added) { - notifyListeners({ agentPubkey, events: added }); - } + processLiveObserverEvents(agentPubkey, events); } /** diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx index 27541889f26..b29a246f72f 100644 --- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx +++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx @@ -1,6 +1,16 @@ import { useAgentManagement } from "@/features/agents/useAgentManagement"; import { AgentCardDialogs } from "./AgentCardViewerDialog"; import { AgentDialog } from "./AgentDialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; /** Global review surfaces opened by owned agents through the Buzz harness. */ export function AgentManagementDialogs() { @@ -42,6 +52,76 @@ export function AgentManagementDialogs() { title="Edit agent" /> ) : null} + {management.request?.action === "adopt" ? ( + { + if (!open) management.dismiss(); + }} + open + > + + + Register existing agent + + Buzz will publish one directory entry for this identity. It will + not import a key or start, stop, or configure the agent. + + +
+
+
Directory name
+
{management.request.request.displayName}
+
+
+
Signed profile
+
+ {management.adoptProfile?.displayName ?? "No display name"} +
+
+
+
Public key
+
+ {management.request.request.agentPubkey} +
+
+
+
Verified owner
+
+ {management.verifiedAdoptOwner ?? "Verifying…"} +
+
+
+
Who can instruct it
+
Owner only
+
+
+ {management.adoptPreviewError ? ( +

+ {management.adoptPreviewError} +

+ ) : null} + + + + + + +
+
+ ) : null} ); diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts index 066f7949a9a..c644c65da2a 100644 --- a/desktop/src/features/agents/useAgentManagement.ts +++ b/desktop/src/features/agents/useAgentManagement.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createInputFromRequest, @@ -25,9 +25,17 @@ import { import { useCreatedAgentChannelAttachment } from "./useCreatedAgentChannelAttachment"; import { classifyAgentManagementOrigin } from "./agentManagementBuffer"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + useArchivedIdentitiesQuery, + useOaOwnerQuery, +} from "@/features/identity-archive/hooks"; import { resolveManagedAgentAvatarUrl } from "./ui/managedAgentAvatar"; import type { AgentCreateIntent } from "./ui/agentCreateIntent"; import { editPersonaDialogState } from "./ui/personaDialogState"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { registerExistingRelayAgent } from "@/shared/api/tauriManagedAgents"; +import { getUserProfile } from "@/shared/api/tauriProfiles"; import type { CreatePersonaInput, UpdatePersonaInput, @@ -59,6 +67,8 @@ function updateInputFromRequest( export function useAgentManagement() { const queryClient = useQueryClient(); + const communities = useCommunities(); + const identityQuery = useIdentityQuery(); const personasQuery = usePersonasQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const channelsQuery = useChannelsQuery(); @@ -66,6 +76,9 @@ export function useAgentManagement() { const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const createAgentMutation = useCreateManagedAgentMutation(); + const registerExistingMutation = useMutation({ + mutationFn: registerExistingRelayAgent, + }); const [request, setRequest] = React.useState( null, ); @@ -79,6 +92,17 @@ export function useAgentManagement() { const bufferedRequestsRef = React.useRef< Array<{ agentPubkey: string; request: AgentManagementRequest }> >([]); + const adoptPubkey = + request?.action === "adopt" ? request.request.agentPubkey : ""; + const adoptEnabled = request?.action === "adopt"; + const adoptOwnerQuery = useOaOwnerQuery(adoptPubkey, adoptEnabled); + const adoptArchiveQuery = useArchivedIdentitiesQuery(adoptEnabled); + const adoptProfileQuery = useQuery({ + enabled: adoptEnabled, + queryKey: ["agent-management-adopt-profile", adoptPubkey] as const, + queryFn: () => getUserProfile(adoptPubkey), + staleTime: 60_000, + }); const acceptOwnedRequest = React.useEffectEvent( (agentPubkey: string, next: AgentManagementRequest) => { @@ -155,7 +179,8 @@ export function useAgentManagement() { const isPending = createPersonaMutation.isPending || updatePersonaMutation.isPending || - createAgentMutation.isPending; + createAgentMutation.isPending || + registerExistingMutation.isPending; function assertAgentCanActFromOrigin(channelId: string) { const targetChannel = (channelsQuery.data ?? []).find( @@ -259,6 +284,46 @@ export function useAgentManagement() { } } + async function submitAdopt(): Promise { + if (request?.action !== "adopt") return false; + setError(null); + try { + assertAgentCanActFromOrigin(request.request.channelId); + const owner = adoptOwnerQuery.data; + if (!owner?.isMe) { + throw new Error( + "This agent does not have a valid ownership attestation for your identity.", + ); + } + if ( + adoptArchiveQuery.data?.archived.includes(request.request.agentPubkey) + ) { + throw new Error("Archived agent identities cannot be registered."); + } + const expectedRelayUrl = communities.activeCommunity?.relayUrl; + const expectedSignerPubkey = identityQuery.data?.pubkey; + if (!expectedRelayUrl || !expectedSignerPubkey) { + throw new Error("The active community identity is unavailable."); + } + await registerExistingMutation.mutateAsync({ + agentPubkey: request.request.agentPubkey, + displayName: request.request.displayName, + expectedOwnerPubkey: owner.owner, + expectedRelayUrl, + expectedSignerPubkey, + }); + dismiss(); + return true; + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : "Could not register this agent identity.", + ); + return false; + } + } + function dismiss() { pendingRequestId.current = null; sourceAgentPubkey.current = null; @@ -292,11 +357,53 @@ export function useAgentManagement() { return null; }, [currentPersona, error, matchingPersonas.length, request]); + const adoptPreviewError = React.useMemo(() => { + if (request?.action !== "adopt") return null; + if (adoptOwnerQuery.isError || adoptProfileQuery.isError) { + return "Could not verify this agent on the active relay."; + } + if (!adoptOwnerQuery.isLoading && !adoptOwnerQuery.data) { + return "This agent has no valid ownership attestation."; + } + if (adoptArchiveQuery.isError) { + return "Could not verify this agent is not archived."; + } + if (adoptOwnerQuery.data && !adoptOwnerQuery.data.isMe) { + return "This agent is not attested to your identity."; + } + if ( + adoptArchiveQuery.data?.archived.includes(request.request.agentPubkey) + ) { + return "This agent identity is archived."; + } + return error; + }, [ + adoptArchiveQuery.data, + adoptArchiveQuery.isError, + adoptOwnerQuery.data, + adoptOwnerQuery.isError, + adoptOwnerQuery.isLoading, + adoptProfileQuery.isError, + error, + request, + ]); + const isAdoptPreviewPending = + adoptEnabled && + (adoptOwnerQuery.isLoading || + adoptArchiveQuery.isLoading || + adoptProfileQuery.isLoading); + return { request, createInitialValues, editInitialValues, editError, + adoptPreviewError, + adoptProfile: adoptProfileQuery.data ?? null, + verifiedAdoptOwner: adoptOwnerQuery.data?.isMe + ? adoptOwnerQuery.data.owner + : null, + isAdoptPreviewPending, error, ...createdAgentAttachment, isPending, @@ -308,6 +415,7 @@ export function useAgentManagement() { : ("ready" as const), submitCreate, submitUpdate, + submitAdopt, dismiss, }; } diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index 9f77566da99..f68f94c8894 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -8,6 +8,26 @@ import type { ManagedAgentRuntimeStatus, } from "@/shared/api/types"; +export type RegisterExistingRelayAgentInput = { + agentPubkey: string; + displayName: string; + expectedOwnerPubkey: string; + expectedRelayUrl: string; + expectedSignerPubkey: string; +}; + +export type RegisterExistingRelayAgentResult = { + eventId: string; + agentPubkey: string; + ownerPubkey: string; +}; + +export async function registerExistingRelayAgent( + input: RegisterExistingRelayAgentInput, +): Promise { + return invokeTauri("register_existing_relay_agent", { input }); +} + export async function startManagedAgent( pubkey: string, options?: { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e4028f01716..f885b8bae81 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13794,6 +13794,26 @@ export function maybeInstallE2eTauriMocks() { // The spec only verifies UI state, not the submitted request shape; // returning null mirrors the Rust submit_event success path. return null; + case "register_existing_relay_agent": { + const args = ( + payload as { + input: { + agentPubkey: string; + displayName: string; + expectedOwnerPubkey: string; + expectedRelayUrl: string; + expectedSignerPubkey: string; + }; + } + ).input; + assertExpectedRelayScope(args.expectedRelayUrl, activeConfig); + assertExpectedSigner(args.expectedSignerPubkey, activeConfig); + return { + eventId: mockEventId(), + agentPubkey: args.agentPubkey, + ownerPubkey: args.expectedOwnerPubkey, + }; + } case "set_canvas": return { ok: true, event_id: mockEventId() }; case "get_canvas": { diff --git a/desktop/tests/e2e/register-existing-agent.spec.ts b/desktop/tests/e2e/register-existing-agent.spec.ts new file mode 100644 index 00000000000..7eae533b69f --- /dev/null +++ b/desktop/tests/e2e/register-existing-agent.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; +const SOURCE_AGENT = TEST_IDENTITIES.charlie.pubkey; +const REMOTE_AGENT = TEST_IDENTITIES.bob.pubkey; + +test("reviews and registers an owner-attested remote identity without creating a local runtime", async ({ + page, +}) => { + await installMockBridge(page, { + archivedIdentities: [], + managedAgents: [ + { + pubkey: SOURCE_AGENT, + name: "Registry helper", + status: "running", + channelNames: ["agents"], + }, + ], + oaOwnerIsMe: true, + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + ); + + await page.evaluate( + ({ agentPubkey, channelId, remoteAgent }) => { + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey, + events: [ + { + seq: 1, + timestamp: new Date().toISOString(), + kind: "tool_result", + agentIndex: 0, + channelId, + sessionId: "registry-session", + turnId: "registry-turn", + payload: { + type: "agent_management_request", + action: "adopt", + requestId: "register-remote-agent", + request: { + channelId, + agentPubkey: remoteAgent, + displayName: "Luci", + }, + }, + }, + ], + }); + }, + { + agentPubkey: SOURCE_AGENT, + channelId: CHANNEL_ID, + remoteAgent: REMOTE_AGENT, + }, + ); + + const review = page.getByTestId("register-existing-agent-review"); + await expect(review).toBeVisible(); + await expect(review).toContainText("Register existing agent"); + await expect(review).toContainText("Luci"); + await expect(review).toContainText(REMOTE_AGENT); + await expect(review).toContainText("Owner only"); + await expect( + review.getByRole("button", { name: "Register identity" }), + ).toBeEnabled(); + await review.screenshot({ + path: "test-results/register-existing-agent/01-owner-review.png", + }); + await review.getByRole("button", { name: "Register identity" }).click(); + await expect(review).not.toBeVisible(); + + const commands = await page.evaluate( + () => window.__BUZZ_E2E_COMMAND_LOG__ ?? [], + ); + const registration = commands.find( + (entry) => entry.command === "register_existing_relay_agent", + ); + expect(registration?.payload).toMatchObject({ + input: { + agentPubkey: REMOTE_AGENT, + displayName: "Luci", + expectedOwnerPubkey: expect.any(String), + expectedRelayUrl: expect.any(String), + expectedSignerPubkey: expect.any(String), + }, + }); + expect(commands.map((entry) => entry.command)).not.toContain( + "create_managed_agent", + ); +});