mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
fix(mobile): fail closed on unsupported managed-agent policy
Mobile accepted any 30177 record with a name, while desktop parses the full ManagedAgentEventContent and excludes an agent whose latest record it cannot apply. Mobile now requires the same policy shape (a supported respond_to, a u32 parallelism, a string allowlist, and string optional fields), so an unsupported latest record hides the agent from Solo and fails revalidation instead of leaving it usable on mobile only. Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -163,7 +163,7 @@ Future<List<String>> _managedAgentPubkeys(
|
||||
}
|
||||
return [
|
||||
for (final MapEntry(key: d, value: event) in heads.entries)
|
||||
if (_agentPubkey.hasMatch(d) && _hasName(event)) d,
|
||||
if (_agentPubkey.hasMatch(d) && _isSupportedPolicy(event)) d,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -171,14 +171,41 @@ bool _newer(NostrEvent a, NostrEvent b) =>
|
||||
a.createdAt > b.createdAt ||
|
||||
(a.createdAt == b.createdAt && a.id.compareTo(b.id) < 0);
|
||||
|
||||
/// Desktop omits managed-agent records without a name; match its inventory.
|
||||
bool _hasName(NostrEvent event) {
|
||||
/// Whether a managed-agent record holds a policy desktop can apply. This
|
||||
/// mirrors desktop's `ManagedAgentEventContent` parse, plus the non-empty name
|
||||
/// desktop's agent library requires. An unsupported latest record fails
|
||||
/// closed: it excludes the agent rather than falling back to an older one.
|
||||
bool _isSupportedPolicy(NostrEvent event) {
|
||||
final Object? decoded;
|
||||
try {
|
||||
final body = jsonDecode(event.content);
|
||||
return body is Map &&
|
||||
body['name'] is String &&
|
||||
(body['name'] as String).trim().isNotEmpty;
|
||||
} catch (_) {
|
||||
decoded = jsonDecode(event.content);
|
||||
} on FormatException {
|
||||
return false;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return false;
|
||||
final body = decoded;
|
||||
final name = body['name'];
|
||||
final parallelism = body['parallelism'];
|
||||
final allowlist = body['respond_to_allowlist'];
|
||||
return name is String &&
|
||||
name.trim().isNotEmpty &&
|
||||
parallelism is int &&
|
||||
parallelism >= 0 &&
|
||||
parallelism <= 0xFFFFFFFF &&
|
||||
_respondTo.contains(body['respond_to']) &&
|
||||
(!body.containsKey('respond_to_allowlist') ||
|
||||
(allowlist is List && allowlist.every((entry) => entry is String))) &&
|
||||
_optionalPolicyText.every((field) {
|
||||
final value = body[field];
|
||||
return value == null || value is String;
|
||||
});
|
||||
}
|
||||
|
||||
const _respondTo = {'owner-only', 'allowlist', 'anyone'};
|
||||
const _optionalPolicyText = [
|
||||
'persona_id',
|
||||
'system_prompt',
|
||||
'model',
|
||||
'provider',
|
||||
'persona_source_version',
|
||||
];
|
||||
|
||||
@@ -114,7 +114,11 @@ void main() {
|
||||
final inventory = NostrEvent.fromJson(
|
||||
nostr.Event.from(
|
||||
kind: 30177,
|
||||
content: jsonEncode({'name': 'Owned agent'}),
|
||||
content: jsonEncode({
|
||||
'name': 'Owned agent',
|
||||
'parallelism': 1,
|
||||
'respond_to': 'owner-only',
|
||||
}),
|
||||
secretKey: owner.secret,
|
||||
tags: [
|
||||
['d', agent.public],
|
||||
|
||||
@@ -84,12 +84,17 @@ void main() {
|
||||
|
||||
var fakeAgents = 0;
|
||||
String fakeAgent() => (++fakeAgents).toRadixString(16).padLeft(64, 'a');
|
||||
const policy = {
|
||||
'name': 'Agent',
|
||||
'parallelism': 1,
|
||||
'respond_to': 'owner-only',
|
||||
};
|
||||
NostrEvent managed(
|
||||
String d, {
|
||||
nostr.Keys? by,
|
||||
int time = 1,
|
||||
int kind = 30177,
|
||||
Object? content = const {'name': 'Agent'},
|
||||
Object? content = policy,
|
||||
}) => NostrEvent.fromJson(
|
||||
nostr.Event.from(
|
||||
kind: kind,
|
||||
@@ -233,10 +238,10 @@ void main() {
|
||||
final relay = _Relay(
|
||||
inventory: [
|
||||
[
|
||||
managed(renamed.public, time: 3, content: {'name': ' '}),
|
||||
managed(renamed.public, time: 3, content: {...policy, 'name': ' '}),
|
||||
managed(agent.public, time: 2),
|
||||
managed(renamed.public, time: 2),
|
||||
managed(unnamed.public, content: {'name': 7}),
|
||||
managed(unnamed.public, content: {...policy, 'name': 7}),
|
||||
managed(agent.public.toUpperCase()),
|
||||
managed('not-a-pubkey'),
|
||||
],
|
||||
@@ -251,6 +256,74 @@ void main() {
|
||||
expect(choices.map((a) => a.pubkey), [agent.public]);
|
||||
expect(relay.profileAuthors.single, [agent.public]);
|
||||
});
|
||||
for (final (label, content) in [
|
||||
('unsupported respond_to', {...policy, 'respond_to': 'nobody'}),
|
||||
('missing respond_to', {'name': 'Agent', 'parallelism': 1}),
|
||||
('missing parallelism', {'name': 'Agent', 'respond_to': 'anyone'}),
|
||||
('negative parallelism', {...policy, 'parallelism': -1}),
|
||||
('fractional parallelism', {...policy, 'parallelism': 1.5}),
|
||||
('oversized parallelism', {...policy, 'parallelism': 0x100000000}),
|
||||
('non-list allowlist', {...policy, 'respond_to_allowlist': 'x'}),
|
||||
(
|
||||
'non-string allowlist entry',
|
||||
{
|
||||
...policy,
|
||||
'respond_to': 'allowlist',
|
||||
'respond_to_allowlist': [7],
|
||||
},
|
||||
),
|
||||
('null allowlist', {...policy, 'respond_to_allowlist': null}),
|
||||
('non-string model', {...policy, 'model': 7}),
|
||||
('non-object content', ['Agent']),
|
||||
]) {
|
||||
test('an unsupported latest policy fails closed: $label', () async {
|
||||
final relay = _Relay(
|
||||
inventory: [
|
||||
[
|
||||
managed(agent.public, time: 2, content: content),
|
||||
managed(agent.public),
|
||||
],
|
||||
],
|
||||
profiles: [profile(agent, ownedBy: owner)],
|
||||
);
|
||||
final container = setup(relay);
|
||||
expect(await container.read(meAssistantsProvider.future), isEmpty);
|
||||
expect(
|
||||
await isOwnedMeAssistant(container.read(meSyncProvider), agent.public),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
test('accepts every supported policy shape', () async {
|
||||
final shapes = [
|
||||
policy,
|
||||
{...policy, 'respond_to': 'anyone', 'parallelism': 0},
|
||||
{
|
||||
...policy,
|
||||
'respond_to': 'allowlist',
|
||||
'respond_to_allowlist': [other.public],
|
||||
'persona_id': 'scout',
|
||||
'model': null,
|
||||
'future_field': true,
|
||||
},
|
||||
];
|
||||
final agents = [for (final _ in shapes) nostr.Keys.generate()];
|
||||
final relay = _Relay(
|
||||
inventory: [
|
||||
[
|
||||
for (var i = 0; i < shapes.length; i++)
|
||||
managed(agents[i].public, content: shapes[i]),
|
||||
],
|
||||
],
|
||||
profiles: [for (final key in agents) profile(key, ownedBy: owner)],
|
||||
);
|
||||
expect(
|
||||
(await setup(
|
||||
relay,
|
||||
).read(meAssistantsProvider.future)).map((a) => a.pubkey),
|
||||
unorderedEquals(agents.map((key) => key.public)),
|
||||
);
|
||||
});
|
||||
test('reads beyond the first inventory page with a cursor', () async {
|
||||
final first = fullPage;
|
||||
final relay = _Relay(
|
||||
@@ -320,7 +393,11 @@ void main() {
|
||||
});
|
||||
test('same-second inventory records keep the lowest id', () async {
|
||||
final named = managed(agent.public, time: 5);
|
||||
final unnamed = managed(agent.public, time: 5, content: {'name': ''});
|
||||
final unnamed = managed(
|
||||
agent.public,
|
||||
time: 5,
|
||||
content: {...policy, 'name': ''},
|
||||
);
|
||||
final relay = _Relay(
|
||||
inventory: [
|
||||
[named, unnamed],
|
||||
|
||||
Reference in New Issue
Block a user