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:
Tom Brow
2026-10-27 08:42:49 -07:00
parent ff9eb8f4dc
commit 46d0f8267a
20 changed files with 1927 additions and 8 deletions

View File

@@ -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

View 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),
),
],
),
),
),
),
);
}
}

View 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'),
),
],
),
),
),
),
);
}
}

View 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)),
],
),
);
}
}

View File

@@ -153,7 +153,7 @@ class _ConversationAppBarTitle extends StatelessWidget {
),
);
final presence = ref.watch(
presenceCacheProvider.select(
presentedPresenceProvider.select(
(presenceMap) => otherPubkey == null ? null : presenceMap[otherPubkey],
),
);

View File

@@ -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: () {

View File

@@ -202,7 +202,7 @@ class _DmAvatar extends ConsumerWidget {
),
);
final presence = ref.watch(
presenceCacheProvider.select(
presentedPresenceProvider.select(
(presenceMap) => otherPubkey == null ? null : presenceMap[otherPubkey],
),
);

View File

@@ -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',

View File

@@ -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)

View File

@@ -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'};
});

View File

@@ -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!),
],

View 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'}).',
);
}

View File

@@ -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;

View File

@@ -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();

View 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();
}
}

View 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);
});
}

View 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'));
},
);
}

View 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));
});
}

View 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');
}
}

View 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'};
}