mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
fix(mobile): keep ambiguous agent setups recoverable
A sent registration may have allocated an identity even if its response was lost, so only an explicit server rejection can be discarded; Resume recovers the identity with the same idempotency key. An unfinished setup now blocks deleting only its own agent, or any agent while its identity is unknown. Owned-agent admission uses the profile-level NIP-OA check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -80,7 +80,7 @@ Future<Set<String>> 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<bool> 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<String> signOwnerAuthorization(nostr.Keys owner, String agent) {
|
||||
|
||||
/// Accepts the registry's numeric or `*_<suffix>` enum form, nothing else.
|
||||
void requireStatus(Map<String, dynamic> 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 `*_<suffix>`
|
||||
/// enum form.
|
||||
bool hasStatus(Map<String, dynamic> result, String suffix, int numeric) {
|
||||
final status = result['status'];
|
||||
return status == numeric || (status is String && status.endsWith('_$suffix'));
|
||||
}
|
||||
|
||||
@@ -28,16 +28,8 @@ class _Relay extends RelaySessionNotifier {
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => [
|
||||
for (final pubkey in filter.authors ?? const <String>[])
|
||||
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"}'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String?> get subjectChanges => _changes;
|
||||
bool failInstructions = false;
|
||||
bool rejectRegistration = false;
|
||||
bool loseRegistration = false;
|
||||
Object deleteStatus = 1;
|
||||
final calls = <String>[];
|
||||
final bodies = <String, Map<String, dynamic>>{};
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<bool> 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 = <NostrEvent>[];
|
||||
Future<List<NostrEvent>> 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<String, String> 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)],
|
||||
_ => [],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user