mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
feat(mobile): manage Builderlab remote agents
Adds an Agents sheet to the channels header for hosted communities: list, create (register, attest, subscribe, publish the owner registration) and delete remote agents, and show ACTIVE remote agents as Online. Everything is gated by HostedEnvironment.enabled, so builds without BUZZ_AUTH_DOMAINS are unchanged. Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
2
.github/workflows/_ci-clients.yml
vendored
2
.github/workflows/_ci-clients.yml
vendored
@@ -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
|
||||
|
||||
292
mobile/lib/features/agents/agents_sheet.dart
Normal file
292
mobile/lib/features/agents/agents_sheet.dart
Normal file
@@ -0,0 +1,292 @@
|
||||
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/crypto/nip_oa.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<void> showAgents(BuildContext context) async {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
final controller = container.read(remoteAgentControllerProvider);
|
||||
unawaited(controller.run(controller.refresh));
|
||||
final channel = await showBuzzModalBottomSheet<Channel>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showCloseButton: false,
|
||||
builder: (_) => _AgentsNavigation(controller: controller),
|
||||
);
|
||||
if (channel != null && context.mounted) {
|
||||
await Navigator.of(context).push<void>(
|
||||
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<NavigatorState>(), [
|
||||
communityId,
|
||||
]);
|
||||
return SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * .72,
|
||||
child: NavigatorPopHandler<Channel>(
|
||||
onPopWithResult: (result) => navigator.currentState!.pop(result),
|
||||
child: Navigator(
|
||||
key: navigator,
|
||||
onGenerateRoute: (_) => MaterialPageRoute<void>(
|
||||
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<Channel>(
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only registry identities with verified owner profiles on this relay belong
|
||||
/// in its list. Account-wide registry entries alone are not community membership.
|
||||
Future<Set<String>> registeredCommunityAgents(
|
||||
List<Map<String, dynamic>> agents,
|
||||
String? owner,
|
||||
Future<List<NostrEvent>> Function(NostrFilter) fetch,
|
||||
) async {
|
||||
if (agents.isEmpty || owner == null) return {};
|
||||
final keys = agents.map((agent) => agent['agent_pubkey'] as String).toSet();
|
||||
final events = await fetch(NostrFilters.profilesBatch(keys.toList()));
|
||||
return {
|
||||
for (final event in events)
|
||||
if (keys.contains(event.pubkey) &&
|
||||
verifiedOaOwnerPubkey(event.tags, event.pubkey) == owner)
|
||||
event.pubkey,
|
||||
};
|
||||
}
|
||||
|
||||
/// 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<String> 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 registeredCommunityAgents(
|
||||
controller.agents,
|
||||
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 ?? <AgentDirectoryEntry>[])
|
||||
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<void> 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<bool>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
181
mobile/lib/features/agents/create_agent_sheet.dart
Normal file
181
mobile/lib/features/agents/create_agent_sheet.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
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<String?>(null);
|
||||
final active = ref.watch(activeCommunityProvider).asData?.value;
|
||||
final sameCommunity = active?.id == community.id;
|
||||
final pending = controller.pending != null;
|
||||
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<void> 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,
|
||||
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 (pending &&
|
||||
controller.pending?['pubkey'] == null &&
|
||||
!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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
mobile/lib/features/agents/delete_agent_action.dart
Normal file
107
mobile/lib/features/agents/delete_agent_action.dart
Normal file
@@ -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<String?>(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<void> remove() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class _ConversationAppBarTitle extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
final presence = ref.watch(
|
||||
presenceCacheProvider.select(
|
||||
presentedPresenceProvider.select(
|
||||
(presenceMap) => otherPubkey == null ? null : presenceMap[otherPubkey],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -202,7 +202,7 @@ class _DmAvatar extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
final presence = ref.watch(
|
||||
presenceCacheProvider.select(
|
||||
presentedPresenceProvider.select(
|
||||
(presenceMap) => otherPubkey == null ? null : presenceMap[otherPubkey],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, Map<String, String>>(
|
||||
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<Map<String, String>>((ref) {
|
||||
final presence = ref.watch(presenceCacheProvider);
|
||||
final remote = ref.watch(availableRemoteAgentsProvider);
|
||||
if (remote.isEmpty) return presence;
|
||||
return {...presence, for (final pubkey in remote) pubkey: 'online'};
|
||||
});
|
||||
|
||||
@@ -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!),
|
||||
],
|
||||
|
||||
550
mobile/lib/shared/agents/remote_agents.dart
Normal file
550
mobile/lib/shared/agents/remote_agents.dart
Normal file
@@ -0,0 +1,550 @@
|
||||
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 '../hosted/account_identity_store.dart';
|
||||
import '../hosted/builderlab_account.dart';
|
||||
import '../hosted/hosted_environment.dart';
|
||||
import '../hosted/hosted_relay_auth.dart';
|
||||
import '../relay/nostr_models.dart';
|
||||
import '../relay/relay_provider.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<RemoteAgentController>((ref) {
|
||||
final controller = RemoteAgentController(
|
||||
account: ref.watch(builderlabAccountProvider),
|
||||
keys: AccountIdentityStore(),
|
||||
relayAuth: ref.watch(hostedRelayAuthProvider),
|
||||
);
|
||||
ref.onDispose(controller.dispose);
|
||||
return controller;
|
||||
});
|
||||
|
||||
/// Exact public keys the signed-in owner's registry reports ACTIVE.
|
||||
/// Remote workers wake on messages, so this means available, not socket presence.
|
||||
final availableRemoteAgentsProvider = Provider<Set<String>>((ref) {
|
||||
final community = ref.watch(activeCommunityProvider).asData?.value;
|
||||
if (!supportsRemoteAgents(community)) return const {};
|
||||
final controller = ref.watch(remoteAgentControllerProvider);
|
||||
final active = controller.activeAgents;
|
||||
void changed() {
|
||||
if (!setEquals(controller.activeAgents, active)) 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));
|
||||
return active;
|
||||
});
|
||||
|
||||
/// 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;
|
||||
|
||||
final BuilderlabAccountClient account;
|
||||
final AccountIdentityStore _keys;
|
||||
final HostedRelayAuth relayAuth;
|
||||
|
||||
bool busy = false;
|
||||
int _operation = 0;
|
||||
String status = '';
|
||||
String? owner;
|
||||
List<Map<String, dynamic>> agents = [];
|
||||
Map<String, dynamic>? pending;
|
||||
Set<String> 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<String> 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;
|
||||
}
|
||||
|
||||
/// Loads the registry once per community; opening Agents always refreshes.
|
||||
Future<void> load(String relay) async {
|
||||
if (_disposed || busy || _loadedRelay == relay) return;
|
||||
_loadedRelay = relay;
|
||||
await run(refresh);
|
||||
}
|
||||
|
||||
/// Runs one operation at a time and turns its failure into [status].
|
||||
Future<bool> run(Future<void> 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();
|
||||
}
|
||||
|
||||
Future<void> 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<String, dynamic>.from(row as Map),
|
||||
];
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final retries = Map<String, dynamic>.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<String, dynamic>.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 = pending == null
|
||||
? ''
|
||||
: 'Unfinished setup saved. Resume to use the same agent.';
|
||||
}
|
||||
|
||||
/// Requires this account's saved key to be its current server binding.
|
||||
Future<void> 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<String, dynamic>.from(jsonDecode(raw) as Map);
|
||||
if (restored?['hasInstructions'] == true) {
|
||||
final instructions = await accountSecureStorage.read(
|
||||
key: '$scope:instructions:${restored!['request']}',
|
||||
);
|
||||
if (instructions == null) {
|
||||
throw StateError('Saved agent instructions are unavailable.');
|
||||
}
|
||||
restored['instructions'] = instructions;
|
||||
}
|
||||
deletedAgents = prefs.getStringList('$scope:deleted')?.toSet() ?? {};
|
||||
pending = restored;
|
||||
_scope = scope;
|
||||
}
|
||||
|
||||
Future<void> _savePending(Map<String, dynamic> value) async {
|
||||
final scope = _storageScope;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final persisted = Map<String, dynamic>.from(value)..remove('instructions');
|
||||
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<bool> 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 for 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<String, dynamic>.from(pending!);
|
||||
if (setup['owner'] != owner || setup['community'] != community.relayUrl) {
|
||||
throw StateError('Finish the pending setup in its original community.');
|
||||
}
|
||||
if (setup['pubkey'] == null) {
|
||||
status = 'Creating remote identity…';
|
||||
_notify();
|
||||
final result = await account.post('/v3/beekeeper/register-agent', {
|
||||
'agent_name': setup['name'],
|
||||
'idempotency_key': setup['request'],
|
||||
});
|
||||
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);
|
||||
}
|
||||
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);
|
||||
// Retain the completed registration if the listing refresh fails, so Retry
|
||||
// resumes the same identity instead of creating another agent.
|
||||
await refresh();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await accountSecureStorage.delete(
|
||||
key: '$scope:instructions:${setup['request']}',
|
||||
);
|
||||
await prefs.remove('$scope:pending');
|
||||
pending = null;
|
||||
status = '${setup['name']} is ready. Mention it in a channel.';
|
||||
});
|
||||
|
||||
/// Abandons an unfinished setup that never got a server identity, such as
|
||||
/// one whose registration the server rejects. Setups with an identity can
|
||||
/// only be resumed, so no registered agent is orphaned.
|
||||
Future<bool> discardPending() => run(() async {
|
||||
final setup = pending;
|
||||
if (setup == null) return;
|
||||
if (setup['pubkey'] != null) {
|
||||
throw StateError('This setup already has an agent. Resume it.');
|
||||
}
|
||||
final scope = _storageScope;
|
||||
await accountSecureStorage.delete(
|
||||
key: '$scope:instructions:${setup['request']}',
|
||||
);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!await prefs.remove('$scope:pending')) {
|
||||
throw StateError('Could not discard unfinished setup.');
|
||||
}
|
||||
pending = null;
|
||||
status = '';
|
||||
});
|
||||
|
||||
/// Activation alone only reconciles server-configured communities, so a
|
||||
/// community the owner joined later needs an explicit subscription.
|
||||
Future<void> 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<void> publishPolicy(String relay, Map<String, dynamic> 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 {
|
||||
await submitSignedEventOnce(
|
||||
wsUrl: relay,
|
||||
headers: await relayAuth.headers(
|
||||
RelayConfig(baseUrl: relay, nsec: key.nsec),
|
||||
),
|
||||
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<void> removePolicy(String relay, String pubkey) =>
|
||||
keys.withKeys((key) async {
|
||||
await submitSignedEventOnce(
|
||||
wsUrl: relay,
|
||||
headers: await relayAuth.headers(
|
||||
RelayConfig(baseUrl: relay, nsec: key.nsec),
|
||||
),
|
||||
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<bool> deleteAgent({
|
||||
required String pubkey,
|
||||
required Community community,
|
||||
required bool Function() isCurrent,
|
||||
required Future<List<NostrEvent>> 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;
|
||||
final communityOwner = community.pubkey;
|
||||
if (communityOwner == null || owner != communityOwner) {
|
||||
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.');
|
||||
}
|
||||
if (pending != null) {
|
||||
throw StateError('Finish pending agent setup before deleting an 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<String, dynamic>.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);
|
||||
check();
|
||||
final remaining = await fetch(
|
||||
NostrFilter(
|
||||
kinds: const [30177],
|
||||
authors: [communityOwner],
|
||||
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;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs the NIP-OA protocol preimage, not a Nostr event ID.
|
||||
List<String> 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 `*_<suffix>` enum form, nothing else.
|
||||
void requireStatus(Map<String, dynamic> result, String suffix, int numeric) {
|
||||
final status = result['status'];
|
||||
if (status == numeric || (status is String && status.endsWith('_$suffix'))) {
|
||||
return;
|
||||
}
|
||||
throw StateError(
|
||||
'Builderlab did not confirm $suffix '
|
||||
'(${status is String || status is num ? status : 'missing status'}).',
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -226,8 +236,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;
|
||||
|
||||
@@ -73,6 +73,7 @@ Future<NostrEvent> submitSignedEventOnce({
|
||||
required List<List<String>> tags,
|
||||
int? createdAt,
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
Map<String, String> headers = const {},
|
||||
}) async {
|
||||
final privateKey = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privateKey.isEmpty) throw const FormatException('Invalid nsec');
|
||||
@@ -122,6 +123,7 @@ Future<NostrEvent> submitSignedEventOnce({
|
||||
}
|
||||
},
|
||||
);
|
||||
socket.headers = headers;
|
||||
final resultFuture = result.future.timeout(timeout);
|
||||
try {
|
||||
await socket.connect();
|
||||
|
||||
142
mobile/test/features/agents/agents_harness.dart
Normal file
142
mobile/test/features/agents/agents_harness.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
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<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [
|
||||
for (final pubkey in filter.authors ?? const <String>[])
|
||||
NostrEvent(
|
||||
id: pubkey,
|
||||
pubkey: pubkey,
|
||||
createdAt: 1,
|
||||
kind: 0,
|
||||
tags: [signOwnerAuthorization(fakeOwner, pubkey)],
|
||||
content: '{"name":"Scout"}',
|
||||
sig: '',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Holds creation open so a test can observe the in-flight state.
|
||||
class GatedController extends RelayFakeController {
|
||||
GatedController(super.account);
|
||||
Completer<void>? gate;
|
||||
String? submittedName;
|
||||
String? submittedInstructions;
|
||||
|
||||
@override
|
||||
Future<bool> 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<AgentsHarness> 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<void> open(
|
||||
WidgetTester tester, {
|
||||
required List<String> 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();
|
||||
}
|
||||
}
|
||||
38
mobile/test/features/agents/agents_sheet_test.dart
Normal file
38
mobile/test/features/agents/agents_sheet_test.dart
Normal file
@@ -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<Material>(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);
|
||||
});
|
||||
}
|
||||
68
mobile/test/features/agents/create_agent_sheet_test.dart
Normal file
68
mobile/test/features/agents/create_agent_sheet_test.dart
Normal file
@@ -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<void>();
|
||||
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<IconButton>(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'));
|
||||
},
|
||||
);
|
||||
}
|
||||
51
mobile/test/features/agents/delete_agent_action_test.dart
Normal file
51
mobile/test/features/agents/delete_agent_action_test.dart
Normal file
@@ -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));
|
||||
});
|
||||
}
|
||||
91
mobile/test/helpers/remote_agents_fixture.dart
Normal file
91
mobile/test/helpers/remote_agents_fixture.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
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:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
final fakeOwner = nostr.Keys('0' * 63 + '1');
|
||||
final fakeAgent = nostr.Keys('0' * 63 + '2').public;
|
||||
const fakeSubject = 'remote-owner';
|
||||
|
||||
/// Fakes only the Builderlab network boundary.
|
||||
class FakeRegistry extends BuilderlabAccountClient {
|
||||
FakeRegistry({this.bound, this.signedIn = true});
|
||||
String? bound;
|
||||
bool signedIn;
|
||||
String subject = fakeSubject;
|
||||
bool failInstructions = false;
|
||||
bool rejectRegistration = false;
|
||||
Object deleteStatus = 1;
|
||||
final calls = <String>[];
|
||||
final bodies = <String, Map<String, dynamic>>{};
|
||||
List<Map<String, dynamic>> listed = [];
|
||||
|
||||
@override
|
||||
BuilderlabAccount? get account => signedIn
|
||||
? BuilderlabAccount(subject: subject, expiresAt: DateTime.utc(2030))
|
||||
: null;
|
||||
@override
|
||||
Future<BuilderlabAccount?> restore() async => account;
|
||||
@override
|
||||
Future<Map<String, dynamic>> currentIdentity() async => {
|
||||
'identity': {'pubkey_hex': bound},
|
||||
};
|
||||
@override
|
||||
Future<Map<String, dynamic>> post(
|
||||
String path,
|
||||
Map<String, dynamic> 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 (rejectRegistration) throw StateError('Registration rejected.');
|
||||
listed.add({
|
||||
'agent_pubkey': fakeAgent,
|
||||
'agent_id': 'agent',
|
||||
'agent_name': body['agent_name'],
|
||||
'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE',
|
||||
});
|
||||
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<void> publishPolicy(String relay, Map<String, dynamic> setup) async {
|
||||
registry.calls.add('publish-policy');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removePolicy(String relay, String pubkey) async {
|
||||
registry.calls.add('remove-registration');
|
||||
if (rejectRemoval) throw StateError('Relay rejected deletion');
|
||||
}
|
||||
}
|
||||
339
mobile/test/shared/agents/remote_agents_test.dart
Normal file
339
mobile/test/shared/agents/remote_agents_test.dart
Normal file
@@ -0,0 +1,339 @@
|
||||
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/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/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';
|
||||
|
||||
Community _community({String? pubkey}) => Community.create(
|
||||
name: 'Test',
|
||||
relayUrl: 'wss://test.${HostedEnvironment.communitySuffix}',
|
||||
pubkey: pubkey ?? fakeOwner.public,
|
||||
nsec: fakeOwner.nsec,
|
||||
);
|
||||
|
||||
Future<RelayFakeController> _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<bool> 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 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, 'Registration rejected.');
|
||||
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,
|
||||
);
|
||||
|
||||
// Once registered, the setup holds a real agent and can only resume.
|
||||
registry
|
||||
..rejectRegistration = false
|
||||
..failInstructions = true;
|
||||
expect(
|
||||
await controller.create(community, 'Scout', instructions: 'Private.'),
|
||||
isFalse,
|
||||
);
|
||||
expect(controller.pending?['pubkey'], fakeAgent);
|
||||
expect(await controller.discardPending(), isFalse);
|
||||
expect(controller.pending?['pubkey'], fakeAgent);
|
||||
});
|
||||
|
||||
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});
|
||||
|
||||
registry.subject = 'other-owner';
|
||||
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('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'},
|
||||
<String, dynamic>{},
|
||||
]) {
|
||||
expect(() => requireStatus(result, 'ACTIVE', 1), throwsStateError);
|
||||
}
|
||||
});
|
||||
|
||||
test('remote availability shows Online without changing local presence', () {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
presenceCacheProvider.overrideWith(_Presence.new),
|
||||
availableRemoteAgentsProvider.overrideWithValue({'remote'}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
expect(container.read(presentedPresenceProvider), {
|
||||
'remote': 'online',
|
||||
'local': 'away',
|
||||
});
|
||||
expect(container.read(presenceCacheProvider)['remote'], 'offline');
|
||||
});
|
||||
}
|
||||
|
||||
class _Presence extends PresenceCacheNotifier {
|
||||
@override
|
||||
Map<String, String> build() => {'remote': 'offline', 'local': 'away'};
|
||||
}
|
||||
Reference in New Issue
Block a user