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)], + _ => [], + }; +}