diff --git a/desktop/src-tauri/src/commands/external_agent_identity.rs b/desktop/src-tauri/src/commands/external_agent_identity.rs new file mode 100644 index 0000000000..fb31f3973f --- /dev/null +++ b/desktop/src-tauri/src/commands/external_agent_identity.rs @@ -0,0 +1,195 @@ +use nostr::ToBech32; +use serde::Serialize; +use serde_json::Value; +use tauri::State; +use zeroize::Zeroize; + +use crate::{ + app_state::{keyring_service, AppState}, + commands::identity_archive::{extract_oa_owner, fetch_kind0}, + events, + managed_agents::persona_events::monotonic_created_at, + models::ProfileInfo, + nostr_convert, + relay::{query_relay, submit_event_with_keys}, + secret_store::SecretStore, +}; + +const EXTERNAL_AGENT_KEY_PREFIX: &str = "external-agent-nsec:"; + +#[derive(Debug, Serialize)] +pub struct ExternalAgentIdentityStatus { + pub linked: bool, +} + +fn normalize_pubkey(pubkey: &str) -> Result { + let normalized = pubkey.trim().to_ascii_lowercase(); + nostr::PublicKey::from_hex(&normalized) + .map_err(|_| "invalid external agent public key".to_string())?; + Ok(normalized) +} + +fn external_agent_key(pubkey: &str) -> String { + format!("{EXTERNAL_AGENT_KEY_PREFIX}{pubkey}") +} + +fn verify_agent_owner( + event: &nostr::Event, + expected_owner: &nostr::PublicKey, +) -> Result<(), String> { + let Some((owner, _)) = extract_oa_owner(event) else { + return Err("the agent profile has no verified owner attestation".to_string()); + }; + if !owner.eq_ignore_ascii_case(&expected_owner.to_hex()) { + return Err("this Buzz identity does not own the external agent".to_string()); + } + Ok(()) +} + +fn build_owner_auth_tag( + owner_keys: &nostr::Keys, + agent_pubkey: &nostr::PublicKey, +) -> Result<(String, nostr::Tag), String> { + let auth_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(owner_keys, agent_pubkey, "") + .map_err(|e| format!("failed to build owner attestation: {e}"))?; + let parts: Vec = serde_json::from_str(&auth_json) + .map_err(|e| format!("failed to parse owner attestation: {e}"))?; + let tag = + nostr::Tag::parse(parts).map_err(|e| format!("failed to encode owner attestation: {e}"))?; + Ok((auth_json, tag)) +} + +#[tauri::command] +pub fn get_external_agent_identity_status( + pubkey: String, +) -> Result { + let pubkey = normalize_pubkey(&pubkey)?; + let linked = SecretStore::shared(keyring_service()) + .load(&external_agent_key(&pubkey))? + .is_some(); + Ok(ExternalAgentIdentityStatus { linked }) +} + +#[tauri::command] +pub async fn link_external_agent_identity( + pubkey: String, + mut nsec: String, + state: State<'_, AppState>, +) -> Result { + let pubkey = normalize_pubkey(&pubkey)?; + let result = async { + let agent_keys = + nostr::Keys::parse(nsec.trim()).map_err(|_| "invalid private key".to_string())?; + if agent_keys.public_key().to_hex() != pubkey { + return Err("the private key does not belong to this agent".to_string()); + } + + let owner_keys = state.signing_keys()?; + let event = fetch_kind0(&state, &pubkey) + .await? + .ok_or_else(|| "the agent has no published Buzz profile".to_string())?; + verify_agent_owner(&event, &owner_keys.public_key())?; + + SecretStore::shared(keyring_service()).store( + &external_agent_key(&pubkey), + &agent_keys + .secret_key() + .to_bech32() + .map_err(|e| e.to_string())?, + )?; + + Ok(ExternalAgentIdentityStatus { linked: true }) + } + .await; + nsec.zeroize(); + result +} + +#[tauri::command] +pub async fn update_external_agent_profile( + pubkey: String, + display_name: Option, + avatar_url: Option, + about: Option, + state: State<'_, AppState>, +) -> Result { + let pubkey = normalize_pubkey(&pubkey)?; + let stored_nsec = SecretStore::shared(keyring_service()) + .load(&external_agent_key(&pubkey))? + .ok_or_else(|| "link this external agent before editing its profile".to_string())?; + let agent_keys = nostr::Keys::parse(stored_nsec.trim()) + .map_err(|_| "the linked private key is invalid".to_string())?; + if agent_keys.public_key().to_hex() != pubkey { + return Err("the linked private key no longer matches this agent".to_string()); + } + + let owner_keys = state.signing_keys()?; + let prior_event = fetch_kind0(&state, &pubkey) + .await? + .ok_or_else(|| "the agent has no published Buzz profile".to_string())?; + verify_agent_owner(&prior_event, &owner_keys.public_key())?; + + let current: Value = serde_json::from_str(&prior_event.content).unwrap_or(Value::Null); + let dn = display_name + .as_deref() + .or_else(|| current.get("display_name").and_then(Value::as_str)); + let name = current.get("name").and_then(Value::as_str); + let picture = avatar_url + .as_deref() + .or_else(|| current.get("picture").and_then(Value::as_str)); + let about = about + .as_deref() + .or_else(|| current.get("about").and_then(Value::as_str)); + let nip05 = current.get("nip05").and_then(Value::as_str); + let (auth_json, auth_tag) = build_owner_auth_tag(&owner_keys, &agent_keys.public_key())?; + + let builder = events::build_profile(dn, name, picture, about, nip05)? + .tags([auth_tag]) + .custom_created_at(monotonic_created_at(Some( + prior_event.created_at.as_secs() as i64 + ))); + submit_event_with_keys(builder, &state, &agent_keys, Some(&auth_json)).await?; + + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": [pubkey.clone()], + "limit": 1 + })], + ) + .await?; + Ok(events + .first() + .map(nostr_convert::profile_info_from_event) + .transpose()? + .unwrap_or_else(|| ProfileInfo { + pubkey, + display_name: dn.map(str::to_string), + avatar_url: picture.map(str::to_string), + about: about.map(str::to_string), + nip05_handle: nip05.map(str::to_string), + owner_pubkey: Some(owner_keys.public_key().to_hex()), + has_profile_event: true, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_agent_secret_key_is_namespaced_by_normalized_pubkey() { + let pubkey = "01".repeat(32); + assert_eq!( + external_agent_key(&pubkey), + format!("external-agent-nsec:{pubkey}") + ); + } + + #[test] + fn rejects_non_pubkeys_without_echoing_input() { + let error = normalize_pubkey("not-a-key").unwrap_err(); + assert_eq!(error, "invalid external agent public key"); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..aaab4c80cb 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -18,6 +18,7 @@ mod clipboard; mod dms; mod engrams; mod export_util; +mod external_agent_identity; mod global_agent_config; mod identity; mod identity_archive; @@ -77,6 +78,7 @@ pub use channels::*; pub use clipboard::*; pub use dms::*; pub use engrams::*; +pub use external_agent_identity::*; pub use global_agent_config::*; pub use identity::*; pub use identity_archive::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..cb51e59460 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -662,6 +662,9 @@ pub fn run() { get_profile, update_profile, update_profile_at_relay, + get_external_agent_identity_status, + link_external_agent_identity, + update_external_agent_profile, get_user_profile, get_users_batch, get_user_notes, diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..b554bbfc53 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,7 +5,8 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + getVisibleExternalAgents, + isAgentIdentityAllowed, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -136,25 +137,68 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); -test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { +test("getVisibleExternalAgents: returns invocable relay agents that are not managed locally", () => { + const result = getVisibleExternalAgents({ + managedAgentPubkeys: [PUB_A.toUpperCase()], + currentPubkey: CURRENT_PUBKEY, + relayAgents: [ + { + pubkey: PUB_A, + name: "Managed", + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["general"], + }, + { + pubkey: PUB_B, + name: "Zulu", + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + channelIds: ["other"], + }, + { + pubkey: PUB_C, + name: "Alpha", + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["general"], + }, + { + pubkey: PUB_D, + name: "Hidden", + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["other"], + }, + ], + sharedChannelIds: new Set(["general"]), + }); + + assert.deepEqual( + result.map((agent) => agent.pubkey), + [PUB_C, PUB_B], + ); +}); + +test("isAgentIdentityAllowed: keeps people and only explicitly allowed agent identities", () => { const managedAgentPubkeys = new Set([PUB_A]); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityAllowed( { isAgent: false, pubkey: PUB_B }, managedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityAllowed( { isAgent: true, pubkey: PUB_A.toUpperCase() }, managedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityAllowed( { isAgent: true, pubkey: PUB_B }, managedAgentPubkeys, ), diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..e2875914c7 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,13 +54,37 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isAgentIdentityInManagedList( +export function getVisibleExternalAgents({ + currentPubkey, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, +}: { + currentPubkey?: string | null; + managedAgentPubkeys: Iterable; + relayAgents: readonly RelayAgent[] | undefined; + sharedChannelIds: ReadonlySet; +}) { + const managed = new Set( + [...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), + ); + + return (relayAgents ?? []) + .filter( + (agent) => + !managed.has(normalizePubkey(agent.pubkey)) && + relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey), + ) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function isAgentIdentityAllowed( candidate: { isAgent?: boolean; pubkey: string }, - managedAgentPubkeys: ReadonlySet, + allowedAgentPubkeys: ReadonlySet, ) { return ( candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + allowedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) ); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f24a3c06d7..44d423dcad 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -20,6 +20,7 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; +import { ExternalAgentsSection } from "./ExternalAgentsSection"; import { AGENT_CARD_GRID_COLUMNS_CLASS, UnifiedAgentsSection, @@ -209,6 +210,13 @@ export function AgentsView() { }} /> + { + openProfilePanel?.(pubkey, options); + }} + /> + + ["external-agent-identity", pubkey.toLowerCase()] as const; + +export function useExternalAgentIdentityStatus(pubkey: string) { + return useQuery({ + queryKey: identityStatusQueryKey(pubkey), + queryFn: () => getExternalAgentIdentityStatus(pubkey), + staleTime: 30_000, + }); +} + +export function ExternalAgentIdentityDialog({ + name, + onOpenChange, + open, + profile, + pubkey, +}: { + name: string; + onOpenChange: (open: boolean) => void; + open: boolean; + profile: Profile | undefined; + pubkey: string; +}) { + const queryClient = useQueryClient(); + const statusQuery = useExternalAgentIdentityStatus(pubkey); + const [nsec, setNsec] = React.useState(""); + const [displayName, setDisplayName] = React.useState(""); + const [avatarUrl, setAvatarUrl] = React.useState(""); + const [about, setAbout] = React.useState(""); + const [pendingAction, setPendingAction] = React.useState< + "link" | "save" | "image" | null + >(null); + + React.useEffect(() => { + if (!open) { + setNsec(""); + return; + } + setDisplayName(profile?.displayName?.trim() || name); + setAvatarUrl(profile?.avatarUrl?.trim() || ""); + setAbout(profile?.about || ""); + }, [name, open, profile]); + + const linked = statusQuery.data?.linked === true; + const busy = pendingAction !== null; + + async function handleLink() { + setPendingAction("link"); + try { + await linkExternalAgentIdentity(pubkey, nsec); + setNsec(""); + await queryClient.invalidateQueries({ + queryKey: identityStatusQueryKey(pubkey), + }); + toast.success(`${name} is linked securely.`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Couldn’t link this agent.", + ); + } finally { + setPendingAction(null); + } + } + + async function handleChooseImage() { + setPendingAction("image"); + try { + const image = await pickAndUploadImage(); + if (image) setAvatarUrl(image.url); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Couldn’t upload the image.", + ); + } finally { + setPendingAction(null); + } + } + + async function handleSave() { + setPendingAction("save"); + try { + await updateExternalAgentProfile(pubkey, { + displayName: displayName.trim(), + avatarUrl: avatarUrl.trim(), + about: about.trim(), + }); + evictUsersBatchEntries(queryClient, [pubkey]); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["user-profile", pubkey.toLowerCase()], + }), + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "users-batch" && + query.queryKey.includes(pubkey.toLowerCase()), + }), + ]); + toast.success(`Updated ${displayName.trim() || name}.`); + onOpenChange(false); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Couldn’t update this agent.", + ); + } finally { + setPendingAction(null); + } + } + + return ( + + + + {linked ? `Edit ${name}` : `Link ${name}`} + + {linked + ? "Edit the public Buzz profile. The Hermes model, memory, and runtime stay managed by Hermes." + : "Link the existing Hermes identity so Buzz can edit its public name, photo, and description."} + + + +
+ {statusQuery.isLoading ? ( +
+ + Checking secure storage… +
+ ) : linked ? ( + <> +
+ +
+ + setDisplayName(event.target.value)} + value={displayName} + /> +
+
+ +
+
+ + +
+ setAvatarUrl(event.target.value)} + placeholder="https://…" + value={avatarUrl} + /> +
+ +
+ +