fix(mobile): scope remote availability to the community and account

Online status now counts only ACTIVE agents with owner-attested profiles on
the active relay, so agents from another community stay offline. The account
client publishes subject changes, which clear the previous account's registry
immediately. A failed registry load is retried on the next load or relay
reconnect.

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:55:21 -07:00
parent 46d0f8267a
commit b76bfdbf1e
6 changed files with 164 additions and 44 deletions

View File

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

View File

@@ -247,7 +247,7 @@ final presenceCacheProvider =
/// presence. Local presence is unchanged.
final presentedPresenceProvider = Provider<Map<String, String>>((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'};
});

View File

@@ -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<RemoteAgentController>((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<Set<String>>((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<Set<String>>((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<Set<String>>((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<Set<String>> registeredCommunityAgents(
Set<String> keys,
String? owner,
Future<List<NostrEvent>> 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<void> 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();
}
}

View File

@@ -87,6 +87,10 @@ class BuilderlabAccountClient {
BuilderlabAccount? get account => _account;
/// The signed-in subject, notifying when an account signs in, out or changes.
ValueListenable<String?> get subjectChanges => _subject;
final _subject = ValueNotifier<String?>(null);
/// Runs operations one at a time in call order, so a callback exchange that
/// arrives during startup restore waits instead of failing.
Future<T> _serial<T>(Future<T> 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<void> _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();
}
}

View File

@@ -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<String?>(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<String?> get subjectChanges => _changes;
bool failInstructions = false;
bool rejectRegistration = false;
Object deleteStatus = 1;

View File

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