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:
Tom Brow
2026-10-27 12:10:59 -07:00
parent 745ad85b6f
commit 1c852eda3b
5 changed files with 148 additions and 44 deletions

View File

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

View File

@@ -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'));
}

View File

@@ -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"}'),
];
}

View File

@@ -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(),
);
}

View File

@@ -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)],
_ => [],
};