diff --git a/mobile/lib/features/agents/create_agent_sheet.dart b/mobile/lib/features/agents/create_agent_sheet.dart index 6e2ab8b069..6113bb4af3 100644 --- a/mobile/lib/features/agents/create_agent_sheet.dart +++ b/mobile/lib/features/agents/create_agent_sheet.dart @@ -159,9 +159,7 @@ class CreateAgentSheet extends HookConsumerWidget { style: TextStyle(color: context.colors.error), ), ], - if (pending && - controller.pending?['pubkey'] == null && - !controller.busy) + if (controller.canDiscardPending && !controller.busy) TextButton( onPressed: () async { error.value = null; diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 62d30ab665..3ef338f82a 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -80,7 +80,7 @@ Future> ownedCommunityAgents( return { for (final event in events) if (keys.contains(event.pubkey) && - verifiedOaOwnerPubkey(event.tags, event.pubkey) == owner) + verifiedProfileOaOwnerPubkey(event) == owner) event.pubkey, }; } @@ -367,10 +367,21 @@ class RemoteAgentController extends ChangeNotifier { if (setup['pubkey'] == null) { status = 'Creating remote identity…'; _notify(); + // Once sent, the server may allocate an identity even if the response is + // lost. Only Resume, with the same idempotency key, can recover it. + setup = {...setup, 'registering': true}; + await _savePending(setup); final result = await account.post('/v3/beekeeper/register-agent', { 'agent_name': setup['name'], 'idempotency_key': setup['request'], }); + if (!hasStatus(result, 'UNATTESTED', 1) && + result['status'] != null && + result['agent_pubkey'] == null) { + // An explicit rejection allocated nothing, so it can be discarded. + setup = {...setup}..remove('registering'); + await _savePending(setup); + } requireStatus(result, 'UNATTESTED', 1); final pubkey = result['agent_pubkey']; if (pubkey is! String || @@ -417,14 +428,21 @@ class RemoteAgentController extends ChangeNotifier { status = '${setup['name']} is ready. Mention it in a channel.'; }); + /// Whether [pending] provably has no server identity: registration was never + /// sent, or the server explicitly rejected it. + bool get canDiscardPending => + pending != null && + pending!['pubkey'] == null && + pending!['registering'] != true; + /// Abandons an unfinished setup that never got a server identity, such as - /// one whose registration the server rejects. Setups with an identity can - /// only be resumed, so no registered agent is orphaned. + /// one whose registration the server rejects. Setups that have or may have + /// an identity can only be resumed, so no registered agent is orphaned. Future discardPending() => run(() async { final setup = pending; if (setup == null) return; - if (setup['pubkey'] != null) { - throw StateError('This setup already has an agent. Resume it.'); + if (!canDiscardPending) { + throw StateError('This setup may already have an agent. Resume it.'); } final scope = _storageScope; final prefs = await SharedPreferences.getInstance(); @@ -534,8 +552,14 @@ class RemoteAgentController extends ChangeNotifier { 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.'); + // Block only the setup's own agent, or any agent while the setup's + // identity is unknown, since it could be the one being deleted. + final setupAgent = pending?['pubkey']; + if (setupAgent == pubkey || + (setupAgent == null && pending?['registering'] == true)) { + throw StateError( + 'Resume the unfinished setup before deleting this agent.', + ); } final prefs = await SharedPreferences.getInstance(); check(); @@ -625,12 +649,17 @@ List signOwnerAuthorization(nostr.Keys owner, String agent) { /// Accepts the registry's numeric or `*_` enum form, nothing else. void requireStatus(Map result, String suffix, int numeric) { + if (hasStatus(result, suffix, numeric)) return; final status = result['status']; - 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'}).', ); } + +/// Whether [result] reports [suffix] in the registry's numeric or `*_` +/// enum form. +bool hasStatus(Map result, String suffix, int numeric) { + final status = result['status']; + return status == numeric || (status is String && status.endsWith('_$suffix')); +} diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart index 35ead951a6..f5ab604489 100644 --- a/mobile/test/features/agents/agents_harness.dart +++ b/mobile/test/features/agents/agents_harness.dart @@ -28,16 +28,8 @@ class _Relay extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async => [ - for (final pubkey in filter.authors ?? const []) - NostrEvent( - id: pubkey, - pubkey: pubkey, - createdAt: 1, - kind: 0, - tags: [signOwnerAuthorization(fakeOwner, pubkey)], - content: '{"name":"Scout"}', - sig: '', - ), + if (filter.authors?.contains(fakeAgent) ?? false) + signedAgentProfile(fakeAgentKeys, fakeOwner, content: '{"name":"Scout"}'), ]; } diff --git a/mobile/test/helpers/remote_agents_fixture.dart b/mobile/test/helpers/remote_agents_fixture.dart index 88c143ce6d..386d53eda9 100644 --- a/mobile/test/helpers/remote_agents_fixture.dart +++ b/mobile/test/helpers/remote_agents_fixture.dart @@ -1,13 +1,18 @@ +import 'dart:convert'; + import 'package:buzz/shared/agents/remote_agents.dart'; import 'package:buzz/shared/hosted/account_identity_store.dart'; import 'package:buzz/shared/hosted/builderlab_account.dart'; import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; final fakeOwner = nostr.Keys('0' * 63 + '1'); -final fakeAgent = nostr.Keys('0' * 63 + '2').public; +final fakeAgentKeys = nostr.Keys('0' * 63 + '2'); +final fakeAgent = fakeAgentKeys.public; const fakeSubject = 'remote-owner'; /// Fakes only the Builderlab network boundary. @@ -36,6 +41,7 @@ class FakeRegistry extends BuilderlabAccountClient { ValueListenable get subjectChanges => _changes; bool failInstructions = false; bool rejectRegistration = false; + bool loseRegistration = false; Object deleteStatus = 1; final calls = []; final bodies = >{}; @@ -62,13 +68,17 @@ class FakeRegistry extends BuilderlabAccountClient { case '/v3/beekeeper/list-agents': return {'status': 1, 'agents': listed}; case '/v3/beekeeper/register-agent': - if (rejectRegistration) throw StateError('Registration rejected.'); + if (rejectRegistration) { + return {'status': 'REGISTER_AGENT_STATUS_NAME_TAKEN'}; + } listed.add({ 'agent_pubkey': fakeAgent, 'agent_id': 'agent', 'agent_name': body['agent_name'], 'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE', }); + // The server committed the identity, but the response never arrived. + if (loseRegistration) throw StateError('Connection lost.'); return {'status': 1, 'agent_pubkey': fakeAgent, 'agent_id': 'agent'}; case '/v3/beekeeper/attest-agent': return {'status': 'ATTEST_AGENT_STATUS_ACTIVE'}; @@ -108,3 +118,31 @@ class RelayFakeController extends RemoteAgentController { if (rejectRemoval) throw StateError('Relay rejected deletion'); } } + +/// A signed agent profile carrying [owner]'s attestation under [conditions]. +NostrEvent signedAgentProfile( + nostr.Keys agent, + nostr.Keys owner, { + String conditions = '', + String content = '{}', +}) { + final pubkey = agent.public; + final digest = sha256 + .convert(utf8.encode('nostr:agent-auth:$pubkey:$conditions')) + .toString(); + final auth = [ + 'auth', + owner.public, + conditions, + nostr.Schnorr.sign(secretKey: owner.secret, message: digest), + ]; + return NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + content: content, + secretKey: agent.secret, + createdAt: 100, + tags: [auth], + ).toMap(), + ); +} diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index 317c6febde..351be017ce 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -21,6 +21,9 @@ import '../../helpers/remote_agents_fixture.dart'; final _scope = 'remote-agents:${HostedEnvironment.origin.host}:$fakeSubject'; +/// Deterministic test keys. +nostr.Keys _keys(String last) => nostr.Keys('0' * 63 + last); + Community _community({String? pubkey}) => Community.create( name: 'Test', relayUrl: 'wss://test.${HostedEnvironment.communitySuffix}', @@ -333,7 +336,8 @@ void main() { await controller.create(community, 'Scout', instructions: 'Private.'), isFalse, ); - expect(controller.status, 'Registration rejected.'); + expect(controller.status, contains('NAME_TAKEN')); + expect(controller.canDiscardPending, isTrue); final request = controller.pending!['request']; expect(prefs.getString('$_scope:pending'), isNotNull); @@ -345,17 +349,63 @@ void main() { isNull, ); - // Once registered, the setup holds a real agent and can only resume. + // A lost response may hide a committed identity, so it can only resume. registry ..rejectRegistration = false - ..failInstructions = true; + ..loseRegistration = true; expect( await controller.create(community, 'Scout', instructions: 'Private.'), isFalse, ); + expect(controller.pending?['pubkey'], isNull); + expect(controller.canDiscardPending, isFalse); + expect(await controller.discardPending(), isFalse); + final lost = controller.pending!['request']; + expect(jsonDecode(prefs.getString('$_scope:pending')!)['request'], lost); + + // Until the identity is known, any listed agent could be the lost one. + final other = _keys('3').public; + registry.listed = [ + ...registry.listed, + {'agent_pubkey': other, 'agent_id': 'remote-id'}, + ]; + await controller.run(controller.refresh); + final elsewhere = Community.create( + name: 'Other', + relayUrl: 'wss://other.${HostedEnvironment.communitySuffix}', + pubkey: fakeOwner.public, + nsec: fakeOwner.nsec, + ); + Future remove(String pubkey) => controller.deleteAgent( + pubkey: pubkey, + community: elsewhere, + isCurrent: () => true, + fetch: (_) async => [], + ); + expect(await remove(other), isFalse); + expect(controller.status, contains('Resume the unfinished setup')); + + // Resume reuses the idempotency key and recovers the same identity. + registry + ..loseRegistration = false + ..failInstructions = true + ..bodies.clear(); + expect(await controller.create(community, 'Scout'), isFalse); + expect( + registry.bodies['/v3/beekeeper/register-agent']?['idempotency_key'], + lost, + ); expect(controller.pending?['pubkey'], fakeAgent); expect(await controller.discardPending(), isFalse); expect(controller.pending?['pubkey'], fakeAgent); + + // Once known, the unfinished setup blocks deleting only its own agent. + expect(await remove(fakeAgent), isFalse); + expect(controller.status, contains('Resume the unfinished setup')); + expect(await remove(other), isTrue); + expect(controller.deletedAgents, {other}); + expect(controller.pending?['pubkey'], fakeAgent); + expect(prefs.getString('$_scope:pending'), isNotNull); }); test('another account never sees the previous registry', () async { @@ -398,12 +448,19 @@ void main() { test( 'availability requires an owned profile and a live registration', () async { - final other = nostr.Keys('0' * 63 + '3').public; - final elsewhere = nostr.Keys('0' * 63 + '5').public; + String key(String last) => _keys(last).public; + final other = key('3'); + final elsewhere = key('5'); + final forged = signedAgentProfile(_keys('8'), fakeOwner).toJson() + ..['sig'] = '0' * 128; final profiles = [ - _profile(fakeAgent, fakeOwner), + signedAgentProfile(fakeAgentKeys, fakeOwner), // Attested by someone else, so not this owner's agent. - _profile(other, nostr.Keys('0' * 63 + '4')), + signedAgentProfile(_keys('3'), _keys('4')), + // Signed conditions that exclude this profile, and a forged envelope. + signedAgentProfile(_keys('6'), fakeOwner, conditions: 'kind=1'), + signedAgentProfile(_keys('7'), fakeOwner, conditions: 'created_at<50'), + NostrEvent.fromJson(forged), ]; var registrations = []; Future> fetch(NostrFilter filter) async { @@ -416,7 +473,7 @@ void main() { return registrations; } - final keys = {fakeAgent, other, elsewhere}; + final keys = {fakeAgent, other, elsewhere, key('6'), key('7'), key('8')}; // The list keeps owned agents, so an interrupted delete stays retryable. expect(await ownedCommunityAgents(keys, fakeOwner.public, fetch), { fakeAgent, @@ -516,16 +573,6 @@ class _AgentPresence extends PresenceCacheNotifier { Map build() => {fakeAgent: 'offline'}; } -NostrEvent _profile(String pubkey, nostr.Keys owner) => NostrEvent( - id: 'profile-$pubkey', - pubkey: pubkey, - createdAt: 1, - kind: 0, - tags: [signOwnerAuthorization(owner, pubkey)], - content: '{}', - sig: '', -); - NostrEvent _registration(String agent) => NostrEvent( id: 'registration-$agent', pubkey: fakeOwner.public, @@ -550,7 +597,7 @@ class _AgentRelay extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async => switch (filter.kinds.single) { - 0 => [_profile(fakeAgent, fakeOwner)], + 0 => [signedAgentProfile(fakeAgentKeys, fakeOwner)], 30177 when live => [_registration(fakeAgent)], _ => [], };