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:
Tom Brow
2026-10-26 21:58:50 -07:00
parent be269eb340
commit e914dfd625
3 changed files with 121 additions and 13 deletions

View File

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

View File

@@ -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],

View File

@@ -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],