mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +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 active = ref.watch(activeCommunityProvider).asData?.value;
|
||||||
final sameCommunity = active?.id == community.id;
|
final sameCommunity = active?.id == community.id;
|
||||||
final pending = controller.pending != null;
|
final pending = controller.pending != null;
|
||||||
|
final instructionsMissing =
|
||||||
|
controller.pending?['instructionsMissing'] == true;
|
||||||
final canCreate =
|
final canCreate =
|
||||||
!controller.busy && sameCommunity && name.text.trim().isNotEmpty;
|
!controller.busy && sameCommunity && name.text.trim().isNotEmpty;
|
||||||
final actionStyle = IconButton.styleFrom(
|
final actionStyle = IconButton.styleFrom(
|
||||||
@@ -134,7 +136,8 @@ class CreateAgentSheet extends HookConsumerWidget {
|
|||||||
const SizedBox(height: Grid.sm),
|
const SizedBox(height: Grid.sm),
|
||||||
TextField(
|
TextField(
|
||||||
controller: instructions,
|
controller: instructions,
|
||||||
enabled: !controller.busy && !pending,
|
enabled:
|
||||||
|
!controller.busy && (!pending || instructionsMissing),
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
|||||||
@@ -250,9 +250,12 @@ class RemoteAgentController extends ChangeNotifier {
|
|||||||
agents = listed
|
agents = listed
|
||||||
.where((row) => !deletedAgents.contains(row['agent_pubkey']))
|
.where((row) => !deletedAgents.contains(row['agent_pubkey']))
|
||||||
.toList();
|
.toList();
|
||||||
status = pending == null
|
status = switch (pending) {
|
||||||
? ''
|
null => '',
|
||||||
: 'Unfinished setup saved. Resume to use the same agent.';
|
{'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.
|
/// 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']}',
|
key: '$scope:instructions:${restored!['request']}',
|
||||||
);
|
);
|
||||||
if (instructions == null) {
|
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() ?? {};
|
deletedAgents = prefs.getStringList('$scope:deleted')?.toSet() ?? {};
|
||||||
pending = restored;
|
pending = restored;
|
||||||
@@ -294,7 +300,9 @@ class RemoteAgentController extends ChangeNotifier {
|
|||||||
Future<void> _savePending(Map<String, dynamic> value) async {
|
Future<void> _savePending(Map<String, dynamic> value) async {
|
||||||
final scope = _storageScope;
|
final scope = _storageScope;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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?;
|
final instructions = value['instructions'] as String?;
|
||||||
if (instructions != null && instructions.isNotEmpty) {
|
if (instructions != null && instructions.isNotEmpty) {
|
||||||
await accountSecureStorage.write(
|
await accountSecureStorage.write(
|
||||||
@@ -348,6 +356,14 @@ class RemoteAgentController extends ChangeNotifier {
|
|||||||
if (setup['owner'] != owner || setup['community'] != community.relayUrl) {
|
if (setup['owner'] != owner || setup['community'] != community.relayUrl) {
|
||||||
throw StateError('Finish the pending setup in its original community.');
|
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) {
|
if (setup['pubkey'] == null) {
|
||||||
status = 'Creating remote identity…';
|
status = 'Creating remote identity…';
|
||||||
_notify();
|
_notify();
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:buzz/features/profile/presence_cache_provider.dart';
|
import 'package:buzz/features/profile/presence_cache_provider.dart';
|
||||||
import 'package:buzz/shared/agents/remote_agents.dart';
|
import 'package:buzz/shared/agents/remote_agents.dart';
|
||||||
import 'package:buzz/shared/community/community.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/crypto/nip_oa.dart';
|
||||||
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
||||||
import 'package:buzz/shared/hosted/hosted_environment.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/relay/relay.dart';
|
||||||
import 'package:buzz/shared/storage/secure_storage.dart';
|
import 'package:buzz/shared/storage/secure_storage.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_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 {
|
test('a setup without a server identity can be discarded', () async {
|
||||||
final registry = FakeRegistry(bound: fakeOwner.public)
|
final registry = FakeRegistry(bound: fakeOwner.public)
|
||||||
..rejectRegistration = true;
|
..rejectRegistration = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user