agent-protocols 0.4.1__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/PKG-INFO +3 -3
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/README.md +2 -2
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/pyproject.toml +1 -1
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/__init__.py +13 -1
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/delegation.py +26 -2
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/discourse.py +155 -3
- agent_protocols-0.5.0/src/agent_protocols/errors.py +6 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/http_client.py +34 -4
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/identity.py +44 -9
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols/profile.py +10 -1
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/PKG-INFO +3 -3
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_delegation.py +42 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_discourse.py +114 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_identity.py +83 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_profile.py +28 -4
- agent_protocols-0.4.1/src/agent_protocols/errors.py +0 -4
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/setup.cfg +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/requires.txt +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/top_level.txt +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_discourse_coverage.py +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_http_client.py +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_identity_coverage.py +0 -0
- {agent_protocols-0.4.1 → agent_protocols-0.5.0}/tests/test_profile_coverage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
@@ -42,6 +42,6 @@ envelope = signer.sign_event(event)
|
|
|
42
42
|
profile = materialize_profile(envelope)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
`username`
|
|
45
|
+
Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
|
|
46
46
|
|
|
47
|
-
ADP room writes
|
|
47
|
+
ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
|
|
@@ -27,6 +27,6 @@ envelope = signer.sign_event(event)
|
|
|
27
27
|
profile = materialize_profile(envelope)
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
`username`
|
|
30
|
+
Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
|
|
31
31
|
|
|
32
|
-
ADP room writes
|
|
32
|
+
ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
|
|
@@ -13,6 +13,7 @@ from .identity import (
|
|
|
13
13
|
event_hash,
|
|
14
14
|
event_hash_bytes,
|
|
15
15
|
public_key_bytes,
|
|
16
|
+
service_origin,
|
|
16
17
|
sign_event,
|
|
17
18
|
sign_event_hash,
|
|
18
19
|
unix_ms,
|
|
@@ -31,7 +32,14 @@ from .identity import (
|
|
|
31
32
|
with_room_head,
|
|
32
33
|
with_room_id,
|
|
33
34
|
)
|
|
34
|
-
from .profile import
|
|
35
|
+
from .profile import (
|
|
36
|
+
PROFILE_PROTOCOL,
|
|
37
|
+
PROFILE_UPDATE,
|
|
38
|
+
latest_profile_update,
|
|
39
|
+
materialize_profile,
|
|
40
|
+
profile_update_event,
|
|
41
|
+
validate_profile_update,
|
|
42
|
+
)
|
|
35
43
|
from .delegation import (
|
|
36
44
|
DELEGATION_GRANT,
|
|
37
45
|
DELEGATION_PROTOCOL,
|
|
@@ -41,6 +49,7 @@ from .delegation import (
|
|
|
41
49
|
materialize_delegation_credential,
|
|
42
50
|
validate_delegation_envelope,
|
|
43
51
|
validate_delegation_grant_payload,
|
|
52
|
+
validate_delegation_query_request,
|
|
44
53
|
validate_delegation_revoke_payload,
|
|
45
54
|
validate_principal_document,
|
|
46
55
|
)
|
|
@@ -66,10 +75,12 @@ __all__ = [
|
|
|
66
75
|
"create_request_jwt_claims",
|
|
67
76
|
"event_hash",
|
|
68
77
|
"event_hash_bytes",
|
|
78
|
+
"latest_profile_update",
|
|
69
79
|
"materialize_profile",
|
|
70
80
|
"materialize_delegation_credential",
|
|
71
81
|
"profile_update_event",
|
|
72
82
|
"public_key_bytes",
|
|
83
|
+
"service_origin",
|
|
73
84
|
"sign_event",
|
|
74
85
|
"sign_event_hash",
|
|
75
86
|
"unix_ms",
|
|
@@ -77,6 +88,7 @@ __all__ = [
|
|
|
77
88
|
"validate_agent_id",
|
|
78
89
|
"validate_delegation_envelope",
|
|
79
90
|
"validate_delegation_grant_payload",
|
|
91
|
+
"validate_delegation_query_request",
|
|
80
92
|
"validate_delegation_revoke_payload",
|
|
81
93
|
"validate_nonce",
|
|
82
94
|
"validate_principal_document",
|
|
@@ -65,12 +65,36 @@ def validate_delegation_grant_payload(
|
|
|
65
65
|
raise AgentProtocolError("invalid_delegation", "constraints must be an object")
|
|
66
66
|
expires_at = payload.get("expires_at")
|
|
67
67
|
if expires_at is not None:
|
|
68
|
-
not_before = payload.get("not_before"
|
|
68
|
+
not_before = payload.get("not_before")
|
|
69
69
|
if not_before is not None and expires_at <= not_before:
|
|
70
70
|
raise AgentProtocolError(
|
|
71
71
|
"invalid_delegation",
|
|
72
|
-
"expires_at must be greater than not_before
|
|
72
|
+
"expires_at must be greater than not_before",
|
|
73
73
|
)
|
|
74
|
+
if created_at is not None and expires_at <= created_at:
|
|
75
|
+
raise AgentProtocolError(
|
|
76
|
+
"invalid_delegation",
|
|
77
|
+
"expires_at must be greater than created_at",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_delegation_query_request(request: dict[str, Any]) -> None:
|
|
82
|
+
"""A delegation query MUST include at least one of `subject` or
|
|
83
|
+
`principal_id`. `limit` defaults to 20; services SHOULD cap it at 100."""
|
|
84
|
+
subject = request.get("subject")
|
|
85
|
+
principal_id = request.get("principal_id")
|
|
86
|
+
if subject is None and principal_id is None:
|
|
87
|
+
raise AgentProtocolError(
|
|
88
|
+
"invalid_delegation",
|
|
89
|
+
"query must include at least one of subject or principal_id",
|
|
90
|
+
)
|
|
91
|
+
if subject is not None:
|
|
92
|
+
validate_agent_id(subject)
|
|
93
|
+
if principal_id is not None:
|
|
94
|
+
_validate_https_url(principal_id, "principal_id")
|
|
95
|
+
limit = request.get("limit")
|
|
96
|
+
if limit is not None and (not isinstance(limit, int) or limit < 1):
|
|
97
|
+
raise AgentProtocolError("invalid_delegation", "limit must be a positive integer")
|
|
74
98
|
|
|
75
99
|
|
|
76
100
|
def validate_delegation_revoke_payload(payload: DelegationRevokePayload) -> None:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Agent Discourse Protocol 1.0: kernel types, the room type system, and
|
|
2
2
|
verification helpers.
|
|
3
3
|
|
|
4
|
-
The protocol defines
|
|
4
|
+
The protocol defines eleven built-in event types. Every other event type is
|
|
5
5
|
declared per room as a schema-validated type definition, either inline or
|
|
6
6
|
imported from a type pack. Hosts validate structure and permissions; they
|
|
7
7
|
never need to understand application semantics.
|
|
@@ -32,12 +32,14 @@ from .identity import (
|
|
|
32
32
|
|
|
33
33
|
DISCOURSE_PROTOCOL = "agent-discourse/1.0"
|
|
34
34
|
|
|
35
|
-
# The
|
|
35
|
+
# The eleven built-in event types. All other types are room-defined.
|
|
36
36
|
ROOM_CREATE = "room.create"
|
|
37
|
+
ROOM_UPDATE = "room.update"
|
|
37
38
|
ROOM_JOIN = "room.join"
|
|
38
39
|
ROOM_JOIN_REVIEW = "room.join.review"
|
|
39
40
|
ROOM_LEAVE = "room.leave"
|
|
40
41
|
ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
|
|
42
|
+
ROOM_MEMBER_REMOVE = "room.member.remove"
|
|
41
43
|
ROOM_CLOSE = "room.close"
|
|
42
44
|
ROOM_CANCEL = "room.cancel"
|
|
43
45
|
TYPE_DEFINE = "type.define"
|
|
@@ -45,16 +47,67 @@ MESSAGE_CREATE = "message.create"
|
|
|
45
47
|
|
|
46
48
|
BUILTIN_EVENT_TYPES = {
|
|
47
49
|
ROOM_CREATE,
|
|
50
|
+
ROOM_UPDATE,
|
|
48
51
|
ROOM_JOIN,
|
|
49
52
|
ROOM_JOIN_REVIEW,
|
|
50
53
|
ROOM_LEAVE,
|
|
51
54
|
ROOM_MEMBER_ROLE_UPDATE,
|
|
55
|
+
ROOM_MEMBER_REMOVE,
|
|
52
56
|
ROOM_CLOSE,
|
|
53
57
|
ROOM_CANCEL,
|
|
54
58
|
TYPE_DEFINE,
|
|
55
59
|
MESSAGE_CREATE,
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
# Built-in membership events. They carry the `signal` class: they anchor to
|
|
63
|
+
# an accepted record but never contend for or advance the room head, so busy
|
|
64
|
+
# rooms cannot starve joins, reviews, or other membership writes.
|
|
65
|
+
MEMBERSHIP_EVENT_TYPES = (
|
|
66
|
+
ROOM_JOIN,
|
|
67
|
+
ROOM_JOIN_REVIEW,
|
|
68
|
+
ROOM_LEAVE,
|
|
69
|
+
ROOM_MEMBER_ROLE_UPDATE,
|
|
70
|
+
ROOM_MEMBER_REMOVE,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Standard ADP error codes from the Section 20 table.
|
|
74
|
+
DISCOURSE_ERROR_CODES = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"invalid_event",
|
|
77
|
+
"invalid_event_hash",
|
|
78
|
+
"invalid_signature",
|
|
79
|
+
"invalid_actor",
|
|
80
|
+
"timestamp_out_of_window",
|
|
81
|
+
"nonce_not_greater",
|
|
82
|
+
"room_not_found",
|
|
83
|
+
"room_not_active",
|
|
84
|
+
"room_ended",
|
|
85
|
+
"permission_denied",
|
|
86
|
+
"approval_required",
|
|
87
|
+
"join_request_not_found",
|
|
88
|
+
"join_request_not_approved",
|
|
89
|
+
"join_request_role_mismatch",
|
|
90
|
+
"join_request_expired",
|
|
91
|
+
"member_banned",
|
|
92
|
+
"role_not_allowed",
|
|
93
|
+
"max_speakers_exceeded",
|
|
94
|
+
"membership_required",
|
|
95
|
+
"invalid_token",
|
|
96
|
+
"room_head_mismatch",
|
|
97
|
+
"base_record_mismatch",
|
|
98
|
+
"agent_status_not_found",
|
|
99
|
+
"rate_limited",
|
|
100
|
+
"payload_too_large",
|
|
101
|
+
"type_not_defined",
|
|
102
|
+
"type_disabled",
|
|
103
|
+
"payload_schema_violation",
|
|
104
|
+
"pack_unavailable",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Hosts MUST reject events with more than this many `mentions` entries.
|
|
109
|
+
MAX_MENTIONS = 32
|
|
110
|
+
|
|
58
111
|
# Custom event types must not use these prefixes.
|
|
59
112
|
RESERVED_TYPE_PREFIXES = ("room.", "type.")
|
|
60
113
|
|
|
@@ -171,6 +224,42 @@ def event_requires_room_id(event_type: str) -> bool:
|
|
|
171
224
|
return event_type != ROOM_CREATE
|
|
172
225
|
|
|
173
226
|
|
|
227
|
+
BuiltinEventClass = Literal["lifecycle", "signal", "control", "message"]
|
|
228
|
+
|
|
229
|
+
_BUILTIN_EVENT_CLASSES: dict[str, BuiltinEventClass] = {
|
|
230
|
+
ROOM_CREATE: "lifecycle",
|
|
231
|
+
ROOM_UPDATE: "lifecycle",
|
|
232
|
+
ROOM_CLOSE: "lifecycle",
|
|
233
|
+
ROOM_CANCEL: "lifecycle",
|
|
234
|
+
ROOM_JOIN: "signal",
|
|
235
|
+
ROOM_JOIN_REVIEW: "signal",
|
|
236
|
+
ROOM_LEAVE: "signal",
|
|
237
|
+
ROOM_MEMBER_ROLE_UPDATE: "signal",
|
|
238
|
+
ROOM_MEMBER_REMOVE: "signal",
|
|
239
|
+
TYPE_DEFINE: "control",
|
|
240
|
+
MESSAGE_CREATE: "message",
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def builtin_event_class(event_type: str) -> BuiltinEventClass | None:
|
|
245
|
+
"""Section 13.2 class of a built-in type; ``None`` for room-defined types."""
|
|
246
|
+
return _BUILTIN_EVENT_CLASSES.get(event_type)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def event_advances_room_head(event_type: str, registry: "TypeRegistry | None" = None) -> bool:
|
|
250
|
+
"""Whether an accepted record of this type advances the room head and must
|
|
251
|
+
therefore match the current head when written (Section 6.1). Room
|
|
252
|
+
lifecycle, `message`-kind, and `control`-kind records advance the head;
|
|
253
|
+
`signal`-kind records — including the built-in membership events — only
|
|
254
|
+
anchor to an accepted record. Unknown custom types default to
|
|
255
|
+
head-advancing."""
|
|
256
|
+
builtin_class = builtin_event_class(event_type)
|
|
257
|
+
if builtin_class is not None:
|
|
258
|
+
return builtin_class != "signal"
|
|
259
|
+
definition = registry.get(event_type) if registry is not None else None
|
|
260
|
+
return definition is None or definition.get("kind") != "signal"
|
|
261
|
+
|
|
262
|
+
|
|
174
263
|
def validate_discourse_envelope(envelope: Envelope) -> None:
|
|
175
264
|
verify_envelope(envelope)
|
|
176
265
|
event = envelope["event"]
|
|
@@ -222,8 +311,17 @@ def _validate_mentions(mentions: Any) -> None:
|
|
|
222
311
|
return
|
|
223
312
|
if not isinstance(mentions, list):
|
|
224
313
|
raise AgentProtocolError("invalid_event", "mentions must be an Agent ID array")
|
|
314
|
+
if len(mentions) > MAX_MENTIONS:
|
|
315
|
+
raise AgentProtocolError("invalid_event", f"mentions must not exceed {MAX_MENTIONS} entries")
|
|
316
|
+
# Validate each entry before testing uniqueness: a non-string mention would
|
|
317
|
+
# otherwise raise a raw TypeError from set() instead of a clean protocol
|
|
318
|
+
# error.
|
|
319
|
+
seen: set[str] = set()
|
|
225
320
|
for mention in mentions:
|
|
226
321
|
validate_agent_id(mention)
|
|
322
|
+
if mention in seen:
|
|
323
|
+
raise AgentProtocolError("invalid_event", "mentions must be unique")
|
|
324
|
+
seen.add(mention)
|
|
227
325
|
|
|
228
326
|
|
|
229
327
|
def validate_custom_event_type_name(name: str) -> None:
|
|
@@ -334,6 +432,12 @@ class TypeRegistry:
|
|
|
334
432
|
|
|
335
433
|
def define(self, definition: dict[str, Any]) -> None:
|
|
336
434
|
validate_type_def(definition)
|
|
435
|
+
existing = self._types.get(definition["type"])
|
|
436
|
+
if existing is not None and existing.get("kind") != definition.get("kind"):
|
|
437
|
+
raise AgentProtocolError(
|
|
438
|
+
"invalid_event",
|
|
439
|
+
f"type {definition['type']} cannot change kind on redefinition",
|
|
440
|
+
)
|
|
337
441
|
self._types[definition["type"]] = definition
|
|
338
442
|
|
|
339
443
|
def _import(self, declaration: dict[str, Any], packs: dict[str, dict[str, Any]]) -> None:
|
|
@@ -452,6 +556,44 @@ def validate_room_join_payload(payload: dict[str, Any]) -> None:
|
|
|
452
556
|
)
|
|
453
557
|
|
|
454
558
|
|
|
559
|
+
_ROOM_UPDATE_FIELDS = frozenset(
|
|
560
|
+
{"topic", "agenda", "guidance", "tags", "language", "policy", "start_time", "end_time"}
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def validate_room_update_payload(payload: dict[str, Any]) -> None:
|
|
565
|
+
"""Shape checks for a `room.update` payload. State-dependent rules — room
|
|
566
|
+
status, effective time ordering against the current contract — remain
|
|
567
|
+
host-side."""
|
|
568
|
+
if not payload:
|
|
569
|
+
raise AgentProtocolError("invalid_event", "room.update payload must not be empty")
|
|
570
|
+
for field in payload:
|
|
571
|
+
if field not in _ROOM_UPDATE_FIELDS:
|
|
572
|
+
raise AgentProtocolError(
|
|
573
|
+
"invalid_event", f"room.update payload field {field} is not updatable"
|
|
574
|
+
)
|
|
575
|
+
topic = payload.get("topic")
|
|
576
|
+
if topic is not None and not str(topic).strip():
|
|
577
|
+
raise AgentProtocolError("invalid_event", "room topic must not be empty")
|
|
578
|
+
start_time = payload.get("start_time")
|
|
579
|
+
end_time = payload.get("end_time")
|
|
580
|
+
if start_time is not None and end_time is not None and start_time >= end_time:
|
|
581
|
+
raise AgentProtocolError("invalid_event", "start_time must be before end_time")
|
|
582
|
+
policy = payload.get("policy") or {}
|
|
583
|
+
max_speakers = policy.get("max_speakers")
|
|
584
|
+
if max_speakers is not None and (not isinstance(max_speakers, int) or max_speakers < 1):
|
|
585
|
+
raise AgentProtocolError("invalid_event", "max_speakers must be a positive integer")
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def validate_room_member_remove_payload(payload: dict[str, Any]) -> None:
|
|
589
|
+
"""Shape checks for a `room.member.remove` payload. Creator, self, and
|
|
590
|
+
membership checks remain host-side."""
|
|
591
|
+
validate_agent_id(payload.get("member"))
|
|
592
|
+
ban = payload.get("ban")
|
|
593
|
+
if ban is not None and not isinstance(ban, bool):
|
|
594
|
+
raise AgentProtocolError("invalid_event", "ban must be a boolean")
|
|
595
|
+
|
|
596
|
+
|
|
455
597
|
def server_record_hash_payload(
|
|
456
598
|
room_id: str,
|
|
457
599
|
seq: int,
|
|
@@ -554,7 +696,15 @@ def can_submit_event(
|
|
|
554
696
|
return bool(context.get("public_join_allowed") or context.get("join_request_approved"))
|
|
555
697
|
if event_type == ROOM_LEAVE:
|
|
556
698
|
return is_creator or role is not None
|
|
557
|
-
if event_type in {
|
|
699
|
+
if event_type in {
|
|
700
|
+
ROOM_UPDATE,
|
|
701
|
+
ROOM_JOIN_REVIEW,
|
|
702
|
+
ROOM_MEMBER_ROLE_UPDATE,
|
|
703
|
+
ROOM_MEMBER_REMOVE,
|
|
704
|
+
ROOM_CLOSE,
|
|
705
|
+
ROOM_CANCEL,
|
|
706
|
+
TYPE_DEFINE,
|
|
707
|
+
}:
|
|
558
708
|
return is_creator or role == "moderator"
|
|
559
709
|
if event_type == MESSAGE_CREATE:
|
|
560
710
|
return is_creator or role in ("moderator", "speaker")
|
|
@@ -576,7 +726,9 @@ def can_write_in_state(event_type: str, state: RoomState) -> bool:
|
|
|
576
726
|
ROOM_JOIN,
|
|
577
727
|
ROOM_JOIN_REVIEW,
|
|
578
728
|
ROOM_MEMBER_ROLE_UPDATE,
|
|
729
|
+
ROOM_MEMBER_REMOVE,
|
|
579
730
|
ROOM_LEAVE,
|
|
731
|
+
ROOM_UPDATE,
|
|
580
732
|
TYPE_DEFINE,
|
|
581
733
|
ROOM_CANCEL,
|
|
582
734
|
}
|
|
@@ -8,6 +8,7 @@ try:
|
|
|
8
8
|
except ImportError: # pragma: no cover
|
|
9
9
|
requests = None # type: ignore[assignment]
|
|
10
10
|
|
|
11
|
+
from .delegation import validate_delegation_query_request
|
|
11
12
|
from .identity import AgentId, Envelope
|
|
12
13
|
|
|
13
14
|
|
|
@@ -22,8 +23,14 @@ class ProfileClient:
|
|
|
22
23
|
def get_profiles(self, agent_ids: list[AgentId]) -> dict[str, Any]:
|
|
23
24
|
return self._post("/v1/profiles/batch", {"ids": agent_ids})
|
|
24
25
|
|
|
25
|
-
def profile_events(
|
|
26
|
-
|
|
26
|
+
def profile_events(
|
|
27
|
+
self, agent_id: AgentId, limit: int = 1, cursor: str | None = None
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
query = urlencode(
|
|
30
|
+
{key: value for key, value in {"limit": limit, "cursor": cursor}.items() if value is not None},
|
|
31
|
+
quote_via=quote,
|
|
32
|
+
)
|
|
33
|
+
return self._get(f"/v1/profiles/{agent_id}/events?{query}")
|
|
27
34
|
|
|
28
35
|
def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
|
|
29
36
|
return self._post("/v1/profiles", envelope)
|
|
@@ -87,8 +94,30 @@ class DiscourseClient:
|
|
|
87
94
|
suffix = f"?{query}" if query else ""
|
|
88
95
|
return self._get(f"/v1/rooms/public{suffix}")
|
|
89
96
|
|
|
90
|
-
def my_rooms(
|
|
91
|
-
|
|
97
|
+
def my_rooms(
|
|
98
|
+
self,
|
|
99
|
+
jwt: str,
|
|
100
|
+
*,
|
|
101
|
+
status: str | None = None,
|
|
102
|
+
membership: str | None = None,
|
|
103
|
+
limit: int | None = None,
|
|
104
|
+
cursor: str | None = None,
|
|
105
|
+
) -> list[dict[str, Any]]:
|
|
106
|
+
query = urlencode(
|
|
107
|
+
{
|
|
108
|
+
key: value
|
|
109
|
+
for key, value in {
|
|
110
|
+
"status": status,
|
|
111
|
+
"membership": membership,
|
|
112
|
+
"limit": limit,
|
|
113
|
+
"cursor": cursor,
|
|
114
|
+
}.items()
|
|
115
|
+
if value is not None
|
|
116
|
+
},
|
|
117
|
+
quote_via=quote,
|
|
118
|
+
)
|
|
119
|
+
suffix = f"?{query}" if query else ""
|
|
120
|
+
return self._get(f"/v1/me/rooms{suffix}", jwt=jwt)
|
|
92
121
|
|
|
93
122
|
def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
94
123
|
return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
|
|
@@ -192,6 +221,7 @@ class DelegationClient:
|
|
|
192
221
|
return self._post("/v1/delegations", envelope)
|
|
193
222
|
|
|
194
223
|
def query_delegations(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
validate_delegation_query_request(request)
|
|
195
225
|
return self._post("/v1/delegations/query", request)
|
|
196
226
|
|
|
197
227
|
def _get(self, path: str) -> Any:
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import re
|
|
7
7
|
import time
|
|
8
8
|
from dataclasses import dataclass
|
|
9
|
+
from urllib.parse import urlparse
|
|
9
10
|
from typing import Any, MutableMapping, Protocol
|
|
10
11
|
|
|
11
12
|
import rfc8785
|
|
@@ -34,9 +35,11 @@ def agent_id_from_public_key(public_key: bytes) -> AgentId:
|
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
def public_key_bytes(agent_id: AgentId) -> bytes:
|
|
38
|
+
if not isinstance(agent_id, str):
|
|
39
|
+
raise AgentProtocolError("invalid_agent_id", "agent id must be a string")
|
|
37
40
|
if not agent_id.startswith(AGENT_ID_PREFIX):
|
|
38
41
|
raise AgentProtocolError("invalid_agent_id", "agent id must start with did:agent:")
|
|
39
|
-
data =
|
|
42
|
+
data = _base64url_decode(agent_id[len(AGENT_ID_PREFIX):])
|
|
40
43
|
if len(data) != 32:
|
|
41
44
|
raise AgentProtocolError("invalid_public_key", f"agent id public key must be 32 bytes, got {len(data)}")
|
|
42
45
|
return data
|
|
@@ -114,7 +117,14 @@ class MemoryNonceStore:
|
|
|
114
117
|
if record is not None:
|
|
115
118
|
max_nonce, expires_at = record
|
|
116
119
|
if expires_at > now_ms and nonce <= max_nonce:
|
|
117
|
-
|
|
120
|
+
# Services rejecting for this reason MUST return the effective
|
|
121
|
+
# maximum in the `Max-Seen-Nonce` response header; `data`
|
|
122
|
+
# carries it.
|
|
123
|
+
raise AgentProtocolError(
|
|
124
|
+
"nonce_not_greater",
|
|
125
|
+
f"nonce must be greater than accepted max nonce {max_nonce}",
|
|
126
|
+
data={"max_nonce": max_nonce},
|
|
127
|
+
)
|
|
118
128
|
self._records[actor] = (nonce, now_ms + ttl_ms)
|
|
119
129
|
return nonce
|
|
120
130
|
|
|
@@ -215,6 +225,11 @@ def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
|
|
|
215
225
|
|
|
216
226
|
|
|
217
227
|
def sign_event_hash(private_key: Ed25519PrivateKey, event_hash: bytes) -> str:
|
|
228
|
+
"""Signs a precomputed 32-byte event hash. Signing a digest supplied by
|
|
229
|
+
another component without seeing the event it commits to (blind signing)
|
|
230
|
+
is NOT RECOMMENDED: ``actor`` and all event content are inside the digest,
|
|
231
|
+
so a blind signer can be tricked into signing arbitrary events attributed
|
|
232
|
+
to its key. Prefer :func:`sign_event`."""
|
|
218
233
|
return _base64url_encode(private_key.sign(_valid_event_hash_bytes(event_hash)))
|
|
219
234
|
|
|
220
235
|
|
|
@@ -302,6 +317,20 @@ def verify_request_jwt(token: str, *, audience: str, now_secs: int | None = None
|
|
|
302
317
|
return claims
|
|
303
318
|
|
|
304
319
|
|
|
320
|
+
def service_origin(url: str) -> str:
|
|
321
|
+
"""Derives the request JWT ``aud`` from a request URL: the service origin —
|
|
322
|
+
scheme, host, and non-default port, with no path (Agent Identity Section 8)."""
|
|
323
|
+
parsed = urlparse(url)
|
|
324
|
+
if parsed.scheme not in ("https", "http") or not parsed.hostname:
|
|
325
|
+
raise AgentProtocolError("invalid_url", f"not an HTTP(S) URL: {url}")
|
|
326
|
+
host = parsed.hostname
|
|
327
|
+
port = parsed.port
|
|
328
|
+
default_port = 443 if parsed.scheme == "https" else 80
|
|
329
|
+
if port is None or port == default_port:
|
|
330
|
+
return f"{parsed.scheme}://{host}"
|
|
331
|
+
return f"{parsed.scheme}://{host}:{port}"
|
|
332
|
+
|
|
333
|
+
|
|
305
334
|
def unix_ms() -> int:
|
|
306
335
|
return int(time.time() * 1000)
|
|
307
336
|
|
|
@@ -320,14 +349,20 @@ def _base64url_encode(data: bytes) -> str:
|
|
|
320
349
|
|
|
321
350
|
|
|
322
351
|
def _base64url_decode(value: str) -> bytes:
|
|
352
|
+
"""Canonical base64url decoding: URL-safe alphabet, no padding, zero
|
|
353
|
+
trailing bits. Receivers MUST reject non-canonical encodings, otherwise
|
|
354
|
+
one value gains multiple distinct string forms and corrupts string-keyed
|
|
355
|
+
comparisons."""
|
|
356
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]*", value):
|
|
357
|
+
raise AgentProtocolError("invalid_encoding", "expected canonical base64url without padding")
|
|
323
358
|
padding = "=" * ((4 - len(value) % 4) % 4)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
if
|
|
329
|
-
raise AgentProtocolError("invalid_encoding", "expected base64url without padding")
|
|
330
|
-
return
|
|
359
|
+
try:
|
|
360
|
+
data = base64.urlsafe_b64decode(value + padding)
|
|
361
|
+
except (ValueError, TypeError) as exc:
|
|
362
|
+
raise AgentProtocolError("invalid_encoding", "expected canonical base64url without padding") from exc
|
|
363
|
+
if _base64url_encode(data) != value:
|
|
364
|
+
raise AgentProtocolError("invalid_encoding", "expected canonical base64url without padding")
|
|
365
|
+
return data
|
|
331
366
|
|
|
332
367
|
|
|
333
368
|
def _valid_event_hash_bytes(event_hash: bytes) -> bytes:
|
|
@@ -35,7 +35,6 @@ def materialize_profile(envelope: Envelope) -> AgentProfile:
|
|
|
35
35
|
return {
|
|
36
36
|
"id": payload_id,
|
|
37
37
|
"name": payload["name"],
|
|
38
|
-
"username": None,
|
|
39
38
|
"description": payload.get("description"),
|
|
40
39
|
"avatar_url": payload.get("avatar_url"),
|
|
41
40
|
"provider": payload.get("provider"),
|
|
@@ -47,3 +46,13 @@ def materialize_profile(envelope: Envelope) -> AgentProfile:
|
|
|
47
46
|
"updated_at": envelope["event"]["created_at"],
|
|
48
47
|
"event_id": envelope["hash"],
|
|
49
48
|
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def latest_profile_update(envelopes: list[Envelope]) -> Envelope | None:
|
|
52
|
+
"""Selects the latest profile state from accepted update envelopes. Nonces
|
|
53
|
+
are strictly monotonic per Agent ID, so the latest profile is defined as
|
|
54
|
+
the accepted `profile.update` with the greatest `nonce` — deterministic
|
|
55
|
+
and independently checkable from event history alone."""
|
|
56
|
+
if not envelopes:
|
|
57
|
+
return None
|
|
58
|
+
return max(envelopes, key=lambda envelope: envelope["event"]["nonce"])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
@@ -42,6 +42,6 @@ envelope = signer.sign_event(event)
|
|
|
42
42
|
profile = materialize_profile(envelope)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
`username`
|
|
45
|
+
Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
|
|
46
46
|
|
|
47
|
-
ADP room writes
|
|
47
|
+
ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
|
|
@@ -122,3 +122,45 @@ class DelegationTests(unittest.TestCase):
|
|
|
122
122
|
|
|
123
123
|
if __name__ == "__main__":
|
|
124
124
|
unittest.main()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Revision20260704DelegationTests(unittest.TestCase):
|
|
128
|
+
def test_grant_expiry_checked_against_not_before_and_created_at(self):
|
|
129
|
+
from agent_protocols.delegation import validate_delegation_grant_payload
|
|
130
|
+
from agent_protocols.errors import AgentProtocolError
|
|
131
|
+
from agent_protocols.identity import AgentSigner
|
|
132
|
+
|
|
133
|
+
subject = AgentSigner.from_seed(bytes([46]) * 32).agent_id()
|
|
134
|
+
base = {
|
|
135
|
+
"id": "del_1",
|
|
136
|
+
"principal": {"id": "https://al.ink/yan"},
|
|
137
|
+
"subject": subject,
|
|
138
|
+
"scopes": ["inbox.screen"],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
with self.assertRaisesRegex(AgentProtocolError, "greater than not_before"):
|
|
142
|
+
validate_delegation_grant_payload(
|
|
143
|
+
{**base, "not_before": 2000, "expires_at": 2000}, 1000
|
|
144
|
+
)
|
|
145
|
+
with self.assertRaisesRegex(AgentProtocolError, "greater than created_at"):
|
|
146
|
+
validate_delegation_grant_payload({**base, "expires_at": 1000}, 1000)
|
|
147
|
+
with self.assertRaisesRegex(AgentProtocolError, "greater than created_at"):
|
|
148
|
+
validate_delegation_grant_payload(
|
|
149
|
+
{**base, "not_before": 500, "expires_at": 800}, 1000
|
|
150
|
+
)
|
|
151
|
+
validate_delegation_grant_payload(
|
|
152
|
+
{**base, "not_before": 500, "expires_at": 1500}, 1000
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def test_delegation_queries_require_subject_or_principal(self):
|
|
156
|
+
from agent_protocols.delegation import validate_delegation_query_request
|
|
157
|
+
from agent_protocols.errors import AgentProtocolError
|
|
158
|
+
from agent_protocols.identity import AgentSigner
|
|
159
|
+
|
|
160
|
+
subject = AgentSigner.from_seed(bytes([47]) * 32).agent_id()
|
|
161
|
+
validate_delegation_query_request({"subject": subject})
|
|
162
|
+
validate_delegation_query_request({"principal_id": "https://al.ink/yan", "limit": 20})
|
|
163
|
+
with self.assertRaisesRegex(AgentProtocolError, "at least one of subject or principal_id"):
|
|
164
|
+
validate_delegation_query_request({"status": "active"})
|
|
165
|
+
with self.assertRaisesRegex(AgentProtocolError, "positive integer"):
|
|
166
|
+
validate_delegation_query_request({"subject": subject, "limit": 0})
|
|
@@ -27,6 +27,7 @@ from agent_protocols.discourse import (
|
|
|
27
27
|
can_accept_room_write,
|
|
28
28
|
can_submit_event,
|
|
29
29
|
can_write_in_state,
|
|
30
|
+
discourse_event,
|
|
30
31
|
pack_map,
|
|
31
32
|
room_create_event,
|
|
32
33
|
server_record_hash,
|
|
@@ -41,6 +42,7 @@ from agent_protocols.discourse import (
|
|
|
41
42
|
verify_server_record,
|
|
42
43
|
verify_server_record_chain,
|
|
43
44
|
)
|
|
45
|
+
from agent_protocols import discourse
|
|
44
46
|
from agent_protocols.errors import AgentProtocolError
|
|
45
47
|
from agent_protocols.http_client import sse_events_url
|
|
46
48
|
from agent_protocols.identity import AgentSigner, create_event
|
|
@@ -401,6 +403,118 @@ class DiscourseTests(unittest.TestCase):
|
|
|
401
403
|
"https://api.example.com/v1/rooms/room123/events/live",
|
|
402
404
|
)
|
|
403
405
|
|
|
406
|
+
class Revision20260704Tests(unittest.TestCase):
|
|
407
|
+
def test_kernel_defines_eleven_builtins_with_membership_signals(self):
|
|
408
|
+
self.assertEqual(len(discourse.BUILTIN_EVENT_TYPES), 11)
|
|
409
|
+
self.assertIn("room.update", discourse.BUILTIN_EVENT_TYPES)
|
|
410
|
+
self.assertIn("room.member.remove", discourse.BUILTIN_EVENT_TYPES)
|
|
411
|
+
|
|
412
|
+
for membership in discourse.MEMBERSHIP_EVENT_TYPES:
|
|
413
|
+
self.assertEqual(discourse.builtin_event_class(membership), "signal")
|
|
414
|
+
self.assertFalse(discourse.event_advances_room_head(membership))
|
|
415
|
+
for advancing in (
|
|
416
|
+
discourse.ROOM_CREATE,
|
|
417
|
+
discourse.ROOM_UPDATE,
|
|
418
|
+
discourse.ROOM_CLOSE,
|
|
419
|
+
discourse.ROOM_CANCEL,
|
|
420
|
+
discourse.TYPE_DEFINE,
|
|
421
|
+
discourse.MESSAGE_CREATE,
|
|
422
|
+
):
|
|
423
|
+
self.assertTrue(discourse.event_advances_room_head(advancing))
|
|
424
|
+
self.assertIsNone(discourse.builtin_event_class("review.finding"))
|
|
425
|
+
|
|
426
|
+
registry = discourse.TypeRegistry()
|
|
427
|
+
registry.define(
|
|
428
|
+
{
|
|
429
|
+
"type": "reaction.create",
|
|
430
|
+
"kind": "signal",
|
|
431
|
+
"title": "Reaction",
|
|
432
|
+
"schema": {"type": "object"},
|
|
433
|
+
}
|
|
434
|
+
)
|
|
435
|
+
self.assertFalse(discourse.event_advances_room_head("reaction.create", registry))
|
|
436
|
+
self.assertTrue(discourse.event_advances_room_head("unknown.type", registry))
|
|
437
|
+
|
|
438
|
+
self.assertIn("member_banned", discourse.DISCOURSE_ERROR_CODES)
|
|
439
|
+
self.assertIn("role_not_allowed", discourse.DISCOURSE_ERROR_CODES)
|
|
440
|
+
self.assertIn("max_speakers_exceeded", discourse.DISCOURSE_ERROR_CODES)
|
|
441
|
+
|
|
442
|
+
def test_room_update_and_member_remove_follow_moderator_rules(self):
|
|
443
|
+
for builtin in (discourse.ROOM_UPDATE, discourse.ROOM_MEMBER_REMOVE):
|
|
444
|
+
self.assertTrue(discourse.can_submit_event(builtin, {"role": "moderator"}))
|
|
445
|
+
self.assertTrue(discourse.can_submit_event(builtin, {"is_creator": True}))
|
|
446
|
+
self.assertFalse(discourse.can_submit_event(builtin, {"role": "speaker"}))
|
|
447
|
+
self.assertTrue(discourse.can_write_in_state(builtin, "scheduled"))
|
|
448
|
+
self.assertTrue(discourse.can_write_in_state(builtin, "active"))
|
|
449
|
+
self.assertFalse(discourse.can_write_in_state(builtin, "ended"))
|
|
450
|
+
self.assertFalse(discourse.can_write_in_state(builtin, "cancelled"))
|
|
451
|
+
|
|
452
|
+
def test_validates_room_update_payloads(self):
|
|
453
|
+
discourse.validate_room_update_payload(
|
|
454
|
+
{"topic": "New topic", "guidance": "", "end_time": 2000}
|
|
455
|
+
)
|
|
456
|
+
with self.assertRaisesRegex(AgentProtocolError, "must not be empty"):
|
|
457
|
+
discourse.validate_room_update_payload({})
|
|
458
|
+
with self.assertRaisesRegex(AgentProtocolError, "not updatable"):
|
|
459
|
+
discourse.validate_room_update_payload({"visibility": "private"})
|
|
460
|
+
with self.assertRaisesRegex(AgentProtocolError, "topic"):
|
|
461
|
+
discourse.validate_room_update_payload({"topic": " "})
|
|
462
|
+
with self.assertRaisesRegex(AgentProtocolError, "before end_time"):
|
|
463
|
+
discourse.validate_room_update_payload({"start_time": 5, "end_time": 5})
|
|
464
|
+
with self.assertRaisesRegex(AgentProtocolError, "max_speakers"):
|
|
465
|
+
discourse.validate_room_update_payload({"policy": {"max_speakers": 0}})
|
|
466
|
+
|
|
467
|
+
def test_validates_room_member_remove_payloads(self):
|
|
468
|
+
member = AgentSigner.from_seed(bytes([41]) * 32).agent_id()
|
|
469
|
+
discourse.validate_room_member_remove_payload({"member": member})
|
|
470
|
+
discourse.validate_room_member_remove_payload({"member": member, "ban": True})
|
|
471
|
+
with self.assertRaises(AgentProtocolError):
|
|
472
|
+
discourse.validate_room_member_remove_payload({"member": "not-an-id"})
|
|
473
|
+
with self.assertRaisesRegex(AgentProtocolError, "ban must be a boolean"):
|
|
474
|
+
discourse.validate_room_member_remove_payload({"member": member, "ban": "yes"})
|
|
475
|
+
|
|
476
|
+
def test_mentions_are_capped_at_32_unique_agent_ids(self):
|
|
477
|
+
signer = AgentSigner.from_seed(bytes([42]) * 32)
|
|
478
|
+
others = [AgentSigner.from_seed(bytes([100 + index]) * 32).agent_id() for index in range(33)]
|
|
479
|
+
|
|
480
|
+
def envelope_with(mentions):
|
|
481
|
+
event = discourse_event(
|
|
482
|
+
MESSAGE_CREATE,
|
|
483
|
+
signer.agent_id(),
|
|
484
|
+
100,
|
|
485
|
+
1,
|
|
486
|
+
"room1",
|
|
487
|
+
1,
|
|
488
|
+
"room-create-head",
|
|
489
|
+
{"content_type": "text/plain", "content": "hi"},
|
|
490
|
+
)
|
|
491
|
+
event["mentions"] = mentions
|
|
492
|
+
return signer.sign_event(event)
|
|
493
|
+
|
|
494
|
+
validate_discourse_envelope(envelope_with(others[:32]))
|
|
495
|
+
with self.assertRaisesRegex(AgentProtocolError, "must not exceed 32"):
|
|
496
|
+
validate_discourse_envelope(envelope_with(others))
|
|
497
|
+
with self.assertRaisesRegex(AgentProtocolError, "unique"):
|
|
498
|
+
validate_discourse_envelope(envelope_with([others[0], others[0]]))
|
|
499
|
+
# A non-string mention yields a clean protocol error, not a raw
|
|
500
|
+
# TypeError from set() hashing.
|
|
501
|
+
with self.assertRaises(AgentProtocolError):
|
|
502
|
+
validate_discourse_envelope(envelope_with([{"not": "a string"}]))
|
|
503
|
+
|
|
504
|
+
def test_type_redefinition_cannot_change_kind(self):
|
|
505
|
+
definition = {
|
|
506
|
+
"type": "review.finding",
|
|
507
|
+
"kind": "message",
|
|
508
|
+
"title": "Finding",
|
|
509
|
+
"schema": {"type": "object"},
|
|
510
|
+
}
|
|
511
|
+
registry = discourse.TypeRegistry()
|
|
512
|
+
registry.define(definition)
|
|
513
|
+
registry.define({**definition, "title": "Finding v2"})
|
|
514
|
+
self.assertEqual(registry.get("review.finding")["title"], "Finding v2")
|
|
515
|
+
with self.assertRaisesRegex(AgentProtocolError, "cannot change kind"):
|
|
516
|
+
registry.define({**definition, "kind": "signal"})
|
|
517
|
+
|
|
404
518
|
|
|
405
519
|
if __name__ == "__main__":
|
|
406
520
|
unittest.main()
|
|
@@ -131,3 +131,86 @@ class IdentityTests(unittest.TestCase):
|
|
|
131
131
|
|
|
132
132
|
if __name__ == "__main__":
|
|
133
133
|
unittest.main()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Revision20260704IdentityTests(unittest.TestCase):
|
|
137
|
+
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
138
|
+
|
|
139
|
+
def test_base64url_decoding_rejects_non_canonical_encodings(self):
|
|
140
|
+
from agent_protocols.errors import AgentProtocolError
|
|
141
|
+
from agent_protocols.identity import (
|
|
142
|
+
AGENT_ID_PREFIX,
|
|
143
|
+
AgentSigner,
|
|
144
|
+
create_event,
|
|
145
|
+
validate_agent_id,
|
|
146
|
+
verify_envelope,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
signer = AgentSigner.from_seed(bytes([60]) * 32)
|
|
150
|
+
agent_id = signer.agent_id()
|
|
151
|
+
suffix = agent_id[len(AGENT_ID_PREFIX):]
|
|
152
|
+
validate_agent_id(agent_id)
|
|
153
|
+
|
|
154
|
+
# Padding characters are rejected even when the decoded bytes match.
|
|
155
|
+
with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
|
|
156
|
+
validate_agent_id(f"{AGENT_ID_PREFIX}{suffix}==")
|
|
157
|
+
|
|
158
|
+
# Non-zero trailing bits give the same key a second string form:
|
|
159
|
+
# replace the final character with one sharing its used bits but
|
|
160
|
+
# different trailing bits (the last char of a 32-byte value uses 4 of
|
|
161
|
+
# its 6 bits).
|
|
162
|
+
index = self.ALPHABET.index(suffix[-1])
|
|
163
|
+
non_canonical = self.ALPHABET[(index & ~3) | ((index & 3) ^ 1)]
|
|
164
|
+
with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
|
|
165
|
+
validate_agent_id(f"{AGENT_ID_PREFIX}{suffix[:-1]}{non_canonical}")
|
|
166
|
+
|
|
167
|
+
# Non-alphabet characters are rejected.
|
|
168
|
+
with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
|
|
169
|
+
validate_agent_id(f"{AGENT_ID_PREFIX}{suffix[:-1]}+")
|
|
170
|
+
|
|
171
|
+
# Signatures must be canonical base64url too.
|
|
172
|
+
event = create_event(
|
|
173
|
+
"agent-profile/1.0",
|
|
174
|
+
"profile.update",
|
|
175
|
+
agent_id,
|
|
176
|
+
1000,
|
|
177
|
+
1,
|
|
178
|
+
{"id": agent_id, "name": "Agent"},
|
|
179
|
+
)
|
|
180
|
+
envelope = signer.sign_event(event)
|
|
181
|
+
tampered = {**envelope, "signature": envelope["signature"] + "="}
|
|
182
|
+
with self.assertRaisesRegex(AgentProtocolError, "canonical base64url"):
|
|
183
|
+
verify_envelope(tampered)
|
|
184
|
+
|
|
185
|
+
def test_service_origin_derives_request_jwt_audience(self):
|
|
186
|
+
from agent_protocols.errors import AgentProtocolError
|
|
187
|
+
from agent_protocols.identity import service_origin
|
|
188
|
+
|
|
189
|
+
self.assertEqual(
|
|
190
|
+
service_origin("https://api.example.com/v1/rooms/room1"),
|
|
191
|
+
"https://api.example.com",
|
|
192
|
+
)
|
|
193
|
+
self.assertEqual(
|
|
194
|
+
service_origin("https://api.example.com:8443/path?q=1"),
|
|
195
|
+
"https://api.example.com:8443",
|
|
196
|
+
)
|
|
197
|
+
self.assertEqual(
|
|
198
|
+
service_origin("https://API.Example.com:443/"),
|
|
199
|
+
"https://api.example.com",
|
|
200
|
+
)
|
|
201
|
+
with self.assertRaises(AgentProtocolError):
|
|
202
|
+
service_origin("not a url")
|
|
203
|
+
with self.assertRaises(AgentProtocolError):
|
|
204
|
+
service_origin("ftp://example.com")
|
|
205
|
+
|
|
206
|
+
def test_nonce_not_greater_errors_carry_the_effective_maximum(self):
|
|
207
|
+
from agent_protocols.errors import AgentProtocolError
|
|
208
|
+
from agent_protocols.identity import AgentSigner, MemoryNonceStore
|
|
209
|
+
|
|
210
|
+
actor = AgentSigner.from_seed(bytes([61]) * 32).agent_id()
|
|
211
|
+
store = MemoryNonceStore()
|
|
212
|
+
store.check_and_update(actor, 7, 1000, 1000)
|
|
213
|
+
with self.assertRaises(AgentProtocolError) as caught:
|
|
214
|
+
store.check_and_update(actor, 7, 1100, 1000)
|
|
215
|
+
self.assertEqual(caught.exception.code, "nonce_not_greater")
|
|
216
|
+
self.assertEqual(caught.exception.data, {"max_nonce": 7})
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import unittest
|
|
2
2
|
|
|
3
3
|
from agent_protocols.identity import AgentSigner
|
|
4
|
-
from agent_protocols.profile import
|
|
4
|
+
from agent_protocols.profile import (
|
|
5
|
+
latest_profile_update,
|
|
6
|
+
materialize_profile,
|
|
7
|
+
profile_update_event,
|
|
8
|
+
validate_profile_update,
|
|
9
|
+
)
|
|
5
10
|
|
|
6
11
|
|
|
7
12
|
class ProfileTests(unittest.TestCase):
|
|
@@ -28,14 +33,14 @@ class ProfileTests(unittest.TestCase):
|
|
|
28
33
|
|
|
29
34
|
self.assertEqual(profile["id"], signer.agent_id())
|
|
30
35
|
self.assertEqual(profile["name"], "ResearchAgent-v3")
|
|
31
|
-
self.
|
|
36
|
+
self.assertNotIn("username", profile)
|
|
32
37
|
self.assertEqual(profile["links"], payload["links"])
|
|
33
38
|
self.assertEqual(profile["delegations"], payload["delegations"])
|
|
34
39
|
self.assertEqual(profile["extra"], payload["extra"])
|
|
35
40
|
self.assertEqual(profile["updated_at"], 1_779_753_600_000)
|
|
36
41
|
self.assertEqual(profile["event_id"], envelope["hash"])
|
|
37
42
|
|
|
38
|
-
def
|
|
43
|
+
def test_does_not_materialize_the_removed_username_field(self):
|
|
39
44
|
signer = AgentSigner.from_seed(bytes([15]) * 32)
|
|
40
45
|
payload = {"id": signer.agent_id(), "name": "ResearchAgent-v3", "username": "anda"}
|
|
41
46
|
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload))
|
|
@@ -43,7 +48,26 @@ class ProfileTests(unittest.TestCase):
|
|
|
43
48
|
profile = materialize_profile(envelope)
|
|
44
49
|
|
|
45
50
|
self.assertEqual(profile["id"], signer.agent_id())
|
|
46
|
-
self.
|
|
51
|
+
self.assertNotIn("username", profile)
|
|
52
|
+
|
|
53
|
+
def test_latest_profile_update_picks_greatest_nonce(self):
|
|
54
|
+
signer = AgentSigner.from_seed(bytes([16]) * 32)
|
|
55
|
+
envelopes = [
|
|
56
|
+
signer.sign_event(
|
|
57
|
+
profile_update_event(
|
|
58
|
+
signer.agent_id(),
|
|
59
|
+
1_779_753_600_000 + nonce,
|
|
60
|
+
nonce,
|
|
61
|
+
{"id": signer.agent_id(), "name": f"Agent-v{nonce}"},
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
for nonce in (3, 1, 2)
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
self.assertIsNone(latest_profile_update([]))
|
|
68
|
+
latest = latest_profile_update(envelopes)
|
|
69
|
+
self.assertEqual(latest["event"]["nonce"], 3)
|
|
70
|
+
self.assertEqual(materialize_profile(latest)["name"], "Agent-v3")
|
|
47
71
|
|
|
48
72
|
def test_rejects_actor_payload_mismatch(self):
|
|
49
73
|
signer = AgentSigner.from_seed(bytes([12]) * 32)
|
|
File without changes
|
|
File without changes
|
{agent_protocols-0.4.1 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|