From d4a07063c8b816124e27e00720e5b47f0a5ae1ce Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 27 Oct 2026 14:05:44 -0700 Subject: [PATCH] feat(mobile): manage Builderlab remote agents (#8626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Builderlab remote agents to mobile: a member signed in to Builderlab can list, create and delete their own hosted agents from the channels header, and active remote agents show as Online. Everything is gated by `HostedEnvironment.enabled` (the build-time `BUZZ_AUTH_DOMAINS` define). App Store and internal release builds do not set it, so they render and fetch nothing new. ### Behavior - **Agents** button in the channels header (native iOS bar and Material header) for hosted communities only. It opens a sheet listing the community's agents; tapping one pushes its profile inside the same sheet. - **Create**: for the member's own agents (the NIP-OA owner is the member, not the community). Registers the agent with Beekeeper, attests it to the community, saves optional private instructions, subscribes it to the relay, then publishes the kind 30177 registration with a NIP-OA owner proof. A failed step resumes with the same agent identity on retry; instructions stay in secure storage, never in preferences. - **Delete**: removes the relay registration, confirms it is gone, then deletes the Beekeeper agent. A partial failure keeps a retry row in the list. - **Presence**: an ACTIVE agent with a live kind 30177 registration on the active relay shows Online in profiles, channel rows, DMs and mentions, since remote agents wake on messages. Local presence data is unchanged. - Sign-in reuses the shared hosted flow. The account's saved Buzz key must match the server binding; nothing is adopted or rewritten. - `BuilderlabAccountClient` gains an allowlist for the six Beekeeper routes desktop already uses. Not included from the prototype: the separate entrypoint and second sign-in, test-community creation, staging-only hosts, QR-code agent links, and the USB proxy. ### Validation - Added tests: build-time gate, account/key binding before listing, setup resume with private instructions, deletion contract and retry, owner-proof verification, status parsing, presented presence requiring a live registration in the active community and following registration publish and removal (mutation-checked), account-change clearing, load retry, recovery of a pending setup whose instructions were lost, a lost registration response that can only resume with the same idempotency key (from any community), an HTTP-rejected registration that can be discarded, deletion of other agents while a setup is unfinished, rejection of owner proofs whose signed conditions or profile signature fail, registration publish and removal over a loopback WebSocket relay for an `http://`-saved community, and widget tests for in-sheet detail navigation, creation overlay with failure then resume, and delete confirmation. - The new tests also run in the CI lane with `BUZZ_AUTH_DOMAINS` configured, so the enabled path is covered. - Full mobile format, analyze and test suite pass locally. - Independent agent review: no gate leak or key-handling issue found; its robustness findings (discarding a setup that never got a server identity, clearing registry state on account change, retrying a skipped load, guarded notifications) are fixed; discard and account change have added tests. - Simulator: a `BUZZ_AUTH_DOMAINS` debug build launches to Builderlab sign-in. The signed-in flow was not re-run on this branch. - Codex Security Review: an earlier head received a published review, and its two findings were fixed. On 745ad85 and be2e31b the review hit its 45-minute timeout and was cancelled without a result, so the final head has no published security review. - Human app test: not applicable under the mobile testing exception. The end-to-end create, mention and delete flow was demonstrated on the prototype branch against Builderlab; this PR keeps the same Beekeeper and relay contracts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Opus 5.5 --- .github/workflows/_ci-clients.yml | 2 + mobile/lib/features/agents/agents_sheet.dart | 276 +++++++ .../features/agents/create_agent_sheet.dart | 182 +++++ .../features/agents/delete_agent_action.dart | 107 +++ .../channels/channel_detail_page/app_bar.dart | 2 +- .../lib/features/channels/channels_page.dart | 14 + .../channels/channels_page/channel_tile.dart | 2 +- .../channels_page/header_actions.dart | 8 + .../mentions/mention_candidates_provider.dart | 2 +- .../profile/presence_cache_provider.dart | 11 + .../features/profile/user_profile_sheet.dart | 14 +- mobile/lib/shared/agents/remote_agents.dart | 701 ++++++++++++++++++ .../lib/shared/hosted/builderlab_account.dart | 26 +- .../lib/shared/relay/signed_event_relay.dart | 2 + .../test/features/agents/agents_harness.dart | 134 ++++ .../features/agents/agents_sheet_test.dart | 38 + .../agents/create_agent_sheet_test.dart | 68 ++ .../agents/delete_agent_action_test.dart | 51 ++ .../test/helpers/remote_agents_fixture.dart | 150 ++++ .../shared/agents/remote_agents_test.dart | 641 ++++++++++++++++ 20 files changed, 2423 insertions(+), 8 deletions(-) create mode 100644 mobile/lib/features/agents/agents_sheet.dart create mode 100644 mobile/lib/features/agents/create_agent_sheet.dart create mode 100644 mobile/lib/features/agents/delete_agent_action.dart create mode 100644 mobile/lib/shared/agents/remote_agents.dart create mode 100644 mobile/test/features/agents/agents_harness.dart create mode 100644 mobile/test/features/agents/agents_sheet_test.dart create mode 100644 mobile/test/features/agents/create_agent_sheet_test.dart create mode 100644 mobile/test/features/agents/delete_agent_action_test.dart create mode 100644 mobile/test/helpers/remote_agents_fixture.dart create mode 100644 mobile/test/shared/agents/remote_agents_test.dart diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index 8c9d080430..29ad02c00b 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -149,6 +149,8 @@ jobs: test/features/onboarding/hosted_journeys_test.dart test/features/onboarding/hosted_communities_test.dart test/features/pairing/pairing_hosted_handoff_test.dart + test/shared/agents/remote_agents_test.dart + test/features/agents - name: Test mobile without push configuration if: matrix.lane == 'checks' run: cd mobile && flutter test test/shared/push/push_unconfigured_build_test.dart diff --git a/mobile/lib/features/agents/agents_sheet.dart b/mobile/lib/features/agents/agents_sheet.dart new file mode 100644 index 0000000000..9191680f0a --- /dev/null +++ b/mobile/lib/features/agents/agents_sheet.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/agents/remote_agents.dart'; +import '../../shared/community/community_provider.dart'; +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/hosted/hosted_flow_provider.dart'; +import '../../shared/identity_names/identity_names_provider.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_titled_sheet_layout.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import '../channels/channel.dart'; +import '../channels/channel_detail_page.dart'; +import '../profile/user_profile_sheet.dart'; +import 'create_agent_sheet.dart'; +import 'delete_agent_action.dart'; + +/// Opens the active community's Agents navigation stack inside one modal. +Future showAgents(BuildContext context) async { + final container = ProviderScope.containerOf(context); + final controller = container.read(remoteAgentControllerProvider); + unawaited(controller.run(controller.refresh)); + final channel = await showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showCloseButton: false, + builder: (_) => _AgentsNavigation(controller: controller), + ); + if (channel != null && context.mounted) { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ChannelDetailPage(channel: channel)), + ); + } +} + +class _AgentsNavigation extends HookConsumerWidget { + const _AgentsNavigation({required this.controller}); + + final RemoteAgentController controller; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final communityId = ref.watch(activeCommunityProvider).asData?.value?.id; + final navigator = useMemoized(() => GlobalKey(), [ + communityId, + ]); + return SizedBox( + height: MediaQuery.sizeOf(context).height * .72, + child: NavigatorPopHandler( + onPopWithResult: (result) => navigator.currentState!.pop(result), + child: Navigator( + key: navigator, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (listContext) => Material( + color: context.colors.surface, + child: AgentsSheet( + controller: controller, + onClose: () => Navigator.of(context).pop(), + onSelect: (pubkey) async { + final channel = await Navigator.of(listContext).push( + MaterialPageRoute( + builder: (detailContext) => Material( + key: const ValueKey('agent-detail-surface'), + color: context.colors.surface, + child: SizedBox.expand( + child: BuzzTitledSheetLayout( + title: '', + leading: IconButton( + tooltip: 'Back', + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () => + Navigator.of(detailContext).pop(), + ), + trailing: const SizedBox.shrink(), + child: UserProfileSheet( + pubkey: pubkey, + primaryActionsSpacing: Grid.md, + contextualActionsSpacing: Grid.xxs, + contextualActions: (_) => DeleteAgentAction( + controller: controller, + pubkey: pubkey, + ), + ), + ), + ), + ), + ), + ); + if (channel != null && context.mounted) { + Navigator.of(context).pop(channel); + } + }, + ), + ), + ), + ), + ), + ); + } +} + +/// The active community's agents, with remote-agent creation for its owner. +class AgentsSheet extends HookConsumerWidget { + const AgentsSheet({ + required this.controller, + required this.onClose, + required this.onSelect, + super.key, + }); + final VoidCallback onClose; + final ValueChanged onSelect; + final RemoteAgentController controller; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(controller); + final flow = ref.watch(hostedFlowProvider); + useListenable(flow); + final signingIn = flow.reconnecting && flow.stage != HostedFlowStage.idle; + // Account sign-in belongs to the shared hosted flow; reload once it lands. + useEffect(() { + if (flow.reconnecting && flow.stage == HostedFlowStage.complete) { + unawaited(controller.run(controller.refresh)); + } + return null; + }, [flow.stage]); + final community = ref.watch(activeCommunityProvider).asData?.value; + final directory = ref.watch(agentDirectoryProvider); + final registryFuture = useMemoized(() async { + final fetch = ref.read(relaySessionProvider.notifier).fetchHistory; + return ownedCommunityAgents( + { + for (final agent in controller.agents) + agent['agent_pubkey'] as String, + }, + controller.owner, + fetch, + ); + }, [community?.relayUrl, controller.agents, controller.owner]); + final registered = useFuture(registryFuture, preserveState: false); + final keys = { + ...?registered.data, + for (final entry in directory.asData?.value ?? []) + entry.pubkey, + }.where((key) => !controller.deletedAgents.contains(key)).toList(); + final names = watchIdentityNames( + ref, + keys, + agentPubkeys: keys.toSet(), + fallbackNames: { + for (final agent in controller.agents) + agent['agent_pubkey'] as String: agent['agent_name'] as String, + }, + ); + keys.sort((a, b) => names.labelFor(a).compareTo(names.labelFor(b))); + final profiles = ref.watch(userCacheProvider); + final creating = useState(false); + final failed = directory.hasError || registered.hasError; + final loading = + !creating.value && + (controller.busy || + directory.isLoading || + registered.connectionState != ConnectionState.done); + + Future refresh() async { + await controller.run(controller.refresh); + if (context.mounted) ref.invalidate(agentDirectoryProvider); + } + + return SizedBox( + height: MediaQuery.sizeOf(context).height * .72, + child: BuzzTitledSheetLayout( + title: 'Agents', + leading: IconButton( + tooltip: 'Close', + icon: const Icon(Icons.close), + onPressed: onClose, + ), + trailing: IconButton( + tooltip: 'Add agent', + onPressed: community == null || controller.busy || creating.value + ? null + : () async { + creating.value = true; + try { + await showBuzzModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + showCloseButton: false, + builder: (_) => CreateAgentSheet( + controller: controller, + community: community, + onCreated: () => ref.invalidate(agentDirectoryProvider), + ), + ); + } finally { + if (context.mounted) creating.value = false; + } + }, + icon: const Icon(Icons.add), + ), + child: Material( + color: Colors.transparent, + child: RefreshIndicator( + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.sm), + children: [ + if (loading) const LinearProgressIndicator(), + if (controller.account.account == null) + ListTile( + title: const Text('Sign in to create an agent'), + trailing: TextButton( + onPressed: controller.busy || signingIn + ? null + : () => unawaited(flow.reconnectAccount()), + child: const Text('Sign in'), + ), + ), + if (!controller.busy && + controller.account.account != null && + controller.status.isNotEmpty && + !creating.value) + ListTile(subtitle: Text(controller.status)), + if (failed) + ListTile( + title: const Text('Could not load agents'), + trailing: TextButton( + onPressed: refresh, + child: const Text('Retry'), + ), + ), + if (!loading && !failed && keys.isEmpty) + const Padding( + padding: EdgeInsets.all(Grid.sm), + child: Text( + 'No agents in this community yet.', + textAlign: TextAlign.center, + ), + ), + for (final key in keys) + ListTile( + key: ValueKey(key), + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.xxs, + ), + leading: AvatarImage( + imageUrl: profiles[key]?.avatarUrl, + radius: Grid.sm, + isAgent: true, + backgroundColor: context.colors.surfaceContainerHighest, + fallback: Center( + child: Text(names.labelFor(key).characters.first), + ), + ), + title: Text(names.labelFor(key)), + onTap: () => onSelect(key), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/agents/create_agent_sheet.dart b/mobile/lib/features/agents/create_agent_sheet.dart new file mode 100644 index 0000000000..6113bb4af3 --- /dev/null +++ b/mobile/lib/features/agents/create_agent_sheet.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/agents/remote_agents.dart'; +import '../../shared/community/community.dart'; +import '../../shared/community/community_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_titled_sheet_layout.dart'; + +/// Uses the registry's name and private-instructions contract. Runtime/model +/// selection belongs to the remote service and is not an API option here. +class CreateAgentSheet extends HookConsumerWidget { + const CreateAgentSheet({ + required this.controller, + required this.community, + required this.onCreated, + super.key, + }); + final RemoteAgentController controller; + final Community community; + final VoidCallback onCreated; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(controller); + final name = useTextEditingController( + text: controller.pending?['name'] as String? ?? '', + ); + final instructions = useTextEditingController( + text: controller.pending?['instructions'] as String? ?? '', + ); + useListenable(name); + final error = useState(null); + final active = ref.watch(activeCommunityProvider).asData?.value; + final sameCommunity = active?.id == community.id; + final pending = controller.pending != null; + final instructionsMissing = + controller.pending?['instructionsMissing'] == true; + final canCreate = + !controller.busy && sameCommunity && name.text.trim().isNotEmpty; + final actionStyle = IconButton.styleFrom( + minimumSize: const Size.square(44), + backgroundColor: context.colors.surfaceContainerHighest, + side: BorderSide(color: context.colors.outlineVariant), + shape: const CircleBorder(), + ); + InputDecoration fieldDecoration(String label, {String? hint}) => + InputDecoration( + labelText: label, + floatingLabelBehavior: FloatingLabelBehavior.always, + hintText: hint, + filled: true, + fillColor: context.colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xs, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.container), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.container), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.container), + borderSide: BorderSide.none, + ), + ); + + Future create() async { + error.value = null; + if (!sameCommunity) { + error.value = 'The active community changed. Reopen Agents.'; + return; + } + final success = await controller.create( + community, + name.text, + instructions: instructions.text, + ); + if (!context.mounted) return; + if (success) { + onCreated(); + Navigator.pop(context, true); + } else { + error.value = controller.status; + } + } + + return PopScope( + canPop: !controller.busy, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: BuzzTitledSheetLayout( + title: 'Create agent', + leading: IconButton( + tooltip: 'Cancel', + style: actionStyle, + onPressed: controller.busy ? null : () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + trailing: IconButton( + tooltip: pending ? 'Resume' : 'Create', + style: actionStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + canCreate + ? context.colors.primary + : context.colors.surfaceContainerHighest, + ), + foregroundColor: WidgetStatePropertyAll( + canCreate + ? context.colors.onPrimary + : context.colors.onSurfaceVariant, + ), + ), + onPressed: canCreate ? create : null, + icon: const Icon(Icons.check), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(Grid.gutter), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: name, + autofocus: true, + enabled: !controller.busy && !pending, + textCapitalization: TextCapitalization.words, + decoration: fieldDecoration('Name'), + ), + const SizedBox(height: Grid.sm), + TextField( + controller: instructions, + enabled: + !controller.busy && (!pending || instructionsMissing), + minLines: 4, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.newline, + decoration: fieldDecoration( + 'Instructions', + hint: 'Describe what this agent should do (optional).', + ), + ), + if (controller.busy) ...[ + const SizedBox(height: Grid.xs), + const LinearProgressIndicator(), + Text(controller.status), + ], + if (error.value != null) ...[ + const SizedBox(height: Grid.xs), + Text( + error.value!, + style: TextStyle(color: context.colors.error), + ), + ], + if (controller.canDiscardPending && !controller.busy) + TextButton( + onPressed: () async { + error.value = null; + if (await controller.discardPending()) { + name.clear(); + instructions.clear(); + } else if (context.mounted) { + error.value = controller.status; + } + }, + child: const Text('Discard unfinished setup'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/agents/delete_agent_action.dart b/mobile/lib/features/agents/delete_agent_action.dart new file mode 100644 index 0000000000..5b2e77be10 --- /dev/null +++ b/mobile/lib/features/agents/delete_agent_action.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/agents/remote_agents.dart'; +import '../../shared/community/community_provider.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; + +/// Confirmed deletion for the signed-in owner's remote agents only. +class DeleteAgentAction extends HookConsumerWidget { + const DeleteAgentAction({ + required this.controller, + required this.pubkey, + super.key, + }); + + final RemoteAgentController controller; + final String pubkey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(controller); + final error = useState(null); + final community = ref.watch(activeCommunityProvider).asData?.value; + final agent = controller.agents + .where((row) => row['agent_pubkey'] == pubkey) + .firstOrNull; + if (community == null || + community.pubkey == null || + community.pubkey != controller.owner || + agent == null || + controller.account.account == null) { + return const SizedBox.shrink(); + } + Future remove() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text('Delete ${agent['agent_name'] ?? 'agent'}?'), + content: const Text( + 'Permanently delete this remote agent and its registration in this community? Message history and memberships remain. Registrations in other communities are not removed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom( + foregroundColor: context.colors.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + error.value = null; + final success = await controller.deleteAgent( + pubkey: pubkey, + community: community, + isCurrent: () => + context.mounted && + ref.read(activeCommunityProvider).asData?.value?.id == community.id, + fetch: ref.read(relaySessionProvider.notifier).fetchHistory, + ); + if (!context.mounted) return; + ref.invalidate(agentDirectoryProvider); + if (success) { + Navigator.of(context).pop(); + } else { + error.value = controller.status; + } + } + + return Padding( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: controller.busy ? null : remove, + style: FilledButton.styleFrom( + minimumSize: const Size(double.infinity, Grid.xxl), + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.xs, + ), + backgroundColor: context.colors.surfaceContainerHighest, + foregroundColor: context.colors.error, + textStyle: context.textTheme.titleSmall, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + ), + ), + child: Text(controller.busy ? 'Working…' : 'Delete'), + ), + if (error.value != null) + Text(error.value!, style: TextStyle(color: context.colors.error)), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 22cbcf82fc..685d09b185 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -153,7 +153,7 @@ class _ConversationAppBarTitle extends StatelessWidget { ), ); final presence = ref.watch( - presenceCacheProvider.select( + presentedPresenceProvider.select( (presenceMap) => otherPubkey == null ? null : presenceMap[otherPubkey], ), ); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index f69a9b244a..a6cd29659e 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -1,3 +1,5 @@ +import '../agents/agents_sheet.dart'; +import '../../shared/agents/remote_agents.dart'; import 'channel_unread.dart'; import '../../shared/widgets/collapsible_section_body.dart'; import '../../shared/widgets/compose_morph_icon.dart'; @@ -451,6 +453,9 @@ class ChannelsPage extends HookConsumerWidget { final communityName = activeCommunity?.name.trim() ?? ''; final topSectionGradient = context.appColors.topSectionGradient; final usesPinnedGradient = topSectionGradient != null; + final onAgents = supportsRemoteAgents(activeCommunity) + ? () => showAgents(context) + : null; return FrostedScaffold( backgroundColor: usesPinnedGradient @@ -483,6 +488,14 @@ class ChannelsPage extends HookConsumerWidget { onPressed: openCommunitySwitcher, ), nativeActions: [ + if (onAgents != null) + IosNavigationAction( + label: 'Agents', + symbol: 'person.2', + sharesBackground: true, + icon: BuzzIcons.bot, + onPressed: onAgents, + ), if (onSearch != null) IosNavigationAction( label: 'Search', @@ -539,6 +552,7 @@ class ChannelsPage extends HookConsumerWidget { actions: [ _ChannelHeaderActions( onSearch: onSearch, + onAgents: onAgents, onInbox: onInbox, hasUnreadInbox: hasUnreadInbox, onSettings: () { diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 795052ba98..a3bdf684f6 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -202,7 +202,7 @@ class _DmAvatar extends ConsumerWidget { ), ); final presence = ref.watch( - presenceCacheProvider.select( + presentedPresenceProvider.select( (presenceMap) => otherPubkey == null ? null : presenceMap[otherPubkey], ), ); diff --git a/mobile/lib/features/channels/channels_page/header_actions.dart b/mobile/lib/features/channels/channels_page/header_actions.dart index ce5bfd35ca..5d09f870a4 100644 --- a/mobile/lib/features/channels/channels_page/header_actions.dart +++ b/mobile/lib/features/channels/channels_page/header_actions.dart @@ -3,12 +3,14 @@ part of '../channels_page.dart'; class _ChannelHeaderActions extends StatelessWidget { const _ChannelHeaderActions({ required this.onSearch, + required this.onAgents, required this.onInbox, required this.hasUnreadInbox, required this.onSettings, }); final VoidCallback? onSearch; + final VoidCallback? onAgents; final VoidCallback? onInbox; final bool hasUnreadInbox; final VoidCallback onSettings; @@ -22,6 +24,12 @@ class _ChannelHeaderActions extends StatelessWidget { Row( mainAxisSize: MainAxisSize.min, children: [ + if (onAgents != null) + IconButton( + tooltip: 'Agents', + icon: const Icon(BuzzIcons.bot), + onPressed: onAgents, + ), if (onSearch != null) IconButton( tooltip: 'Search', diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 86bab7a801..a5589d08a4 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -218,7 +218,7 @@ final mentionCandidatesProvider = Provider.family candidates, ); loadIdentityNameOwners(ref, names); - final presence = ref.watch(presenceCacheProvider); + final presence = ref.watch(presentedPresenceProvider); return rankMentionCandidates( [ for (final candidate in candidates) diff --git a/mobile/lib/features/profile/presence_cache_provider.dart b/mobile/lib/features/profile/presence_cache_provider.dart index 8738e34f50..83f7a08fa6 100644 --- a/mobile/lib/features/profile/presence_cache_provider.dart +++ b/mobile/lib/features/profile/presence_cache_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/agents/remote_agents.dart'; import '../../shared/relay/relay.dart'; /// Community-scoped presence: absent means unknown. Polls recover expired leases. @@ -240,3 +241,13 @@ final presenceCacheProvider = NotifierProvider>( PresenceCacheNotifier.new, ); + +/// Presentation shared by profiles, DMs and mentions. Remote agents wake on +/// messages, so an ACTIVE registration shows Online whatever their socket +/// presence. Local presence is unchanged. +final presentedPresenceProvider = Provider>((ref) { + final presence = ref.watch(presenceCacheProvider); + final remote = ref.watch(availableRemoteAgentsProvider).value ?? const {}; + if (remote.isEmpty) return presence; + return {...presence, for (final pubkey in remote) pubkey: 'online'}; +}); diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 6577074890..4706452e54 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -66,11 +66,19 @@ class UserProfileSheet extends HookConsumerWidget { /// Optional actions supplied by the opening surface, below the profile tiles. final WidgetBuilder? contextualActions; + /// Space before the primary profile actions. + final double primaryActionsSpacing; + + /// Space between primary actions and caller-supplied actions. + final double contextualActionsSpacing; + const UserProfileSheet({ super.key, required this.pubkey, this.names, this.contextualActions, + this.primaryActionsSpacing = Grid.xs + Grid.half, + this.contextualActionsSpacing = Grid.xs, }); @override @@ -82,7 +90,7 @@ class UserProfileSheet extends HookConsumerWidget { final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); - final presenceMap = ref.watch(presenceCacheProvider); + final presenceMap = ref.watch(presentedPresenceProvider); final presence = presenceMap[pk]; final statusCache = ref.watch(userStatusCacheProvider); final userStatus = statusCache[pk]; @@ -264,7 +272,7 @@ class UserProfileSheet extends HookConsumerWidget { ), ], - const SizedBox(height: Grid.xs + Grid.half), + SizedBox(height: primaryActionsSpacing), Row( children: [ @@ -296,7 +304,7 @@ class UserProfileSheet extends HookConsumerWidget { ), ], ), - const SizedBox(height: Grid.xs), + SizedBox(height: contextualActionsSpacing), if (contextualActions != null) Builder(builder: contextualActions!), ], diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart new file mode 100644 index 0000000000..561d2aaf7b --- /dev/null +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -0,0 +1,701 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../crypto/nip_oa.dart'; +import '../hosted/account_identity_store.dart'; +import '../hosted/builderlab_account.dart'; +import '../hosted/hosted_environment.dart'; +import '../hosted/hosted_relay_auth.dart'; +import '../relay/nostr_filters.dart'; +import '../relay/nostr_models.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import '../relay/signed_event_relay.dart'; +import '../storage/secure_storage.dart'; + +/// Remote agents run on Builderlab, so they exist only in builds with +/// Builderlab enabled and only in hosted communities this build supports. +bool supportsRemoteAgents(Community? community) => + HostedEnvironment.enabled && + community != null && + community.pubkey != null && + HostedEnvironment.supportsRelay(Uri.parse(community.relayUrl)); + +final remoteAgentControllerProvider = Provider((ref) { + final controller = RemoteAgentController( + account: ref.watch(builderlabAccountProvider), + keys: AccountIdentityStore(), + relayAuth: ref.watch(hostedRelayAuthProvider), + ); + ref.onDispose(controller.dispose); + return controller; +}); + +/// The active community's remote agents that the signed-in owner's registry +/// reports ACTIVE. Remote workers wake on messages, so this means available, +/// not socket presence. +final availableRemoteAgentsProvider = FutureProvider>((ref) async { + final community = ref.watch(activeCommunityProvider).asData?.value; + if (!supportsRemoteAgents(community)) return const {}; + // A reconnect retries a registry load that failed. + ref.watch(relaySessionProvider.select((session) => session.status)); + final controller = ref.watch(remoteAgentControllerProvider); + final active = controller.activeAgents; + final revision = controller.registrationRevision; + void changed() { + if (!setEquals(controller.activeAgents, active) || + controller.registrationRevision != revision) { + ref.invalidateSelf(); + } + } + + controller.addListener(changed); + ref.onDispose(() => controller.removeListener(changed)); + // Loading notifies listeners, which must not happen during this build. + scheduleMicrotask(() => controller.load(community!.relayUrl)); + if (active.isEmpty) return const {}; + // The registry is account-wide; only agents live on this relay count. + return liveCommunityAgents( + active, + controller.owner, + ref.read(relaySessionProvider.notifier).fetchHistory, + ); +}); + +/// Registry identities with verified owner profiles on this relay. Account-wide +/// registry entries alone are not membership. A deletion interrupted after its +/// relay registration was removed still counts, so it can be retried. +Future> ownedCommunityAgents( + Set keys, + String? owner, + Future> Function(NostrFilter) fetch, +) async { + if (keys.isEmpty || owner == null) return {}; + final events = await fetch(NostrFilters.profilesBatch(keys.toList())); + return { + for (final event in events) + if (keys.contains(event.pubkey) && + verifiedProfileOaOwnerPubkey(event) == owner) + event.pubkey, + }; +} + +/// Owned agents whose kind 30177 registration is live on this relay, so they +/// can answer here. Only these are presented as available. +Future> liveCommunityAgents( + Set keys, + String? owner, + Future> Function(NostrFilter) fetch, +) async { + final owned = await ownedCommunityAgents(keys, owner, fetch); + if (owned.isEmpty) return {}; + final registrations = await fetch( + NostrFilter( + kinds: const [30177], + authors: [owner!], + tags: {'#d': owned.toList()}, + ), + ); + return { + for (final event in registrations) + if (event.kind == 30177 && event.pubkey == owner) + for (final tag in event.tags) + if (tag.length > 1 && tag[0] == 'd' && owned.contains(tag[1])) tag[1], + }; +} + +/// Owner-side registry client for Builderlab remote agents, using the same +/// account and Beekeeper contracts as desktop. +class RemoteAgentController extends ChangeNotifier { + RemoteAgentController({ + required this.account, + required AccountIdentityStore keys, + required this.relayAuth, + }) : _keys = keys { + account.subjectChanges.addListener(_accountChanged); + } + + final BuilderlabAccountClient account; + final AccountIdentityStore _keys; + final HostedRelayAuth relayAuth; + + bool busy = false; + int _operation = 0; + String status = ''; + String? owner; + List> agents = []; + Map? pending; + Set deletedAgents = {}; + String? _scope; + String? _loadedRelay; + bool _disposed = false; + + /// The current account's own identity slot. + AccountIdentityStore get keys { + final subject = account.account?.subject; + if (subject == null) throw AccountFailure.signedOut; + return _keys.forSubject(subject); + } + + /// Empty until the signed-in account's own registry has loaded. + Set get activeAgents => _scope == null || _scope != _currentScope + ? const {} + : { + for (final agent in agents) + if (agent['status'] == 'BEEKEEPER_AGENT_STATUS_ACTIVE' || + agent['status'] == 2) + agent['agent_pubkey'] as String, + }; + + String? get _currentScope { + final subject = account.account?.subject; + return subject == null + ? null + : 'remote-agents:${HostedEnvironment.origin.host}:$subject'; + } + + String get _storageScope => _currentScope ?? (throw AccountFailure.signedOut); + + /// Forgets the previous account's registry before showing another's. + void _clearAccountState() { + agents = []; + owner = null; + pending = null; + deletedAgents = {}; + _scope = null; + } + + /// Drops another account's registry as soon as the account changes. + void _accountChanged() { + if (_scope == null || _scope == _currentScope) return; + _clearAccountState(); + _loadedRelay = null; + _notify(); + } + + /// Loads the registry once per community; opening Agents always refreshes. + /// A failed load is retried on the next call. + Future load(String relay) async { + if (_disposed || busy || _loadedRelay == relay) return; + _loadedRelay = relay; + if (!await run(refresh) && _loadedRelay == relay) _loadedRelay = null; + } + + /// Runs one operation at a time and turns its failure into [status]. + Future run(Future Function() action) async { + if (busy || _disposed) return false; + busy = true; + final operation = ++_operation; + notifyListeners(); + try { + await action(); + return true; + } catch (error) { + // Account failures never carry credentials or response bodies. + status = switch (error) { + AccountFailure.signedOut => 'Sign in to Builderlab again.', + AccountFailure() => 'Builderlab: ${error.name}. Try again.', + AccountIdentityFailure() => 'Buzz identity: ${error.name}.', + StateError() => error.message, + _ => 'Operation failed (${error.runtimeType}). Try again.', + }; + return false; + } finally { + if (operation == _operation && !_disposed) { + busy = false; + notifyListeners(); + } + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + /// Bumped when this device publishes or removes a kind 30177 registration, + /// which changes availability even when the registry's ACTIVE set does not. + int registrationRevision = 0; + + void _registrationsChanged() { + registrationRevision++; + _notify(); + } + + Future refresh() async { + final session = account.account ?? await account.restore(); + if (session == null) { + _clearAccountState(); + status = 'Sign in to Builderlab to manage agents.'; + return; + } + await ensureBinding(); + final result = await account.post('/v3/beekeeper/list-agents', {}); + requireStatus(result, 'SUCCESS', 1); + final rows = result['agents'] ?? []; + if (rows is! List) throw StateError('Invalid agent listing.'); + final listed = [ + for (final row in rows) Map.from(row as Map), + ]; + final prefs = await SharedPreferences.getInstance(); + final retries = Map.from( + jsonDecode(prefs.getString('$_storageScope:delete-retries') ?? '{}') + as Map, + ); + // A deletion interrupted after its relay registration was removed keeps + // its row so Delete can be retried. + for (final retry in retries.values) { + final row = Map.from(retry as Map); + if (!listed.any( + (agent) => agent['agent_pubkey'] == row['agent_pubkey'], + )) { + listed.add(row); + } + } + agents = listed + .where((row) => !deletedAgents.contains(row['agent_pubkey'])) + .toList(); + status = switch (pending) { + null => '', + {'instructionsMissing': true} => + 'Unfinished setup saved. Enter its instructions again, then Resume.', + _ => 'Unfinished setup saved. Resume to use the same agent.', + }; + } + + /// Requires this account's saved key to be its current server binding. + Future ensureBinding() async { + final subject = account.account?.subject; + if (subject == null) throw AccountFailure.signedOut; + if (_scope != _storageScope) _clearAccountState(); + final local = await keys.read(); + if (local == null) throw AccountIdentityFailure.absent; + final response = await account.currentIdentity(); + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + if (response['error'] != null) throw AccountFailure.invalidResponse; + final bound = (response['identity'] as Map?)?['pubkey_hex']; + if (bound != local.pubkey) throw AccountIdentityFailure.mismatch; + owner = local.pubkey; + final scope = _storageScope; + if (_scope == scope) return; + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('$scope:pending'); + final restored = raw == null + ? null + : Map.from(jsonDecode(raw) as Map); + if (restored?['hasInstructions'] == true) { + final instructions = await accountSecureStorage.read( + key: '$scope:instructions:${restored!['request']}', + ); + if (instructions == null) { + // Preferences can outlive device-only secure storage, for example + // after a device restore. Keep the setup and ask for them again. + restored['instructionsMissing'] = true; + } else { + restored['instructions'] = instructions; + } + } + deletedAgents = prefs.getStringList('$scope:deleted')?.toSet() ?? {}; + pending = restored; + _scope = scope; + } + + Future _savePending(Map value) async { + final scope = _storageScope; + final prefs = await SharedPreferences.getInstance(); + final persisted = Map.from(value) + ..remove('instructions') + ..remove('hasInstructions'); + final instructions = value['instructions'] as String?; + if (instructions != null && instructions.isNotEmpty) { + await accountSecureStorage.write( + key: '$scope:instructions:${value['request']}', + value: instructions, + ); + persisted['hasInstructions'] = true; + } + if (!await prefs.setString('$scope:pending', jsonEncode(persisted))) { + throw StateError('Could not save unfinished setup.'); + } + pending = value; + } + + /// Registers, activates and subscribes an agent in [community]. A failed + /// attempt keeps its identity so the next call resumes the same agent. + Future create( + Community community, + String name, { + String instructions = '', + }) => run(() async { + if (!HostedEnvironment.supportsRelay(Uri.parse(community.relayUrl))) { + throw StateError('This community does not support remote agents.'); + } + await ensureBinding(); + if (community.pubkey != owner) { + throw StateError( + 'Sign in with the Builderlab account you use in this community.', + ); + } + final scope = _storageScope; + if (pending == null) { + final trimmed = name.trim(); + if (!RegExp(r'^[\w .-]{1,64}$').hasMatch(trimmed)) { + throw StateError( + 'Use 1 to 64 letters, numbers, spaces, dots, hyphens or underscores.', + ); + } + if (instructions.trim().length > 20000) { + throw StateError('Use at most 20,000 characters for instructions.'); + } + await _savePending({ + 'name': trimmed, + 'instructions': instructions.trim(), + 'community': community.relayUrl, + 'owner': owner, + 'request': const Uuid().v4(), + }); + } + var setup = Map.from(pending!); + // A registration whose response was lost may be replayed from any + // community, so the account learns its identity even if the original + // community is unavailable. Everything after it needs that community. + final elsewhere = setup['community'] != community.relayUrl; + final unknownIdentity = + setup['pubkey'] == null && setup['registering'] == true; + if (setup['owner'] != owner || (elsewhere && !unknownIdentity)) { + throw StateError('Finish the pending setup in its original community.'); + } + if (setup['instructionsMissing'] == true) { + if (instructions.trim().length > 20000) { + throw StateError('Use at most 20,000 characters for instructions.'); + } + setup = {...setup, 'instructions': instructions.trim()} + ..remove('instructionsMissing'); + await _savePending(setup); + } + if (setup['pubkey'] == null) { + status = 'Creating remote identity…'; + _notify(); + // Once sent, the server may allocate an identity even if the response is + // lost. Only Resume, with the same idempotency key, can recover it. + setup = {...setup, 'registering': true}; + await _savePending(setup); + // An explicit rejection allocated nothing, so it can be discarded. + Future refused() async { + setup = {...setup}..remove('registering'); + await _savePending(setup); + } + + final Map result; + try { + result = await account.post('/v3/beekeeper/register-agent', { + 'agent_name': setup['name'], + 'idempotency_key': setup['request'], + }); + } on AccountFailure catch (failure) { + if (failure == AccountFailure.rejected) await refused(); + rethrow; + } + if (!hasStatus(result, 'UNATTESTED', 1) && + result['status'] != null && + result['agent_pubkey'] == null) { + await refused(); + } + requireStatus(result, 'UNATTESTED', 1); + final pubkey = result['agent_pubkey']; + if (pubkey is! String || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(pubkey) || + result['agent_id'] is! String) { + throw StateError('Invalid registration response. Retry this setup.'); + } + setup = {...setup, 'pubkey': pubkey, 'id': result['agent_id']}; + await _savePending(setup); + } + if (elsewhere) { + throw StateError( + 'Agent identity recovered. Finish the setup in its original community.', + ); + } + final pubkey = setup['pubkey'] as String; + status = 'Authorizing and activating agent…'; + _notify(); + final auth = await keys.withKeys( + (key) async => signOwnerAuthorization(key, pubkey), + ); + final result = await account.post('/v3/beekeeper/attest-agent', { + 'agent_pubkey': pubkey, + 'owner_auth_tag_json': jsonEncode(auth), + 'community_url': community.relayUrl, + }); + requireStatus(result, 'ACTIVE', 1); + if ((setup['instructions'] as String?)?.isNotEmpty == true) { + await account.post('/v3/beekeeper/update-agent', { + 'agent_pubkey': pubkey, + 'agent_instructions': setup['instructions'], + }); + } + await subscribe(setup['id'] as String, community.relayUrl); + await publishPolicy(community.relayUrl, setup); + _registrationsChanged(); + // Retain the completed registration if the listing refresh fails, so Retry + // resumes the same identity instead of creating another agent. + await refresh(); + // Drop the record before its instructions, so an interrupted cleanup never + // leaves a pending setup whose instructions are gone. + final prefs = await SharedPreferences.getInstance(); + if (!await prefs.remove('$scope:pending')) { + throw StateError('Could not clear the finished setup. Retry.'); + } + pending = null; + await accountSecureStorage.delete( + key: '$scope:instructions:${setup['request']}', + ); + status = '${setup['name']} is ready. Mention it in a channel.'; + }); + + /// Whether [pending] provably has no server identity: registration was never + /// sent, or the server explicitly rejected it. + bool get canDiscardPending => + pending != null && + pending!['pubkey'] == null && + pending!['registering'] != true; + + /// Abandons an unfinished setup that never got a server identity, such as + /// one whose registration the server rejects. Setups that have or may have + /// an identity can only be resumed, so no registered agent is orphaned. + Future discardPending() => run(() async { + final setup = pending; + if (setup == null) return; + if (!canDiscardPending) { + throw StateError('This setup may already have an agent. Resume it.'); + } + final scope = _storageScope; + final prefs = await SharedPreferences.getInstance(); + if (!await prefs.remove('$scope:pending')) { + throw StateError('Could not discard unfinished setup.'); + } + pending = null; + await accountSecureStorage.delete( + key: '$scope:instructions:${setup['request']}', + ); + status = ''; + }); + + /// Activation alone only reconciles server-configured communities, so a + /// community the owner joined later needs an explicit subscription. + Future subscribe(String agentId, String relay) async { + final result = await account.post('/v3/beekeeper/add-buzz-subscription', { + 'agent_id': agentId, + 'community': Uri.parse(relay).host, + }); + final value = result['status']; + if (value != 1 && + value != 2 && + value != 'ADD_BUZZ_SUBSCRIPTION_STATUS_CREATED' && + value != 'ADD_BUZZ_SUBSCRIPTION_STATUS_ALREADY_EXISTS') { + throw StateError('Community subscription was not confirmed ($value).'); + } + } + + /// Publishes the owner's agent registration only after activation and + /// subscription succeed. + Future publishPolicy(String relay, Map setup) async { + final pubkey = setup['pubkey'] as String; + final prefs = await SharedPreferences.getInstance(); + if (prefs.getStringList('$_storageScope:deleting')?.contains(pubkey) == + true) { + throw StateError('Agent deletion is pending. Retry Delete.'); + } + await keys.withKeys((key) async { + // Saved communities may store an https relay URL; sockets need wss. + final config = RelayConfig(baseUrl: relay, nsec: key.nsec); + await submitSignedEventOnce( + wsUrl: config.wsUrl, + headers: await relayAuth.headers(config), + nsec: key.nsec, + kind: 30177, + content: jsonEncode({ + 'name': setup['name'], + 'parallelism': 1, + 'respond_to': 'owner-only', + }), + tags: [ + ['d', pubkey], + ['runtime', 'block.builderlab/builderlab'], + ], + ); + }); + } + + /// Removes only this owner's registration in [relay]. + Future removePolicy(String relay, String pubkey) => + keys.withKeys((key) async { + final config = RelayConfig(baseUrl: relay, nsec: key.nsec); + await submitSignedEventOnce( + wsUrl: config.wsUrl, + headers: await relayAuth.headers(config), + nsec: key.nsec, + kind: 5, + content: '', + tags: [ + ['a', '30177:${key.public}:$pubkey'], + ], + ); + }); + + /// Deletes an owned runtime only after its registration in [community] is + /// deleted and a fresh relay read confirms its absence. + Future deleteAgent({ + required String pubkey, + required Community community, + required bool Function() isCurrent, + required Future> Function(NostrFilter) fetch, + }) => run(() async { + final subject = account.account?.subject; + void check() { + if (!isCurrent() || account.account?.subject != subject) { + throw StateError('Community or account changed. Reopen Agents.'); + } + } + + if (!HostedEnvironment.supportsRelay(Uri.parse(community.relayUrl))) { + throw StateError('This community does not support remote agents.'); + } + check(); + await ensureBinding(); + check(); + final scope = _storageScope; + // Agents belong to the member who owns them (NIP-OA), not the community. + final member = community.pubkey; + if (member == null || owner != member) { + throw AccountIdentityFailure.mismatch; + } + final agent = agents + .where((row) => row['agent_pubkey'] == pubkey) + .firstOrNull; + final id = agent?['agent_id']; + if (agent == null || id is! String || id.isEmpty) { + throw StateError('Owned remote agent ID is unavailable. Refresh Agents.'); + } + // Block only the setup's own agent, or any agent while the setup's + // identity is unknown, since it could be the one being deleted. + final setupAgent = pending?['pubkey']; + if (setupAgent == pubkey || + (setupAgent == null && pending?['registering'] == true)) { + throw StateError( + 'Resume the unfinished setup before deleting this agent.', + ); + } + final prefs = await SharedPreferences.getInstance(); + check(); + final tombstones = prefs.getStringList('$scope:deleting')?.toSet() ?? {}; + tombstones.add(pubkey); + if (!await prefs.setStringList('$scope:deleting', tombstones.toList())) { + throw StateError('Could not save deletion intent. Retry Delete.'); + } + final retries = Map.from( + jsonDecode(prefs.getString('$scope:delete-retries') ?? '{}') as Map, + ); + retries[pubkey] = agent; + if (!await prefs.setString('$scope:delete-retries', jsonEncode(retries))) { + throw StateError('Could not save deletion retry. Retry Delete.'); + } + check(); + await removePolicy(community.relayUrl, pubkey); + _registrationsChanged(); + check(); + final remaining = await fetch( + NostrFilter( + kinds: const [30177], + authors: [member], + tags: { + '#d': [pubkey], + }, + ), + ); + check(); + if (remaining.isNotEmpty) { + throw StateError('Community registration still exists. Retry Delete.'); + } + final result = await account.post('/v3/beekeeper/delete-agent', { + 'agent_id': id, + }); + check(); + final status = result['status']; + if (status != 1 && + status != 3 && + status != 'DELETE_AGENT_STATUS_DELETED' && + status != 'DELETE_AGENT_STATUS_NOT_FOUND') { + throw StateError( + status == 2 || status == 'DELETE_AGENT_STATUS_DISABLED' + ? 'Agent deletion is disabled on this server.' + : 'Agent deletion was not confirmed. Retry Delete.', + ); + } + final completed = {...deletedAgents, pubkey}; + if (!await prefs.setStringList('$scope:deleted', completed.toList())) { + throw StateError( + 'Runtime deleted, but local cleanup failed. Retry Delete.', + ); + } + deletedAgents = completed; + retries.remove(pubkey); + if (!await prefs.setString('$scope:delete-retries', jsonEncode(retries))) { + throw StateError( + 'Runtime deleted, but local cleanup failed. Retry Delete.', + ); + } + agents = agents.where((row) => row['agent_pubkey'] != pubkey).toList(); + this.status = 'Remote agent deleted.'; + }); + + @override + void dispose() { + _disposed = true; + account.subjectChanges.removeListener(_accountChanged); + super.dispose(); + } +} + +/// Signs the NIP-OA protocol preimage, not a Nostr event ID. +List signOwnerAuthorization(nostr.Keys owner, String agent) { + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(agent) || agent == owner.public) { + throw StateError('Invalid agent key.'); + } + final digest = sha256 + .convert(utf8.encode('nostr:agent-auth:$agent:')) + .toString(); + return [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: digest), + ]; +} + +/// Accepts the registry's numeric or `*_` enum form, nothing else. +void requireStatus(Map result, String suffix, int numeric) { + if (hasStatus(result, suffix, numeric)) return; + final status = result['status']; + throw StateError( + 'Builderlab did not confirm $suffix ' + '(${status is String || status is num ? status : 'missing status'}).', + ); +} + +/// Whether [result] reports [suffix] in the registry's numeric or `*_` +/// enum form. +bool hasStatus(Map result, String suffix, int numeric) { + final status = result['status']; + return status == numeric || (status is String && status.endsWith('_$suffix')); +} diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index b97604fec5..d894707ab4 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -65,6 +65,16 @@ class BuilderlabAccountClient { static final origin = HostedEnvironment.origin; static const _prefix = '/api/goose'; + /// Remote-agent registry routes, shared with desktop. + static const _beekeeperRoutes = { + '/v3/beekeeper/register-agent', + '/v3/beekeeper/update-agent', + '/v3/beekeeper/delete-agent', + '/v3/beekeeper/attest-agent', + '/v3/beekeeper/list-agents', + '/v3/beekeeper/add-buzz-subscription', + }; + /// Sessions are scoped per origin so staging never reads production state. static final _storageKey = 'buzz_hosted_session_${origin.host.replaceAll('.', '_')}_v1'; @@ -77,6 +87,10 @@ class BuilderlabAccountClient { BuilderlabAccount? get account => _account; + /// The signed-in subject, notifying when an account signs in, out or changes. + ValueListenable get subjectChanges => _subject; + final _subject = ValueNotifier(null); + /// Runs operations one at a time in call order, so a callback exchange that /// arrives during startup restore waits instead of failing. Future _serial(Future Function() operation) { @@ -184,6 +198,7 @@ class BuilderlabAccountClient { BuilderlabAccount _adopt(String credential, BuilderlabAccount verified) { if (_disposed) throw AccountFailure.unavailable; _credential = credential; + _subject.value = verified.subject; return _account = verified; } @@ -226,8 +241,13 @@ class BuilderlabAccountClient { String? signingKey, String? expectedSubject, }) { - if (!RegExp(r'^/v1/buzz(/[a-z0-9_-]+)+$').hasMatch(path)) { - throw ArgumentError.value(path, 'path', 'not a /v1/buzz/ route'); + if (!RegExp(r'^/v1/buzz(/[a-z0-9_-]+)+$').hasMatch(path) && + !_beekeeperRoutes.contains(path)) { + throw ArgumentError.value( + path, + 'path', + 'not an allowed Builderlab route', + ); } return _serial(() async { final credential = _credential; @@ -281,6 +301,7 @@ class BuilderlabAccountClient { Future _clear() async { _credential = null; _account = null; + if (!_disposed) _subject.value = null; try { await _storage.delete(key: _storageKey); } catch (_) { @@ -348,6 +369,7 @@ class BuilderlabAccountClient { _disposed = true; _credential = null; _account = null; + _subject.dispose(); _http.close(); } } diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index b0106639eb..571af77277 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -73,6 +73,7 @@ Future submitSignedEventOnce({ required List> tags, int? createdAt, Duration timeout = const Duration(seconds: 12), + Map headers = const {}, }) async { final privateKey = nostr.Nip19.decode(payload: nsec).data; if (privateKey.isEmpty) throw const FormatException('Invalid nsec'); @@ -122,6 +123,7 @@ Future submitSignedEventOnce({ } }, ); + socket.headers = headers; final resultFuture = result.future.timeout(timeout); try { await socket.connect(); diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart new file mode 100644 index 0000000000..f5ab604489 --- /dev/null +++ b/mobile/test/features/agents/agents_harness.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:buzz/features/agents/agents_sheet.dart'; +import 'package:buzz/shared/agents/remote_agents.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helpers/remote_agents_fixture.dart'; + +/// Fakes the relay boundary: owner-attested profiles for registry agents. +class _Relay extends RelaySessionNotifier { + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [ + if (filter.authors?.contains(fakeAgent) ?? false) + signedAgentProfile(fakeAgentKeys, fakeOwner, content: '{"name":"Scout"}'), + ]; +} + +/// Holds creation open so a test can observe the in-flight state. +class GatedController extends RelayFakeController { + GatedController(super.account); + Completer? gate; + String? submittedName; + String? submittedInstructions; + + @override + Future create( + Community community, + String name, { + String instructions = '', + }) { + submittedName = name; + submittedInstructions = instructions; + final gate = this.gate; + if (gate == null) { + return super.create(community, name, instructions: instructions); + } + return run(() async { + await gate.future; + throw StateError('Creation failed. Retry.'); + }); + } +} + +/// One signed-in owner whose saved key matches the account binding. +/// +/// [AccountIdentityStore] serializes through a static queue whose futures +/// belong to the fake-async zone that created them, so each file holds only +/// one widget test that touches the store. +class AgentsHarness { + AgentsHarness._(this.registry, this.controller, this.community); + final FakeRegistry registry; + final GatedController controller; + final Community community; + + static Future create() async { + FlutterSecureStorage.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({}); + final registry = FakeRegistry(bound: fakeOwner.public); + await AccountIdentityStore() + .forSubject(registry.account!.subject) + .create(fakeOwner.nsec); + final controller = GatedController(registry); + addTearDown(() { + controller.dispose(); + controller.relayAuth.dispose(); + registry.dispose(); + }); + return AgentsHarness._( + registry, + controller, + Community.create( + name: 'Test', + relayUrl: 'wss://test.${HostedEnvironment.communitySuffix}', + pubkey: fakeOwner.public, + nsec: fakeOwner.nsec, + ), + ); + } + + /// Opens the Agents sheet over a host page. + Future open( + WidgetTester tester, { + required List directory, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + builderlabAccountProvider.overrideWithValue(registry), + remoteAgentControllerProvider.overrideWithValue(controller), + relaySessionProvider.overrideWith(_Relay.new), + activeCommunityProvider.overrideWith((ref) async => community), + agentDirectoryProvider.overrideWith( + (ref) async => [ + for (final pubkey in directory) + AgentDirectoryEntry(pubkey: pubkey), + ], + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showAgents(context), + child: const Text('Open Agents'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Agents')); + await tester.pumpAndSettle(); + } +} diff --git a/mobile/test/features/agents/agents_sheet_test.dart b/mobile/test/features/agents/agents_sheet_test.dart new file mode 100644 index 0000000000..5709e83e64 --- /dev/null +++ b/mobile/test/features/agents/agents_sheet_test.dart @@ -0,0 +1,38 @@ +import 'package:buzz/features/agents/agents_sheet.dart'; +import 'package:buzz/features/profile/user_profile_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'agents_harness.dart'; + +void main() { + final existing = 'a' * 64; + + testWidgets('agent detail pushes inside one modal and Back restores list', ( + tester, + ) async { + final h = await AgentsHarness.create(); + await h.open(tester, directory: [existing]); + final list = tester.element(find.byType(AgentsSheet)); + await tester.tap(find.byKey(ValueKey(existing))); + await tester.pump(); + for (var frame = 0; frame < 3; frame++) { + await tester.pump(const Duration(milliseconds: 60)); + final surface = find.byKey(const ValueKey('agent-detail-surface')); + expect(tester.widget(surface).color?.a, 1); + expect(tester.takeException(), isNull); + } + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(UserProfileSheet), findsOneWidget); + expect(list.mounted, isTrue); + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(find.byType(UserProfileSheet), findsNothing); + expect(find.byKey(ValueKey(existing)), findsOneWidget); + expect(tester.element(find.byType(AgentsSheet)), same(list)); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + }); +} diff --git a/mobile/test/features/agents/create_agent_sheet_test.dart b/mobile/test/features/agents/create_agent_sheet_test.dart new file mode 100644 index 0000000000..3950d08170 --- /dev/null +++ b/mobile/test/features/agents/create_agent_sheet_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:buzz/features/agents/agents_sheet.dart'; +import 'package:buzz/features/agents/create_agent_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import 'agents_harness.dart'; + +void main() { + final existing = 'a' * 64; + + testWidgets( + 'creation overlays the retained list, then failure and resume both land', + (tester) async { + final h = await AgentsHarness.create(); + final controller = h.controller; + final added = nostr.Keys('0' * 63 + '2').public; + await h.open(tester, directory: [existing]); + final list = tester.element(find.byType(AgentsSheet)); + await tester.tap(find.byTooltip('Add agent')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNWidgets(2)); + final createContext = tester.element(find.byType(CreateAgentSheet)); + expect(Navigator.of(list), isNot(Navigator.of(createContext))); + + controller.gate = Completer(); + await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Scout'); + await tester.enterText( + find.widgetWithText(TextField, 'Instructions'), + 'Reply concisely.', + ); + await tester.pump(); + await tester.tap(find.byTooltip('Create')); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.busy, isTrue); + expect( + find.descendant( + of: find.byType(CreateAgentSheet), + matching: find.byType(LinearProgressIndicator), + ), + findsOneWidget, + ); + expect( + tester + .widget(find.widgetWithIcon(IconButton, Icons.check)) + .onPressed, + isNull, + ); + controller.gate!.complete(); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNWidgets(2)); + expect(find.text('Creation failed. Retry.'), findsOneWidget); + expect(controller.submittedName, 'Scout'); + expect(controller.submittedInstructions, 'Reply concisely.'); + + // The real registry flow then succeeds and returns to the same list. + controller.gate = null; + await tester.tap(find.byTooltip('Create')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(tester.element(find.byType(AgentsSheet)), same(list)); + expect(find.byKey(ValueKey(added)), findsOneWidget); + expect(h.registry.calls, contains('publish-policy')); + }, + ); +} diff --git a/mobile/test/features/agents/delete_agent_action_test.dart b/mobile/test/features/agents/delete_agent_action_test.dart new file mode 100644 index 0000000000..8c0d717929 --- /dev/null +++ b/mobile/test/features/agents/delete_agent_action_test.dart @@ -0,0 +1,51 @@ +import 'package:buzz/features/agents/delete_agent_action.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import 'agents_harness.dart'; + +void main() { + testWidgets('Delete appears only for owned remote agents and needs confirm', ( + tester, + ) async { + final h = await AgentsHarness.create(); + final controller = h.controller; + final agent = nostr.Keys('0' * 63 + '2').public; + h.registry.listed = [ + {'agent_pubkey': agent, 'agent_id': 'remote-id', 'agent_name': 'Scout'}, + ]; + await controller.run(controller.refresh); + h.registry.calls.clear(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + activeCommunityProvider.overrideWith((ref) async => h.community), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Column( + children: [ + DeleteAgentAction(controller: controller, pubkey: agent), + DeleteAgentAction(controller: controller, pubkey: 'local'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Delete'), findsOneWidget); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(find.text('Delete Scout?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(h.registry.calls, isEmpty); + expect(controller.agents, hasLength(1)); + }); +} diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart new file mode 100644 index 0000000000..6ad37c4fbf --- /dev/null +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +import 'package:buzz/shared/agents/remote_agents.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +final fakeOwner = nostr.Keys('0' * 63 + '1'); +final fakeAgentKeys = nostr.Keys('0' * 63 + '2'); +final fakeAgent = fakeAgentKeys.public; +const fakeSubject = 'remote-owner'; + +/// Fakes only the Builderlab network boundary. +class FakeRegistry extends BuilderlabAccountClient { + FakeRegistry({this.bound, bool signedIn = true}) : _signedIn = signedIn { + _changes.value = signedIn ? fakeSubject : null; + } + String? bound; + bool _signedIn; + String _subjectValue = fakeSubject; + final _changes = ValueNotifier(null); + + bool get signedIn => _signedIn; + set signedIn(bool value) { + _signedIn = value; + _changes.value = value ? _subjectValue : null; + } + + String get subject => _subjectValue; + set subject(String value) { + _subjectValue = value; + if (_signedIn) _changes.value = value; + } + + @override + ValueListenable get subjectChanges => _changes; + bool failInstructions = false; + bool rejectRegistration = false; + bool loseRegistration = false; + bool refuseRegistration = false; + Object deleteStatus = 1; + final calls = []; + final bodies = >{}; + List> listed = []; + + @override + BuilderlabAccount? get account => signedIn + ? BuilderlabAccount(subject: subject, expiresAt: DateTime.utc(2030)) + : null; + @override + Future restore() async => account; + @override + Future> currentIdentity() async => { + 'identity': {'pubkey_hex': bound}, + }; + @override + Future> post( + String path, + Map body, + ) async { + calls.add(path.split('/').last); + bodies[path] = body; + switch (path) { + case '/v3/beekeeper/list-agents': + return {'status': 1, 'agents': listed}; + case '/v3/beekeeper/register-agent': + if (refuseRegistration) throw AccountFailure.rejected; + if (rejectRegistration) { + return {'status': 'REGISTER_AGENT_STATUS_NAME_TAKEN'}; + } + listed.add({ + 'agent_pubkey': fakeAgent, + 'agent_id': 'agent', + 'agent_name': body['agent_name'], + 'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE', + }); + // The server committed the identity, but the response never arrived. + if (loseRegistration) throw StateError('Connection lost.'); + return {'status': 1, 'agent_pubkey': fakeAgent, 'agent_id': 'agent'}; + case '/v3/beekeeper/attest-agent': + return {'status': 'ATTEST_AGENT_STATUS_ACTIVE'}; + case '/v3/beekeeper/update-agent': + if (failInstructions) throw StateError('instructions unavailable'); + return {}; + case '/v3/beekeeper/add-buzz-subscription': + return {'status': 1}; + case '/v3/beekeeper/delete-agent': + expect(body, {'agent_id': 'remote-id'}); + return {'status': deleteStatus}; + default: + throw StateError('Unexpected request: $path'); + } + } +} + +/// Fakes only relay publication; registry logic stays real. +class RelayFakeController extends RemoteAgentController { + RelayFakeController(FakeRegistry account) + : super( + account: account, + keys: AccountIdentityStore(), + relayAuth: HostedRelayAuth(account), + ); + bool rejectRemoval = false; + FakeRegistry get registry => account as FakeRegistry; + + @override + Future publishPolicy(String relay, Map setup) async { + registry.calls.add('publish-policy'); + } + + @override + Future removePolicy(String relay, String pubkey) async { + registry.calls.add('remove-registration'); + if (rejectRemoval) throw StateError('Relay rejected deletion'); + } +} + +/// A signed agent profile carrying [owner]'s attestation under [conditions]. +NostrEvent signedAgentProfile( + nostr.Keys agent, + nostr.Keys owner, { + String conditions = '', + String content = '{}', +}) { + final pubkey = agent.public; + final digest = sha256 + .convert(utf8.encode('nostr:agent-auth:$pubkey:$conditions')) + .toString(); + final auth = [ + 'auth', + owner.public, + conditions, + nostr.Schnorr.sign(secretKey: owner.secret, message: digest), + ]; + return NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + content: content, + secretKey: agent.secret, + createdAt: 100, + tags: [auth], + ).toMap(), + ); +} diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart new file mode 100644 index 0000000000..b8b12718c9 --- /dev/null +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -0,0 +1,641 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/features/profile/presence_cache_provider.dart'; +import 'package:buzz/shared/agents/remote_agents.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/crypto/nip_oa.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/storage/secure_storage.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helpers/remote_agents_fixture.dart'; + +final _scope = 'remote-agents:${HostedEnvironment.origin.host}:$fakeSubject'; + +/// Deterministic test keys. +nostr.Keys _keys(String last) => nostr.Keys('0' * 63 + last); + +Community _community({String? pubkey}) => Community.create( + name: 'Test', + relayUrl: 'wss://test.${HostedEnvironment.communitySuffix}', + pubkey: pubkey ?? fakeOwner.public, + nsec: fakeOwner.nsec, +); + +Future _controller(FakeRegistry registry) async { + await AccountIdentityStore().forSubject(fakeSubject).create(fakeOwner.nsec); + final controller = RelayFakeController(registry); + addTearDown(() { + controller.dispose(); + controller.relayAuth.dispose(); + registry.dispose(); + }); + return controller; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({}); + }); + + test('remote agents follow the build-time Builderlab gate', () { + expect(supportsRemoteAgents(_community()), HostedEnvironment.enabled); + expect(supportsRemoteAgents(null), isFalse); + final selfHosted = Community.create( + name: 'Self hosted', + relayUrl: 'wss://relay.example.com', + pubkey: fakeOwner.public, + ); + expect(supportsRemoteAgents(selfHosted), isFalse); + }); + + test('listing requires a signed-in account bound to the saved key', () async { + final registry = FakeRegistry(bound: fakeOwner.public, signedIn: false); + final controller = await _controller(registry); + registry.listed = [ + { + 'agent_pubkey': fakeAgent, + 'agent_name': 'Scout', + 'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE', + }, + ]; + + expect(await controller.run(controller.refresh), isTrue); + expect(registry.calls, isEmpty); + expect(controller.activeAgents, isEmpty); + expect(controller.status, contains('Sign in')); + + registry + ..signedIn = true + ..bound = nostr.Keys('0' * 63 + '3').public; + expect(await controller.run(controller.refresh), isFalse); + expect(registry.calls, isEmpty); + expect(controller.status, 'Buzz identity: mismatch.'); + + registry.bound = fakeOwner.public; + expect(await controller.run(controller.refresh), isTrue); + expect(registry.calls, ['list-agents']); + expect(controller.owner, fakeOwner.public); + expect(controller.activeAgents, {fakeAgent}); + + registry.signedIn = false; + expect(controller.activeAgents, isEmpty); + }); + + test( + 'failed setup resumes the same identity; instructions stay private', + () async { + final registry = FakeRegistry(bound: fakeOwner.public) + ..failInstructions = true; + final controller = await _controller(registry); + final community = _community(); + + expect( + await controller.create(community, 'Scout', instructions: 'x' * 20001), + isFalse, + ); + expect( + await controller.create(_community(pubkey: 'f' * 64), 'Scout'), + isFalse, + ); + expect(registry.calls, isEmpty); + expect(controller.pending, isNull); + + expect( + await controller.create( + community, + 'Scout', + instructions: 'Reply concisely.', + ), + isFalse, + ); + expect(registry.calls, [ + 'register-agent', + 'attest-agent', + 'update-agent', + ]); + expect(controller.pending?['pubkey'], fakeAgent); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('$_scope:pending'), isNotNull); + expect( + prefs.getString('$_scope:pending'), + isNot(contains('Reply concisely.')), + ); + + // A fresh controller restores the pending setup from storage. + final resumed = RelayFakeController(registry); + addTearDown(() { + resumed.dispose(); + resumed.relayAuth.dispose(); + }); + registry + ..failInstructions = false + ..calls.clear(); + expect( + await resumed.create(community, 'Other', instructions: 'Other'), + isTrue, + ); + expect(registry.calls, [ + 'attest-agent', + 'update-agent', + 'add-buzz-subscription', + 'publish-policy', + 'list-agents', + ]); + expect( + registry.bodies['/v3/beekeeper/update-agent']?['agent_instructions'], + 'Reply concisely.', + ); + expect( + registry.bodies['/v3/beekeeper/attest-agent']?['community_url'], + community.relayUrl, + ); + expect( + registry.bodies['/v3/beekeeper/add-buzz-subscription']?['community'], + Uri.parse(community.relayUrl).host, + ); + expect(resumed.pending, isNull); + expect(prefs.getString('$_scope:pending'), isNull); + }, + ); + + test( + 'deletion requires confirmed registration absence; disabled stays retryable', + () async { + final registry = FakeRegistry(bound: fakeOwner.public); + final controller = await _controller(registry); + registry.listed = [ + {'agent_pubkey': fakeAgent, 'agent_id': 'remote-id'}, + ]; + await controller.run(controller.refresh); + registry.calls.clear(); + var registrationExists = true; + var current = true; + Future remove() => controller.deleteAgent( + pubkey: fakeAgent, + community: _community(), + isCurrent: () => current, + fetch: (filter) async { + registry.calls.add('read-registration'); + expect(filter.kinds, [30177]); + expect(filter.authors, [fakeOwner.public]); + expect(filter.tags, { + '#d': [fakeAgent], + }); + return registrationExists + ? [ + NostrEvent( + id: 'registration', + pubkey: fakeOwner.public, + createdAt: 1, + kind: 30177, + tags: [ + ['d', fakeAgent], + ], + content: '{}', + sig: '', + ), + ] + : []; + }, + ); + + controller.rejectRemoval = true; + expect(await remove(), isFalse); + expect(registry.calls, ['remove-registration']); + registry.calls.clear(); + controller.rejectRemoval = false; + expect(await remove(), isFalse); + expect(registry.calls, ['remove-registration', 'read-registration']); + registry + ..calls.clear() + ..deleteStatus = 2; + registrationExists = false; + expect(await remove(), isFalse); + expect(controller.status, contains('disabled')); + expect(controller.agents, hasLength(1)); + + // The relay registration is gone, but the listing keeps a retry row. + registry.listed = []; + await controller.run(controller.refresh); + expect(controller.agents.single['agent_id'], 'remote-id'); + + registry.calls.clear(); + current = false; + expect(await remove(), isFalse); + expect(registry.calls, isEmpty); + current = true; + registry.deleteStatus = 'DELETE_AGENT_STATUS_NOT_FOUND'; + expect(await remove(), isTrue); + expect(registry.calls.last, 'delete-agent'); + expect(controller.agents, isEmpty); + expect(controller.deletedAgents, {fakeAgent}); + await controller.run(controller.refresh); + expect(controller.agents, isEmpty); + }, + ); + + test('a setup whose instructions were lost asks for them again', () async { + final registry = FakeRegistry(bound: fakeOwner.public) + ..failInstructions = true; + final controller = await _controller(registry); + final community = _community(); + expect( + await controller.create(community, 'Scout', instructions: 'Private.'), + isFalse, + ); + final request = controller.pending!['request']; + // Device-only secure storage is gone; preferences survived (a restore). + await accountSecureStorage.delete(key: '$_scope:instructions:$request'); + + final restarted = RelayFakeController(registry); + addTearDown(() { + restarted.dispose(); + restarted.relayAuth.dispose(); + }); + expect(await restarted.run(restarted.refresh), isTrue); + expect(restarted.pending?['instructionsMissing'], isTrue); + expect(restarted.status, contains('Enter its instructions again')); + + registry + ..failInstructions = false + ..calls.clear(); + expect( + await restarted.create(community, 'Scout', instructions: 'Again.'), + isTrue, + ); + expect( + registry.bodies['/v3/beekeeper/update-agent']?['agent_instructions'], + 'Again.', + ); + expect(restarted.pending, isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('$_scope:pending'), isNull); + }); + + test( + 'registrations use a WebSocket URL for https-saved communities', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final kinds = []; + server.listen((request) async { + final peer = await WebSocketTransformer.upgrade(request); + addTearDown(peer.close); + peer.listen((frame) { + final data = jsonDecode(frame as String) as List; + final event = Map.from(data[1] as Map); + if (data.first == 'EVENT') kinds.add(event['kind'] as int); + peer.add(jsonEncode(['OK', event['id'], true, ''])); + }); + peer.add(jsonEncode(['AUTH', 'challenge'])); + }); + final registry = FakeRegistry(bound: fakeOwner.public); + await AccountIdentityStore() + .forSubject(fakeSubject) + .create(fakeOwner.nsec); + final controller = RemoteAgentController( + account: registry, + keys: AccountIdentityStore(), + relayAuth: HostedRelayAuth(registry), + ); + addTearDown(() { + controller.dispose(); + controller.relayAuth.dispose(); + }); + // Stored the way paired communities may keep it: an HTTP(S) origin. + final relay = 'http://127.0.0.1:${server.port}'; + await controller.publishPolicy(relay, { + 'pubkey': fakeAgent, + 'name': 'Scout', + }); + await controller.removePolicy(relay, fakeAgent); + expect(kinds, [30177, 5]); + }, + ); + + test('a setup without a server identity can be discarded', () async { + final registry = FakeRegistry(bound: fakeOwner.public) + ..rejectRegistration = true; + final controller = await _controller(registry); + final community = _community(); + final prefs = await SharedPreferences.getInstance(); + + expect( + await controller.create(community, 'Scout', instructions: 'Private.'), + isFalse, + ); + expect(controller.status, contains('NAME_TAKEN')); + expect(controller.canDiscardPending, isTrue); + // An HTTP rejection is just as definite. + registry + ..rejectRegistration = false + ..refuseRegistration = true; + expect(await controller.create(community, 'Scout'), isFalse); + expect(controller.status, contains('rejected')); + expect(controller.canDiscardPending, isTrue); + final request = controller.pending!['request']; + expect(prefs.getString('$_scope:pending'), isNotNull); + + expect(await controller.discardPending(), isTrue); + expect(controller.pending, isNull); + expect(prefs.getString('$_scope:pending'), isNull); + expect( + await accountSecureStorage.read(key: '$_scope:instructions:$request'), + isNull, + ); + + // A lost response may hide a committed identity, so it can only resume. + registry + ..refuseRegistration = false + ..loseRegistration = true; + expect( + await controller.create(community, 'Scout', instructions: 'Private.'), + isFalse, + ); + expect(controller.pending?['pubkey'], isNull); + expect(controller.canDiscardPending, isFalse); + expect(await controller.discardPending(), isFalse); + final lost = controller.pending!['request']; + expect(jsonDecode(prefs.getString('$_scope:pending')!)['request'], lost); + + // Until the identity is known, any listed agent could be the lost one. + final other = _keys('3').public; + registry.listed = [ + ...registry.listed, + {'agent_pubkey': other, 'agent_id': 'remote-id'}, + ]; + await controller.run(controller.refresh); + final elsewhere = Community.create( + name: 'Other', + relayUrl: 'wss://other.${HostedEnvironment.communitySuffix}', + pubkey: fakeOwner.public, + nsec: fakeOwner.nsec, + ); + Future remove(String pubkey) => controller.deleteAgent( + pubkey: pubkey, + community: elsewhere, + isCurrent: () => true, + fetch: (_) async => [], + ); + expect(await remove(other), isFalse); + expect(controller.status, contains('Resume the unfinished setup')); + + // Resume from another community reuses the idempotency key and recovers + // the same identity; finishing it still needs the original community. + registry + ..loseRegistration = false + ..bodies.clear(); + expect(await controller.create(elsewhere, 'Scout'), isFalse); + expect(controller.status, contains('Agent identity recovered')); + expect( + registry.bodies['/v3/beekeeper/register-agent']?['idempotency_key'], + lost, + ); + registry.bodies.clear(); + expect(await controller.create(elsewhere, 'Scout'), isFalse); + expect(controller.status, contains('original community')); + expect(registry.bodies, isEmpty); + expect(controller.pending?['pubkey'], fakeAgent); + expect(await controller.discardPending(), isFalse); + expect(controller.pending?['pubkey'], fakeAgent); + + // Once known, the unfinished setup blocks deleting only its own agent. + expect(await remove(fakeAgent), isFalse); + expect(controller.status, contains('Resume the unfinished setup')); + expect(await remove(other), isTrue); + expect(controller.deletedAgents, {other}); + expect(controller.pending?['pubkey'], fakeAgent); + expect(prefs.getString('$_scope:pending'), isNotNull); + }); + + test('another account never sees the previous registry', () async { + final registry = FakeRegistry(bound: fakeOwner.public); + final controller = await _controller(registry); + registry.listed = [ + { + 'agent_pubkey': fakeAgent, + 'agent_name': 'Scout', + 'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE', + }, + ]; + await controller.run(controller.refresh); + expect(controller.activeAgents, {fakeAgent}); + + var notified = 0; + controller.addListener(() => notified++); + registry.subject = 'other-owner'; + expect(notified, 1); + expect(controller.activeAgents, isEmpty); + // The other account has no saved key, so its registry is never listed. + expect(await controller.run(controller.refresh), isFalse); + expect(controller.agents, isEmpty); + expect(controller.owner, isNull); + }); + + test('a failed registry load retries on the next load', () async { + final registry = FakeRegistry(bound: 'f' * 64); + final controller = await _controller(registry); + final relay = _community().relayUrl; + await controller.load(relay); + expect(controller.status, 'Buzz identity: mismatch.'); + registry.bound = fakeOwner.public; + await controller.load(relay); + expect(registry.calls, ['list-agents']); + await controller.load(relay); + expect(registry.calls, ['list-agents']); + }); + + test( + 'availability requires an owned profile and a live registration', + () async { + String key(String last) => _keys(last).public; + final other = key('3'); + final elsewhere = key('5'); + final forged = signedAgentProfile(_keys('8'), fakeOwner).toJson() + ..['sig'] = '0' * 128; + final profiles = [ + signedAgentProfile(fakeAgentKeys, fakeOwner), + // Attested by someone else, so not this owner's agent. + signedAgentProfile(_keys('3'), _keys('4')), + // Signed conditions that exclude this profile, and a forged envelope. + signedAgentProfile(_keys('6'), fakeOwner, conditions: 'kind=1'), + signedAgentProfile(_keys('7'), fakeOwner, conditions: 'created_at<50'), + NostrEvent.fromJson(forged), + ]; + var registrations = []; + Future> fetch(NostrFilter filter) async { + if (filter.kinds.single == 0) return profiles; + expect(filter.kinds, [30177]); + expect(filter.authors, [fakeOwner.public]); + expect(filter.tags, { + '#d': [fakeAgent], + }); + return registrations; + } + + final keys = {fakeAgent, other, elsewhere, key('6'), key('7'), key('8')}; + // The list keeps owned agents, so an interrupted delete stays retryable. + expect(await ownedCommunityAgents(keys, fakeOwner.public, fetch), { + fakeAgent, + }); + expect(await liveCommunityAgents(keys, fakeOwner.public, fetch), isEmpty); + registrations = [_registration(fakeAgent)]; + expect(await liveCommunityAgents(keys, fakeOwner.public, fetch), { + fakeAgent, + }); + expect(await liveCommunityAgents(keys, null, fetch), isEmpty); + }, + ); + + test('presence stays local until the agent registration is live', () async { + final registry = FakeRegistry(bound: fakeOwner.public) + ..listed = [ + { + 'agent_pubkey': fakeAgent, + 'agent_id': 'remote-id', + 'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE', + }, + ]; + final controller = await _controller(registry); + final relay = _AgentRelay(); + final container = ProviderContainer( + overrides: [ + presenceCacheProvider.overrideWith(_AgentPresence.new), + activeCommunityProvider.overrideWith((ref) async => _community()), + remoteAgentControllerProvider.overrideWithValue(controller), + relaySessionProvider.overrideWith(() => relay), + ], + ); + addTearDown(container.dispose); + await container.read(activeCommunityProvider.future); + await controller.run(controller.refresh); + + expect(await container.read(availableRemoteAgentsProvider.future), isEmpty); + expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline'); + + relay.live = true; + container.invalidate(availableRemoteAgentsProvider); + expect(await container.read(availableRemoteAgentsProvider.future), { + fakeAgent, + }); + expect(container.read(presentedPresenceProvider)[fakeAgent], 'online'); + + // Removing the registration hides it even when the runtime stays ACTIVE. + registry.deleteStatus = 2; + relay.live = false; + expect( + await controller.deleteAgent( + pubkey: fakeAgent, + community: _community(), + isCurrent: () => true, + fetch: (_) async => [], + ), + isFalse, + ); + expect(controller.activeAgents, {fakeAgent}); + expect(await container.read(availableRemoteAgentsProvider.future), isEmpty); + expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline'); + + // Publishing a registration for the still-ACTIVE agent shows it again. + relay.live = true; + expect(await controller.create(_community(), 'Scout'), isTrue); + expect(controller.activeAgents, {fakeAgent}); + expect(await container.read(availableRemoteAgentsProvider.future), { + fakeAgent, + }); + expect(container.read(presentedPresenceProvider)[fakeAgent], 'online'); + }, skip: !HostedEnvironment.enabled); + + test('owner proof verifies for only the provisioned agent key', () { + final other = nostr.Keys('0' * 63 + '3'); + final auth = signOwnerAuthorization(fakeOwner, fakeAgent); + expect(verifiedOaOwnerPubkey([auth], fakeAgent), fakeOwner.public); + expect(verifiedOaOwnerPubkey([auth], other.public), isNull); + expect( + () => signOwnerAuthorization(fakeOwner, fakeOwner.public), + throwsStateError, + ); + }); + + test('disabled and missing statuses cannot count as activation', () { + requireStatus({'status': 'ATTEST_AGENT_STATUS_ACTIVE'}, 'ACTIVE', 1); + requireStatus({'status': 1}, 'ACTIVE', 1); + for (final result in [ + {'status': 'ATTEST_AGENT_STATUS_FEATURE_DISABLED'}, + {}, + ]) { + expect(() => requireStatus(result, 'ACTIVE', 1), throwsStateError); + } + }); + + test( + 'remote availability shows Online without changing local presence', + () async { + final container = ProviderContainer( + overrides: [ + presenceCacheProvider.overrideWith(_Presence.new), + availableRemoteAgentsProvider.overrideWith((ref) async => {'remote'}), + ], + ); + addTearDown(container.dispose); + await container.read(availableRemoteAgentsProvider.future); + expect(container.read(presentedPresenceProvider), { + 'remote': 'online', + 'local': 'away', + }); + expect(container.read(presenceCacheProvider)['remote'], 'offline'); + }, + ); +} + +class _Presence extends PresenceCacheNotifier { + @override + Map build() => {'remote': 'offline', 'local': 'away'}; +} + +class _AgentPresence extends PresenceCacheNotifier { + @override + Map build() => {fakeAgent: 'offline'}; +} + +NostrEvent _registration(String agent) => NostrEvent( + id: 'registration-$agent', + pubkey: fakeOwner.public, + createdAt: 1, + kind: 30177, + tags: [ + ['d', agent], + ], + content: '{}', + sig: '', +); + +/// Serves the owner's agent profile, and its registration once [live]. +class _AgentRelay extends RelaySessionNotifier { + bool live = false; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => switch (filter.kinds.single) { + 0 => [signedAgentProfile(fakeAgentKeys, fakeOwner)], + 30177 when live => [_registration(fakeAgent)], + _ => [], + }; +}