diff --git a/mobile/lib/features/agents/agents_sheet.dart b/mobile/lib/features/agents/agents_sheet.dart index 623e49f2f8..8696f1f285 100644 --- a/mobile/lib/features/agents/agents_sheet.dart +++ b/mobile/lib/features/agents/agents_sheet.dart @@ -6,7 +6,6 @@ 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'; @@ -107,24 +106,6 @@ class _AgentsNavigation extends HookConsumerWidget { } } -/// Only registry identities with verified owner profiles on this relay belong -/// in its list. Account-wide registry entries alone are not community membership. -Future> registeredCommunityAgents( - List> agents, - String? owner, - Future> 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({ @@ -155,7 +136,10 @@ class AgentsSheet extends HookConsumerWidget { final registryFuture = useMemoized(() async { final fetch = ref.read(relaySessionProvider.notifier).fetchHistory; return registeredCommunityAgents( - controller.agents, + { + for (final agent in controller.agents) + agent['agent_pubkey'] as String, + }, controller.owner, fetch, ); diff --git a/mobile/lib/features/profile/presence_cache_provider.dart b/mobile/lib/features/profile/presence_cache_provider.dart index 5c116b5a4b..83f7a08fa6 100644 --- a/mobile/lib/features/profile/presence_cache_provider.dart +++ b/mobile/lib/features/profile/presence_cache_provider.dart @@ -247,7 +247,7 @@ final presenceCacheProvider = /// presence. Local presence is unchanged. final presentedPresenceProvider = Provider>((ref) { final presence = ref.watch(presenceCacheProvider); - final remote = ref.watch(availableRemoteAgentsProvider); + final remote = ref.watch(availableRemoteAgentsProvider).value ?? const {}; if (remote.isEmpty) return presence; return {...presence, for (final pubkey in remote) pubkey: 'online'}; }); diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 047febd1f9..1137efac4c 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -10,12 +10,15 @@ import 'package:uuid/uuid.dart'; import '../community/community.dart'; import '../community/community_provider.dart'; +import '../crypto/nip_oa.dart'; import '../hosted/account_identity_store.dart'; import '../hosted/builderlab_account.dart'; import '../hosted/hosted_environment.dart'; import '../hosted/hosted_relay_auth.dart'; +import '../relay/nostr_filters.dart'; import '../relay/nostr_models.dart'; import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; import '../relay/signed_event_relay.dart'; import '../storage/secure_storage.dart'; @@ -37,11 +40,14 @@ final remoteAgentControllerProvider = Provider((ref) { 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>((ref) { +/// The active community's remote agents that the signed-in owner's registry +/// reports ACTIVE. Remote workers wake on messages, so this means available, +/// not socket presence. +final availableRemoteAgentsProvider = FutureProvider>((ref) async { final community = ref.watch(activeCommunityProvider).asData?.value; if (!supportsRemoteAgents(community)) return const {}; + // A reconnect retries a registry load that failed. + ref.watch(relaySessionProvider.select((session) => session.status)); final controller = ref.watch(remoteAgentControllerProvider); final active = controller.activeAgents; void changed() { @@ -52,9 +58,32 @@ final availableRemoteAgentsProvider = Provider>((ref) { ref.onDispose(() => controller.removeListener(changed)); // Loading notifies listeners, which must not happen during this build. scheduleMicrotask(() => controller.load(community!.relayUrl)); - return active; + if (active.isEmpty) return const {}; + // The registry is account-wide; only agents registered here belong here. + return registeredCommunityAgents( + active, + controller.owner, + ref.read(relaySessionProvider.notifier).fetchHistory, + ); }); +/// Only registry identities with verified owner profiles on this relay belong +/// to its community. Account-wide registry entries alone are not membership. +Future> registeredCommunityAgents( + Set keys, + String? owner, + Future> Function(NostrFilter) fetch, +) async { + if (keys.isEmpty || owner == null) return {}; + final events = await fetch(NostrFilters.profilesBatch(keys.toList())); + return { + for (final event in events) + if (keys.contains(event.pubkey) && + verifiedOaOwnerPubkey(event.tags, event.pubkey) == owner) + event.pubkey, + }; +} + /// Owner-side registry client for Builderlab remote agents, using the same /// account and Beekeeper contracts as desktop. class RemoteAgentController extends ChangeNotifier { @@ -62,7 +91,9 @@ class RemoteAgentController extends ChangeNotifier { required this.account, required AccountIdentityStore keys, required this.relayAuth, - }) : _keys = keys; + }) : _keys = keys { + account.subjectChanges.addListener(_accountChanged); + } final BuilderlabAccountClient account; final AccountIdentityStore _keys; @@ -114,11 +145,20 @@ class RemoteAgentController extends ChangeNotifier { _scope = null; } + /// Drops another account's registry as soon as the account changes. + void _accountChanged() { + if (_scope == null || _scope == _currentScope) return; + _clearAccountState(); + _loadedRelay = null; + _notify(); + } + /// Loads the registry once per community; opening Agents always refreshes. + /// A failed load is retried on the next call. Future load(String relay) async { if (_disposed || busy || _loadedRelay == relay) return; _loadedRelay = relay; - await run(refresh); + if (!await run(refresh) && _loadedRelay == relay) _loadedRelay = null; } /// Runs one operation at a time and turns its failure into [status]. @@ -517,6 +557,7 @@ class RemoteAgentController extends ChangeNotifier { @override void dispose() { _disposed = true; + account.subjectChanges.removeListener(_accountChanged); super.dispose(); } } diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index 45f7d41885..d894707ab4 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -87,6 +87,10 @@ class BuilderlabAccountClient { BuilderlabAccount? get account => _account; + /// The signed-in subject, notifying when an account signs in, out or changes. + ValueListenable get subjectChanges => _subject; + final _subject = ValueNotifier(null); + /// Runs operations one at a time in call order, so a callback exchange that /// arrives during startup restore waits instead of failing. Future _serial(Future Function() operation) { @@ -194,6 +198,7 @@ class BuilderlabAccountClient { BuilderlabAccount _adopt(String credential, BuilderlabAccount verified) { if (_disposed) throw AccountFailure.unavailable; _credential = credential; + _subject.value = verified.subject; return _account = verified; } @@ -296,6 +301,7 @@ class BuilderlabAccountClient { Future _clear() async { _credential = null; _account = null; + if (!_disposed) _subject.value = null; try { await _storage.delete(key: _storageKey); } catch (_) { @@ -363,6 +369,7 @@ class BuilderlabAccountClient { _disposed = true; _credential = null; _account = null; + _subject.dispose(); _http.close(); } } diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart index 43738e71a3..88c143ce6d 100644 --- a/mobile/test/helpers/remote_agents_fixture.dart +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -2,6 +2,7 @@ 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/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -11,10 +12,28 @@ const fakeSubject = 'remote-owner'; /// Fakes only the Builderlab network boundary. class FakeRegistry extends BuilderlabAccountClient { - FakeRegistry({this.bound, this.signedIn = true}); + FakeRegistry({this.bound, bool signedIn = true}) : _signedIn = signedIn { + _changes.value = signedIn ? fakeSubject : null; + } String? bound; - bool signedIn; - String subject = fakeSubject; + bool _signedIn; + String _subjectValue = fakeSubject; + final _changes = ValueNotifier(null); + + bool get signedIn => _signedIn; + set signedIn(bool value) { + _signedIn = value; + _changes.value = value ? _subjectValue : null; + } + + String get subject => _subjectValue; + set subject(String value) { + _subjectValue = value; + if (_signedIn) _changes.value = value; + } + + @override + ValueListenable get subjectChanges => _changes; bool failInstructions = false; bool rejectRegistration = false; Object deleteStatus = 1; diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index f33fd935fd..5497e21574 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -287,7 +287,10 @@ void main() { await controller.run(controller.refresh); expect(controller.activeAgents, {fakeAgent}); + var notified = 0; + controller.addListener(() => notified++); registry.subject = 'other-owner'; + expect(notified, 1); expect(controller.activeAgents, isEmpty); // The other account has no saved key, so its registry is never listed. expect(await controller.run(controller.refresh), isFalse); @@ -295,6 +298,68 @@ void main() { expect(controller.owner, isNull); }); + test('a failed registry load retries on the next load', () async { + final registry = FakeRegistry(bound: 'f' * 64); + final controller = await _controller(registry); + final relay = _community().relayUrl; + await controller.load(relay); + expect(controller.status, 'Buzz identity: mismatch.'); + registry.bound = fakeOwner.public; + await controller.load(relay); + expect(registry.calls, ['list-agents']); + await controller.load(relay); + expect(registry.calls, ['list-agents']); + }); + + test( + 'availability counts only agents registered in this community', + () async { + final other = nostr.Keys('0' * 63 + '3').public; + final stranger = nostr.Keys('0' * 63 + '4'); + final elsewhere = nostr.Keys('0' * 63 + '5').public; + final profiles = [ + NostrEvent( + id: '1', + pubkey: fakeAgent, + createdAt: 1, + kind: 0, + tags: [signOwnerAuthorization(fakeOwner, fakeAgent)], + content: '{}', + sig: '', + ), + // Attested by someone else, so not this owner's agent. + NostrEvent( + id: '2', + pubkey: other, + createdAt: 1, + kind: 0, + tags: [signOwnerAuthorization(stranger, other)], + content: '{}', + sig: '', + ), + ]; + expect( + await registeredCommunityAgents( + {fakeAgent, other, elsewhere}, + fakeOwner.public, + (filter) async { + expect(filter.kinds, [0]); + return profiles; + }, + ), + {fakeAgent}, + ); + expect( + await registeredCommunityAgents( + {fakeAgent}, + null, + (_) async => profiles, + ), + isEmpty, + ); + }, + ); + test('owner proof verifies for only the provisioned agent key', () { final other = nostr.Keys('0' * 63 + '3'); final auth = signOwnerAuthorization(fakeOwner, fakeAgent); @@ -317,20 +382,24 @@ void main() { } }); - 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'); - }); + test( + 'remote availability shows Online without changing local presence', + () async { + final container = ProviderContainer( + overrides: [ + presenceCacheProvider.overrideWith(_Presence.new), + availableRemoteAgentsProvider.overrideWith((ref) async => {'remote'}), + ], + ); + addTearDown(container.dispose); + await container.read(availableRemoteAgentsProvider.future); + expect(container.read(presentedPresenceProvider), { + 'remote': 'online', + 'local': 'away', + }); + expect(container.read(presenceCacheProvider)['remote'], 'offline'); + }, + ); } class _Presence extends PresenceCacheNotifier {