From 3acf6a70ed4f72eb6d5a3d76c85183c4e6160ed6 Mon Sep 17 00:00:00 2001 From: Leo Zenon Tassi <60750902+zeolenon@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:13:18 -0300 Subject: [PATCH 1/4] fix(desktop): allow authorized external agents in mentions Signed-off-by: Leo Zenon Tassi <60750902+zeolenon@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 10 ++--- .../lib/agentAutocompleteEligibility.ts | 6 +-- .../features/channels/ui/MembersSidebar.tsx | 4 +- .../src/features/messages/lib/useMentions.ts | 24 ++++++++++-- desktop/tests/e2e/mentions.spec.ts | 39 +++++++++++++++++++ 5 files changed, 70 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..eb78883c25 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,7 +5,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityAllowed, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, } from "./agentAutocompleteEligibility.ts"; @@ -136,25 +136,25 @@ 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("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..43fc3e2ba4 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,13 +54,13 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isAgentIdentityInManagedList( +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/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a2..b07f9b4b97 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -9,7 +9,7 @@ import { import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, - isAgentIdentityInManagedList, + isAgentIdentityAllowed, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -282,7 +282,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityAllowed(candidate, managedAgentPubkeys) ) { return; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..e5e79dad96 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -16,7 +16,7 @@ import { coalesceAutocompleteCandidatesByKey, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityAllowed, shouldHideAgentFromMentions, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { @@ -238,6 +238,24 @@ export function useMentions( new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), [members], ); + const allowedExternalAgentPubkeys = React.useMemo( + () => + new Set( + (members ?? []) + .filter((member) => member.isAgent === true || member.role === "bot") + .map((member) => normalizePubkey(member.pubkey)) + .filter( + (pubkey) => + directoryAgentPubkeys.has(pubkey) && + mentionableAgentPubkeys.has(pubkey), + ), + ), + [directoryAgentPubkeys, members, mentionableAgentPubkeys], + ); + const allowedAgentIdentityPubkeys = React.useMemo( + () => new Set([...managedAgentPubkeys, ...allowedExternalAgentPubkeys]), + [allowedExternalAgentPubkeys, managedAgentPubkeys], + ); const mentionCandidates = React.useMemo(() => { const candidatesByPubkey = new Map(); @@ -246,7 +264,7 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if (!isAgentIdentityAllowed(candidate, allowedAgentIdentityPubkeys)) { return; } if ( @@ -412,6 +430,7 @@ export function useMentions( }, [ activePersonaById, activePersonas, + allowedAgentIdentityPubkeys, userSearchResults, canSearchGlobalUsers, currentPubkey, @@ -420,7 +439,6 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, - managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef5..3272434560 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -849,6 +849,45 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" await expect(autocomplete(page)).toHaveCount(0); }); +test("directory-backed channel agents are mentionable without a local runtime", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: PROFILE_ONLY_AGENT_PUBKEY, + name: "mira", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelIds: ["9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@mira"); + + const dropdown = autocomplete(page); + await expect(dropdown.getByText("mira")).toBeVisible(); + await expect(dropdown.getByText("agent")).toBeVisible(); + await input.press("Enter"); + await page.keyboard.type(" hello"); + + const baselineStartCount = commandCount( + await readCommandLog(page), + "start_managed_agent", + ); + await page.getByTestId("send-message").click(); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "start_managed_agent"), + ) + .toBe(baselineStartCount); +}); + test("mentioning an in-channel stopped managed agent starts it before sending", async ({ page, }) => { From b0c9b58d19ff321a6481782f5739b1ca6e7d61de Mon Sep 17 00:00:00 2001 From: Leo Zenon Tassi <60750902+zeolenon@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:30:59 -0300 Subject: [PATCH 2/4] feat(desktop): list authorized external agents Signed-off-by: Leo Zenon Tassi <60750902+zeolenon@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 44 ++++++++ .../lib/agentAutocompleteEligibility.ts | 24 +++++ desktop/src/features/agents/ui/AgentsView.tsx | 8 ++ .../agents/ui/ExternalAgentsSection.tsx | 100 ++++++++++++++++++ desktop/tests/e2e/agents.spec.ts | 28 +++++ 5 files changed, 204 insertions(+) create mode 100644 desktop/src/features/agents/ui/ExternalAgentsSection.tsx diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index eb78883c25..b554bbfc53 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -5,6 +5,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, + getVisibleExternalAgents, isAgentIdentityAllowed, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, @@ -136,6 +137,49 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); +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]); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 43fc3e2ba4..e2875914c7 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,6 +54,30 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +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 }, allowedAgentPubkeys: ReadonlySet, 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); + }} + /> + ; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}; + +export function ExternalAgentsSection({ + managedAgentPubkeys, + onOpenAgentProfile, +}: ExternalAgentsSectionProps) { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + const sharedChannelIds = React.useMemo( + () => getSharedChannelIds(channelsQuery.data), + [channelsQuery.data], + ); + const agents = React.useMemo( + () => + getVisibleExternalAgents({ + currentPubkey: identityQuery.data?.pubkey, + managedAgentPubkeys, + relayAgents: relayAgentsQuery.data, + sharedChannelIds, + }), + [ + identityQuery.data?.pubkey, + managedAgentPubkeys, + relayAgentsQuery.data, + sharedChannelIds, + ], + ); + + if (agents.length === 0) return null; + + return ( +
+
+

Connected agents

+

+ Agents that run outside Buzz Desktop and are available to you. +

+
+
+ {agents.map((agent) => ( + + ))} +
+
+ ); +} + +function ExternalAgentCard({ + agent, + onOpenAgentProfile, +}: { + agent: RelayAgent; + onOpenAgentProfile: (pubkey: string) => void; +}) { + const profileQuery = useUserProfileQuery(agent.pubkey); + const title = profileQuery.data?.displayName?.trim() || agent.name; + const agentType = agent.agentType.trim(); + const modelLabel = agentType + ? `${agentType} · managed externally` + : "Managed externally"; + + return ( + onOpenAgentProfile(agent.pubkey)} + /> + ); +} diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 13eda788b1..986c989b15 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -42,6 +42,34 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +test("shows invocable relay-native agents separately from local runtimes", async ({ + page, +}) => { + const externalPubkey = "9".repeat(64); + await installMockBridge(page, { + relayAgents: [ + { + pubkey: externalPubkey, + name: "Hermes Native", + agentType: "hermes", + respondTo: "allowlist", + respondToAllowlist: ["deadbeef".repeat(8)], + status: "online", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const section = page.getByTestId("external-agents-section"); + await expect(section).toContainText("Connected agents"); + await expect(section).toContainText("Hermes Native"); + await expect(section).toContainText("hermes · managed externally"); + await expect( + page.getByTestId(`external-agent-${externalPubkey}`), + ).toBeVisible(); +}); + async function gotoApp(page: import("@playwright/test").Page) { let lastError: unknown = null; From 310f0fefa79250aee58a45349dfd0a9bbd295dc1 Mon Sep 17 00:00:00 2001 From: Leo Zenon Tassi <60750902+zeolenon@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:46:06 -0300 Subject: [PATCH 3/4] feat(desktop): securely link external agent identities Signed-off-by: Leo Zenon Tassi <60750902+zeolenon@users.noreply.github.com> --- .../src/commands/external_agent_identity.rs | 195 ++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 3 + .../agents/ui/ExternalAgentIdentityDialog.tsx | 289 ++++++++++++++++++ .../agents/ui/ExternalAgentsSection.tsx | 52 +++- .../shared/api/tauriExternalAgentIdentity.ts | 55 ++++ desktop/src/testing/e2eBridge.ts | 47 +++ desktop/tests/e2e/agents.spec.ts | 15 + 8 files changed, 650 insertions(+), 8 deletions(-) create mode 100644 desktop/src-tauri/src/commands/external_agent_identity.rs create mode 100644 desktop/src/features/agents/ui/ExternalAgentIdentityDialog.tsx create mode 100644 desktop/src/shared/api/tauriExternalAgentIdentity.ts 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/ui/ExternalAgentIdentityDialog.tsx b/desktop/src/features/agents/ui/ExternalAgentIdentityDialog.tsx new file mode 100644 index 0000000000..0038e6ee96 --- /dev/null +++ b/desktop/src/features/agents/ui/ExternalAgentIdentityDialog.tsx @@ -0,0 +1,289 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ImagePlus, KeyRound, Loader2 } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { evictUsersBatchEntries } from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + getExternalAgentIdentityStatus, + linkExternalAgentIdentity, + updateExternalAgentProfile, +} from "@/shared/api/tauriExternalAgentIdentity"; +import { pickAndUploadImage } from "@/shared/api/tauriMedia"; +import type { Profile } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; + +const identityStatusQueryKey = (pubkey: string) => + ["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} + /> +
+ +
+ +