From 745ad85b6f478bee631895f20d1480961becd0a1 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 27 Oct 2026 10:32:02 -0700 Subject: [PATCH] fix(mobile): recover a pending setup whose instructions were lost Preferences can outlive device-only secure storage, so a pending setup whose private instructions are missing is restored with the instructions field re-enabled instead of failing every registry operation. Adds a loopback relay test that registration publish and removal reach an http(s)-saved community over a WebSocket URL. Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- .../features/agents/create_agent_sheet.dart | 5 +- mobile/lib/shared/agents/remote_agents.dart | 28 +++++-- .../shared/agents/remote_agents_test.dart | 83 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/agents/create_agent_sheet.dart b/mobile/lib/features/agents/create_agent_sheet.dart index 73b6796ccc..6e2ab8b069 100644 --- a/mobile/lib/features/agents/create_agent_sheet.dart +++ b/mobile/lib/features/agents/create_agent_sheet.dart @@ -35,6 +35,8 @@ class CreateAgentSheet extends HookConsumerWidget { final active = ref.watch(activeCommunityProvider).asData?.value; final sameCommunity = active?.id == community.id; final pending = controller.pending != null; + final instructionsMissing = + controller.pending?['instructionsMissing'] == true; final canCreate = !controller.busy && sameCommunity && name.text.trim().isNotEmpty; final actionStyle = IconButton.styleFrom( @@ -134,7 +136,8 @@ class CreateAgentSheet extends HookConsumerWidget { const SizedBox(height: Grid.sm), TextField( controller: instructions, - enabled: !controller.busy && !pending, + enabled: + !controller.busy && (!pending || instructionsMissing), minLines: 4, maxLines: 6, textCapitalization: TextCapitalization.sentences, diff --git a/mobile/lib/shared/agents/remote_agents.dart b/mobile/lib/shared/agents/remote_agents.dart index 6743a6aa13..62d30ab665 100644 --- a/mobile/lib/shared/agents/remote_agents.dart +++ b/mobile/lib/shared/agents/remote_agents.dart @@ -250,9 +250,12 @@ class RemoteAgentController extends ChangeNotifier { agents = listed .where((row) => !deletedAgents.contains(row['agent_pubkey'])) .toList(); - status = pending == null - ? '' - : 'Unfinished setup saved. Resume to use the same agent.'; + status = switch (pending) { + null => '', + {'instructionsMissing': true} => + 'Unfinished setup saved. Enter its instructions again, then Resume.', + _ => 'Unfinished setup saved. Resume to use the same agent.', + }; } /// Requires this account's saved key to be its current server binding. @@ -282,9 +285,12 @@ class RemoteAgentController extends ChangeNotifier { key: '$scope:instructions:${restored!['request']}', ); if (instructions == null) { - throw StateError('Saved agent instructions are unavailable.'); + // Preferences can outlive device-only secure storage, for example + // after a device restore. Keep the setup and ask for them again. + restored['instructionsMissing'] = true; + } else { + restored['instructions'] = instructions; } - restored['instructions'] = instructions; } deletedAgents = prefs.getStringList('$scope:deleted')?.toSet() ?? {}; pending = restored; @@ -294,7 +300,9 @@ class RemoteAgentController extends ChangeNotifier { Future _savePending(Map value) async { final scope = _storageScope; final prefs = await SharedPreferences.getInstance(); - final persisted = Map.from(value)..remove('instructions'); + final persisted = Map.from(value) + ..remove('instructions') + ..remove('hasInstructions'); final instructions = value['instructions'] as String?; if (instructions != null && instructions.isNotEmpty) { await accountSecureStorage.write( @@ -348,6 +356,14 @@ class RemoteAgentController extends ChangeNotifier { if (setup['owner'] != owner || setup['community'] != community.relayUrl) { throw StateError('Finish the pending setup in its original community.'); } + if (setup['instructionsMissing'] == true) { + if (instructions.trim().length > 20000) { + throw StateError('Use at most 20,000 characters for instructions.'); + } + setup = {...setup, 'instructions': instructions.trim()} + ..remove('instructionsMissing'); + await _savePending(setup); + } if (setup['pubkey'] == null) { status = 'Creating remote identity…'; _notify(); diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index 7733c4c408..317c6febde 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:buzz/features/profile/presence_cache_provider.dart'; import 'package:buzz/shared/agents/remote_agents.dart'; import 'package:buzz/shared/community/community.dart'; @@ -5,6 +8,7 @@ import 'package:buzz/shared/community/community_provider.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/hosted/hosted_relay_auth.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'; @@ -239,6 +243,85 @@ void main() { }, ); + test('a setup whose instructions were lost asks for them again', () async { + final registry = FakeRegistry(bound: fakeOwner.public) + ..failInstructions = true; + final controller = await _controller(registry); + final community = _community(); + expect( + await controller.create(community, 'Scout', instructions: 'Private.'), + isFalse, + ); + final request = controller.pending!['request']; + // Device-only secure storage is gone; preferences survived (a restore). + await accountSecureStorage.delete(key: '$_scope:instructions:$request'); + + final restarted = RelayFakeController(registry); + addTearDown(() { + restarted.dispose(); + restarted.relayAuth.dispose(); + }); + expect(await restarted.run(restarted.refresh), isTrue); + expect(restarted.pending?['instructionsMissing'], isTrue); + expect(restarted.status, contains('Enter its instructions again')); + + registry + ..failInstructions = false + ..calls.clear(); + expect( + await restarted.create(community, 'Scout', instructions: 'Again.'), + isTrue, + ); + expect( + registry.bodies['/v3/beekeeper/update-agent']?['agent_instructions'], + 'Again.', + ); + expect(restarted.pending, isNull); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('$_scope:pending'), isNull); + }); + + test( + 'registrations use a WebSocket URL for https-saved communities', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final kinds = []; + server.listen((request) async { + final peer = await WebSocketTransformer.upgrade(request); + addTearDown(peer.close); + peer.listen((frame) { + final data = jsonDecode(frame as String) as List; + final event = Map.from(data[1] as Map); + if (data.first == 'EVENT') kinds.add(event['kind'] as int); + peer.add(jsonEncode(['OK', event['id'], true, ''])); + }); + peer.add(jsonEncode(['AUTH', 'challenge'])); + }); + final registry = FakeRegistry(bound: fakeOwner.public); + await AccountIdentityStore() + .forSubject(fakeSubject) + .create(fakeOwner.nsec); + final controller = RemoteAgentController( + account: registry, + keys: AccountIdentityStore(), + relayAuth: HostedRelayAuth(registry), + ); + addTearDown(() { + controller.dispose(); + controller.relayAuth.dispose(); + }); + // Stored the way paired communities may keep it: an HTTP(S) origin. + final relay = 'http://127.0.0.1:${server.port}'; + await controller.publishPolicy(relay, { + 'pubkey': fakeAgent, + 'name': 'Scout', + }); + await controller.removePolicy(relay, fakeAgent); + expect(kinds, [30177, 5]); + }, + ); + test('a setup without a server identity can be discarded', () async { final registry = FakeRegistry(bound: fakeOwner.public) ..rejectRegistration = true;