diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c4807..174f7a0565 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -465,6 +465,36 @@ fn parse_channel_uuid(channel_id: &str) -> Result { uuid::Uuid::parse_str(channel_id).map_err(|_| format!("invalid channel UUID: {channel_id}")) } +fn build_external_agent_auth_tag( + owner_keys: &nostr::Keys, + members: &ChannelMembersResponse, + agent_pubkey: &str, +) -> Result { + let owner_pubkey = owner_keys.public_key().to_hex(); + let owner_is_channel_owner = members + .members + .iter() + .any(|member| member.pubkey.eq_ignore_ascii_case(&owner_pubkey) && member.role == "owner"); + if !owner_is_channel_owner { + return Err("only a channel owner can authorize an external agent".to_string()); + } + + let agent = nostr::PublicKey::from_hex(agent_pubkey) + .map_err(|e| format!("invalid agent pubkey: {e}"))?; + let normalized_agent_pubkey = agent.to_hex(); + let agent_is_bot_member = members.members.iter().any(|member| { + member.pubkey.eq_ignore_ascii_case(&normalized_agent_pubkey) + && member.role == "bot" + && member.is_agent + }); + if !agent_is_bot_member { + return Err("the external agent must be a bot member of this channel".to_string()); + } + + buzz_sdk_pkg::nip_oa::compute_auth_tag(owner_keys, &agent, "") + .map_err(|e| format!("failed to authorize external agent: {e}")) +} + fn normalize_channel_name(name: &str) -> String { name.trim().to_ascii_lowercase() } @@ -848,6 +878,60 @@ pub async fn change_channel_member_role( Ok(()) } +/// Create an owner-reviewed NIP-OA credential for an existing external bot. +/// +/// The backend repeats the UI policy checks before signing: the current +/// identity must be a channel owner, the target must be an agent with the bot +/// role in that channel, and a valid target profile must not already name an +/// owner. The credential is returned to the caller but never persisted by +/// Desktop. +#[tauri::command] +pub async fn authorize_external_agent( + channel_id: String, + agent_pubkey: String, + state: State<'_, AppState>, +) -> Result { + let channel_uuid = parse_channel_uuid(&channel_id)?; + let normalized_channel_id = channel_uuid.to_string(); + let agent = nostr::PublicKey::from_hex(&agent_pubkey) + .map_err(|e| format!("invalid agent pubkey: {e}"))?; + let normalized_agent_pubkey = agent.to_hex(); + + let member_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39002], + "#d": [normalized_channel_id], + "limit": 1 + })], + ) + .await?; + let members = member_events + .first() + .map(nostr_convert::channel_members_from_event) + .transpose()? + .ok_or_else(|| "channel members not found".to_string())?; + + let profile_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": [normalized_agent_pubkey], + "limit": 1 + })], + ) + .await?; + if profile_events + .first() + .is_some_and(nostr_convert::profile_has_valid_oa_owner) + { + return Err("this external agent already has a valid owner authorization".to_string()); + } + + let owner_keys = state.signing_keys()?; + build_external_agent_auth_tag(&owner_keys, &members, &agent.to_hex()) +} + #[tauri::command] pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 5b65695a91..fb11ff1cc6 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -27,6 +27,73 @@ const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa const PK_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const PK_C: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +fn member(pubkey: String, role: &str, is_agent: bool) -> crate::models::ChannelMemberInfo { + crate::models::ChannelMemberInfo { + pubkey, + role: role.to_string(), + is_agent, + joined_at: None, + display_name: None, + } +} + +fn members(rows: Vec) -> ChannelMembersResponse { + ChannelMembersResponse { + members: rows, + next_cursor: None, + } +} + +#[test] +fn external_agent_auth_requires_owner_and_bot_membership() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let channel_members = members(vec![ + member(owner.public_key().to_hex(), "owner", false), + member(agent.public_key().to_hex(), "bot", true), + ]); + + let auth_tag = + build_external_agent_auth_tag(&owner, &channel_members, &agent.public_key().to_hex()) + .expect("channel owner should authorize bot member"); + let recovered_owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&auth_tag, &agent.public_key()) + .expect("authorization must verify"); + + assert_eq!(recovered_owner, owner.public_key()); +} + +#[test] +fn external_agent_auth_rejects_non_owner_signer() { + let signer = Keys::generate(); + let agent = Keys::generate(); + let channel_members = members(vec![ + member(signer.public_key().to_hex(), "admin", false), + member(agent.public_key().to_hex(), "bot", true), + ]); + + let error = + build_external_agent_auth_tag(&signer, &channel_members, &agent.public_key().to_hex()) + .expect_err("admin must not mint owner authorization"); + + assert!(error.contains("only a channel owner")); +} + +#[test] +fn external_agent_auth_rejects_non_bot_target() { + let owner = Keys::generate(); + let target = Keys::generate(); + let channel_members = members(vec![ + member(owner.public_key().to_hex(), "owner", false), + member(target.public_key().to_hex(), "member", false), + ]); + + let error = + build_external_agent_auth_tag(&owner, &channel_members, &target.public_key().to_hex()) + .expect_err("human member must not receive an agent authorization"); + + assert!(error.contains("must be a bot member")); +} + #[test] fn directory_cursor_keeps_same_second_tiebreaker() { let timestamp = Timestamp::from(1_700_000_000); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7dcc5994ae..ac6d607f9e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -744,6 +744,7 @@ pub fn run() { add_channel_members, remove_channel_member, change_channel_member_role, + authorize_external_agent, join_channel, leave_channel, get_canvas, diff --git a/desktop/src/features/channels/api/externalAgentAuthorization.ts b/desktop/src/features/channels/api/externalAgentAuthorization.ts new file mode 100644 index 0000000000..ddbfe55e56 --- /dev/null +++ b/desktop/src/features/channels/api/externalAgentAuthorization.ts @@ -0,0 +1,15 @@ +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; + +export async function authorizeExternalAgent( + channelId: string, + agentPubkey: string, +): Promise { + try { + return await tauriInvoke("authorize_external_agent", { + channelId, + agentPubkey, + }); + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)); + } +} diff --git a/desktop/src/features/channels/ui/ExternalAgentAuthorizationDialog.tsx b/desktop/src/features/channels/ui/ExternalAgentAuthorizationDialog.tsx new file mode 100644 index 0000000000..37b652042c --- /dev/null +++ b/desktop/src/features/channels/ui/ExternalAgentAuthorizationDialog.tsx @@ -0,0 +1,68 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; + +type ExternalAgentAuthorizationDialogProps = { + agentLabel: string; + error: unknown; + isPending: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; +}; + +export function ExternalAgentAuthorizationDialog({ + agentLabel, + error, + isPending, + onConfirm, + onOpenChange, + open, +}: ExternalAgentAuthorizationDialogProps) { + return ( + + + + Authorize external agent? + + Only continue if you control and trust {agentLabel}. Buzz will sign + an owner authorization that lets this agent use your relay + membership and identifies it as managed by you. Your private key + stays on this device; only the resulting authorization is copied. + + + {error instanceof Error ? ( +

