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 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-27 10:32:02 -07:00
parent 3697d528a5
commit 745ad85b6f
3 changed files with 109 additions and 7 deletions

View File

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

View File

@@ -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<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 persisted = Map<String, dynamic>.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();

View File

@@ -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 = <int>[];
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<String, dynamic>.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;