mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user