{error.message}

+ ) : null} + + + + + + + + +
+
+ ); +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a2..1e6400a7b3 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -56,6 +56,8 @@ import { managedAgentPairAction, } from "@/features/agents/managedAgentRuntimeStatus"; import { EditRespondToDialog } from "./EditRespondToDialog"; +import { ExternalAgentAuthorizationDialog } from "./ExternalAgentAuthorizationDialog"; +import { useExternalAgentAuthorization } from "./useExternalAgentAuthorization"; import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; const MEMBER_ADD_RESULT_LIMIT = 50; @@ -520,6 +522,7 @@ export function MembersSidebar({ const [editRespondToAgent, setEditRespondToAgent] = React.useState(null); + const externalAgentAuthorization = useExternalAgentAuthorization(channelId); React.useEffect(() => { if (!open) { @@ -598,6 +601,13 @@ export function MembersSidebar({ const managedAgent = memberIsBot ? managedAgentByPubkey.get(normalizePubkey(member.pubkey)) : undefined; + const canAuthorizeExternalAgent = + selfMember?.role === "owner" && + memberIsBot && + !managedAgent && + memberProfilesQuery.isSuccess && + !memberProfile?.ownerPubkey && + member.pubkey !== currentPubkey; const managedAgentRuntime = memberIsBot && relayUrl ? findManagedAgentRuntime( @@ -616,13 +626,15 @@ export function MembersSidebar({ return (
{ + externalAgentAuthorization.open({ + label: formatMemberName(targetMember, currentPubkey), + member: targetMember, + }); + }} onBan={onBan} onChangeRole={(m, role) => { void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); @@ -871,6 +889,14 @@ export function MembersSidebar({ }} open={editRespondToAgent !== null} /> + ); } diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index b375649292..f4ac4554db 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -5,6 +5,7 @@ import { CircleSlash, Clock, Ellipsis, + KeyRound, Pencil, Play, RotateCcw, @@ -46,6 +47,7 @@ import { } from "@/shared/ui/dropdown-menu"; type MembersSidebarMemberCardProps = { + canAuthorizeExternalAgent: boolean; canChangeRole: boolean; canModerate: boolean; canRemoveMember: boolean; @@ -61,6 +63,7 @@ type MembersSidebarMemberCardProps = { memberIsBot: boolean; memberLabel: string; moderationState?: MemberModerationState; + onAuthorizeExternalAgent: (member: ChannelMember) => void; onBan: (member: ChannelMember) => void; onChangeRole: (member: ChannelMember, role: string) => void; onEditRespondTo?: (agent: ManagedAgent) => void; @@ -116,6 +119,7 @@ function formatRespondToLabel(agent: ManagedAgent) { } export function MembersSidebarMemberCard({ + canAuthorizeExternalAgent, canChangeRole, canModerate, canRemoveMember, @@ -129,6 +133,7 @@ export function MembersSidebarMemberCard({ memberIsBot, memberLabel, moderationState, + onAuthorizeExternalAgent, onBan, onChangeRole, onEditRespondTo, @@ -154,7 +159,10 @@ export function MembersSidebarMemberCard({ const canModerateMember = canModerate && !memberIsBot && member.role !== "owner"; const hasActions = memberIsBot - ? Boolean(managedAgent) || canRemoveMember || canViewActivity + ? Boolean(managedAgent) || + canAuthorizeExternalAgent || + canRemoveMember || + canViewActivity : canRemoveMember || canChangeRole || canModerateMember; const memberIdentity = ( @@ -260,6 +268,7 @@ export function MembersSidebarMemberCard({ {memberIdentity} {hasActions ? ( void; onBan: (member: ChannelMember) => void; onChangeRole: (member: ChannelMember, role: string) => void; onEditRespondTo?: (agent: ManagedAgent) => void; @@ -387,6 +401,16 @@ function MemberActionsMenu({ ) : null} ) : null} + {canAuthorizeExternalAgent ? ( + onAuthorizeExternalAgent(member)} + > + + Authorize external agent... + + ) : null} {showChangeRole ? ( - {showChangeRole ? : null} + {showChangeRole || canAuthorizeExternalAgent ? ( + + ) : null} (null); + const [notice, setNotice] = React.useState(null); + const mutation = useMutation({ + mutationFn: async (authorizationTarget: AuthorizationTarget) => { + if (!channelId) throw new Error("No channel selected."); + const authTag = await authorizeExternalAgent( + channelId, + authorizationTarget.member.pubkey, + ); + await writeTextToClipboard(authTag); + return authorizationTarget; + }, + onSuccess: (authorizedTarget) => { + setTarget(null); + setNotice( + `${authorizedTarget.label} authorized. Add the copied value to the agent as BUZZ_AUTH_TAG.`, + ); + }, + }); + const error = mutation.error instanceof Error ? mutation.error.message : null; + + useFeedbackToasts(notice, error); + + return { + error: mutation.error, + isPending: mutation.isPending, + onConfirm: () => { + if (target) mutation.mutate(target); + }, + onOpenChange: (open: boolean) => { + if (!open && !mutation.isPending) setTarget(null); + }, + open: (authorizationTarget: AuthorizationTarget) => { + mutation.reset(); + setNotice(null); + setTarget(authorizationTarget); + }, + target, + }; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 07eaa77902..79d7a809ee 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10875,6 +10875,13 @@ export function maybeInstallE2eTauriMocks() { } case "list_managed_agents": return handleListManagedAgents(activeConfig); + case "authorize_external_agent": + return JSON.stringify([ + "auth", + getMockMemberPubkey(activeConfig), + "", + "a".repeat(128), + ]); case "get_agent_memory": return handleGetAgentMemory( (payload as Parameters[0]) ?? {}, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 9da4022420..716a8f7e74 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3618,6 +3618,76 @@ test("members sidebar collapses same-persona managed agents", async ({ await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1); }); +test("channel owner can authorize an unowned external agent", async ({ + page, +}) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + const externalAgentPubkey = + "abababababababababababababababababababababababababababababababab"; + await installMockBridge(page, { + relayAgents: [ + { + pubkey: externalAgentPubkey, + name: "Hermes", + channelNames: ["general"], + }, + ], + searchProfiles: [ + { + pubkey: externalAgentPubkey, + displayName: "Hermes", + ownerPubkey: null, + isAgent: true, + }, + ], + }); + await page.goto("/"); + await invokeMockCommand(page, "add_channel_members", { + channelId: GENERAL_CHANNEL_ID, + pubkeys: [externalAgentPubkey], + role: "bot", + }); + await page.evaluate(async () => { + await ( + window as Window & { + __BUZZ_E2E_QUERY_CLIENT__?: { + invalidateQueries: () => Promise; + }; + } + ).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries(); + }); + + await openMembersSidebar(page, "general"); + await openMemberMenu(page, externalAgentPubkey); + await page + .getByTestId(`sidebar-authorize-agent-${externalAgentPubkey}`) + .click(); + + await expect( + page.getByTestId("external-agent-authorization-dialog"), + ).toContainText("Your private key stays on this device"); + await page.getByTestId("external-agent-authorization-confirm").click(); + + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Hermes authorized" }), + ).toBeVisible(); + + const commandLog = await readCommandPayloadLog(page); + expect( + commandLog.some( + (entry) => + entry.command === "authorize_external_agent" && + (entry.payload as { agentPubkey?: string }).agentPubkey === + externalAgentPubkey, + ), + ).toBe(true); + expect( + commandLog.some((entry) => entry.command === "copy_text_to_clipboard"), + ).toBe(true); +}); + test("private-channel members can add people and managed agents without admin", async ({ page, }) => {