agent-protocols 0.1.0__tar.gz → 0.2.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.1.0 → agent_protocols-0.2.0}/PKG-INFO +8 -8
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/README.md +7 -6
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/pyproject.toml +2 -2
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/__init__.py +14 -14
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/discourse.py +48 -55
- agent_protocols-0.2.0/src/agent_protocols/http_client.py +130 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/identity.py +87 -73
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/profile.py +9 -7
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/PKG-INFO +8 -8
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/requires.txt +0 -1
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/tests/test_discourse.py +30 -6
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/tests/test_identity.py +21 -26
- agent_protocols-0.2.0/tests/test_profile.py +49 -0
- agent_protocols-0.1.0/src/agent_protocols/http_client.py +0 -75
- agent_protocols-0.1.0/tests/test_profile.py +0 -30
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/setup.cfg +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols/errors.py +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
- {agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/top_level.txt +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
7
7
|
Keywords: agent,protocol,ed25519,sdk
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
|
-
Requires-Dist: base58<3,>=2.1
|
|
11
10
|
Requires-Dist: cryptography>=42
|
|
12
11
|
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
13
12
|
Provides-Extra: http
|
|
@@ -19,22 +18,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
|
|
|
19
18
|
|
|
20
19
|
## Modules
|
|
21
20
|
|
|
22
|
-
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event
|
|
21
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
23
22
|
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
24
|
-
- `agent_protocols.discourse`: ADP event constants,
|
|
23
|
+
- `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
|
|
25
24
|
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
26
25
|
|
|
27
26
|
## Example
|
|
28
27
|
|
|
29
28
|
```python
|
|
30
|
-
from agent_protocols import AgentSigner, materialize_profile, profile_update_event,
|
|
29
|
+
from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
|
|
31
30
|
|
|
32
31
|
signer = AgentSigner.generate()
|
|
32
|
+
nonces = ClientNonceManager()
|
|
33
33
|
event = profile_update_event(
|
|
34
34
|
signer.agent_id(),
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
{"
|
|
35
|
+
unix_ms(),
|
|
36
|
+
nonces.next_nonce(),
|
|
37
|
+
{"id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
38
38
|
)
|
|
39
39
|
envelope = signer.sign_event(event)
|
|
40
40
|
profile = materialize_profile(envelope)
|
|
@@ -4,22 +4,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
|
|
|
4
4
|
|
|
5
5
|
## Modules
|
|
6
6
|
|
|
7
|
-
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event
|
|
7
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
8
8
|
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
9
|
-
- `agent_protocols.discourse`: ADP event constants,
|
|
9
|
+
- `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
|
|
10
10
|
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
11
11
|
|
|
12
12
|
## Example
|
|
13
13
|
|
|
14
14
|
```python
|
|
15
|
-
from agent_protocols import AgentSigner, materialize_profile, profile_update_event,
|
|
15
|
+
from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
|
|
16
16
|
|
|
17
17
|
signer = AgentSigner.generate()
|
|
18
|
+
nonces = ClientNonceManager()
|
|
18
19
|
event = profile_update_event(
|
|
19
20
|
signer.agent_id(),
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{"
|
|
21
|
+
unix_ms(),
|
|
22
|
+
nonces.next_nonce(),
|
|
23
|
+
{"id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
23
24
|
)
|
|
24
25
|
envelope = signer.sign_event(event)
|
|
25
26
|
profile = materialize_profile(envelope)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-protocols"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
license = { text = "MIT" }
|
|
8
8
|
authors = [{ name = "LDCLabs" }]
|
|
9
9
|
keywords = ["agent", "protocol", "ed25519", "sdk"]
|
|
10
|
-
dependencies = ["
|
|
10
|
+
dependencies = ["cryptography>=42", "rfc8785>=0.1.4,<0.2"]
|
|
11
11
|
|
|
12
12
|
[project.optional-dependencies]
|
|
13
13
|
http = ["requests>=2.31,<3"]
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
from .errors import AgentProtocolError
|
|
2
2
|
from .identity import (
|
|
3
3
|
AGENT_ID_PREFIX,
|
|
4
|
-
EVENT_ID_PREFIX,
|
|
5
4
|
AgentSigner,
|
|
5
|
+
ClientNonceManager,
|
|
6
|
+
MAX_NONCE_HEADER,
|
|
6
7
|
MemoryNonceStore,
|
|
7
8
|
RequestBinding,
|
|
8
9
|
agent_id_from_public_key,
|
|
9
10
|
canonical_event_bytes,
|
|
10
11
|
create_event,
|
|
11
12
|
create_request_jwt_claims,
|
|
12
|
-
|
|
13
|
+
event_hash,
|
|
13
14
|
public_key_bytes,
|
|
14
|
-
random_nonce,
|
|
15
15
|
sign_event,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
unix_ms,
|
|
17
|
+
unix_secs,
|
|
18
18
|
validate_agent_id,
|
|
19
|
+
validate_nonce,
|
|
19
20
|
verify_envelope,
|
|
20
|
-
|
|
21
|
+
verify_event_hash,
|
|
21
22
|
verify_live_envelope,
|
|
22
23
|
verify_request_jwt,
|
|
23
|
-
verify_request_jwt_live,
|
|
24
24
|
verify_signature,
|
|
25
25
|
verify_timestamp,
|
|
26
26
|
with_room_id,
|
|
@@ -29,32 +29,32 @@ from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, prof
|
|
|
29
29
|
|
|
30
30
|
__all__ = [
|
|
31
31
|
"AGENT_ID_PREFIX",
|
|
32
|
-
"
|
|
32
|
+
"MAX_NONCE_HEADER",
|
|
33
33
|
"PROFILE_PROTOCOL",
|
|
34
34
|
"PROFILE_UPDATE",
|
|
35
35
|
"AgentProtocolError",
|
|
36
36
|
"AgentSigner",
|
|
37
|
+
"ClientNonceManager",
|
|
37
38
|
"MemoryNonceStore",
|
|
38
39
|
"RequestBinding",
|
|
39
40
|
"agent_id_from_public_key",
|
|
40
41
|
"canonical_event_bytes",
|
|
41
42
|
"create_event",
|
|
42
43
|
"create_request_jwt_claims",
|
|
43
|
-
"
|
|
44
|
+
"event_hash",
|
|
44
45
|
"materialize_profile",
|
|
45
46
|
"profile_update_event",
|
|
46
47
|
"public_key_bytes",
|
|
47
|
-
"random_nonce",
|
|
48
48
|
"sign_event",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
49
|
+
"unix_ms",
|
|
50
|
+
"unix_secs",
|
|
51
51
|
"validate_agent_id",
|
|
52
|
+
"validate_nonce",
|
|
52
53
|
"validate_profile_update",
|
|
53
54
|
"verify_envelope",
|
|
54
|
-
"
|
|
55
|
+
"verify_event_hash",
|
|
55
56
|
"verify_live_envelope",
|
|
56
57
|
"verify_request_jwt",
|
|
57
|
-
"verify_request_jwt_live",
|
|
58
58
|
"verify_signature",
|
|
59
59
|
"verify_timestamp",
|
|
60
60
|
"with_room_id",
|
|
@@ -10,61 +10,55 @@ LEGACY_DISCOURSE_PROTOCOL = "adp/1.0"
|
|
|
10
10
|
|
|
11
11
|
ROOM_CREATE = "room.create"
|
|
12
12
|
ROOM_JOIN = "room.join"
|
|
13
|
+
ROOM_JOIN_REVIEW = "room.join.review"
|
|
13
14
|
ROOM_LEAVE = "room.leave"
|
|
14
15
|
ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
|
|
15
|
-
ROOM_INVITE = "room.invite"
|
|
16
|
-
ROOM_INVITE_REVOKE = "room.invite.revoke"
|
|
17
16
|
ROOM_CLOSE = "room.close"
|
|
18
17
|
ROOM_CANCEL = "room.cancel"
|
|
19
|
-
|
|
20
|
-
MESSAGE_MARKDOWN = "message.markdown"
|
|
21
|
-
MESSAGE_DATA = "message.data"
|
|
18
|
+
MESSAGE_CREATE = "message.create"
|
|
22
19
|
REACTION_CREATE = "reaction.create"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
MESSAGE_PROPOSAL_CREATE = "message.proposal.create"
|
|
21
|
+
MESSAGE_POLL_CREATE = "message.poll.create"
|
|
22
|
+
MESSAGE_POLL_VOTE = "message.poll.vote"
|
|
23
|
+
MESSAGE_RESOLUTION_CREATE = "message.resolution.create"
|
|
27
24
|
SOURCE_ADD = "source.add"
|
|
28
25
|
TURN_UPDATE = "turn.update"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
SESSION_AUTH = "session.auth"
|
|
26
|
+
QUESTION_CREATE = "question.create"
|
|
27
|
+
ROOM_STEER = "room.steer"
|
|
28
|
+
MAP_UPDATE = "map.update"
|
|
29
|
+
ARTIFACT_CREATE = "artifact.create"
|
|
34
30
|
|
|
35
31
|
KNOWN_EVENT_TYPES = {
|
|
36
32
|
ROOM_CREATE,
|
|
37
33
|
ROOM_JOIN,
|
|
34
|
+
ROOM_JOIN_REVIEW,
|
|
38
35
|
ROOM_LEAVE,
|
|
39
36
|
ROOM_MEMBER_ROLE_UPDATE,
|
|
40
|
-
ROOM_INVITE,
|
|
41
|
-
ROOM_INVITE_REVOKE,
|
|
42
37
|
ROOM_CLOSE,
|
|
43
38
|
ROOM_CANCEL,
|
|
44
|
-
|
|
45
|
-
MESSAGE_MARKDOWN,
|
|
46
|
-
MESSAGE_DATA,
|
|
39
|
+
MESSAGE_CREATE,
|
|
47
40
|
REACTION_CREATE,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
42
|
+
MESSAGE_POLL_CREATE,
|
|
43
|
+
MESSAGE_POLL_VOTE,
|
|
44
|
+
MESSAGE_RESOLUTION_CREATE,
|
|
52
45
|
SOURCE_ADD,
|
|
53
46
|
TURN_UPDATE,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
SESSION_AUTH,
|
|
47
|
+
QUESTION_CREATE,
|
|
48
|
+
ROOM_STEER,
|
|
49
|
+
MAP_UPDATE,
|
|
50
|
+
ARTIFACT_CREATE,
|
|
59
51
|
}
|
|
60
52
|
|
|
61
53
|
RoomState = Literal["scheduled", "active", "ended", "cancelled"]
|
|
62
54
|
Role = Literal["moderator", "expert", "participant", "observer"]
|
|
55
|
+
JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
|
|
63
56
|
|
|
64
57
|
|
|
65
58
|
class PermissionContext(TypedDict, total=False):
|
|
66
59
|
role: Role
|
|
67
60
|
is_creator: bool
|
|
61
|
+
join_request_approved: bool
|
|
68
62
|
moderator_authorized: bool
|
|
69
63
|
expert_policy_allowed: bool
|
|
70
64
|
participant_policy_allowed: bool
|
|
@@ -72,11 +66,11 @@ class PermissionContext(TypedDict, total=False):
|
|
|
72
66
|
observer_poll_vote_allowed: bool
|
|
73
67
|
|
|
74
68
|
|
|
75
|
-
def room_create_event(actor: AgentId, created_at: int, nonce:
|
|
69
|
+
def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
|
|
76
70
|
return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
|
|
77
71
|
|
|
78
72
|
|
|
79
|
-
def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce:
|
|
73
|
+
def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
|
|
80
74
|
return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
|
|
81
75
|
|
|
82
76
|
|
|
@@ -92,6 +86,8 @@ def validate_discourse_envelope(envelope: Envelope, accept_legacy_protocol: bool
|
|
|
92
86
|
|
|
93
87
|
def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
|
|
94
88
|
actual = envelope["event"].get("room_id")
|
|
89
|
+
if actual is None and envelope["event"]["type"] == ROOM_CREATE:
|
|
90
|
+
return
|
|
95
91
|
if actual is None:
|
|
96
92
|
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
97
93
|
if actual != path_room_id:
|
|
@@ -103,8 +99,10 @@ def event_requires_room_id(event_type: str) -> bool:
|
|
|
103
99
|
|
|
104
100
|
|
|
105
101
|
def can_submit_event(event_type: str, context: PermissionContext) -> bool:
|
|
106
|
-
if event_type
|
|
102
|
+
if event_type == ROOM_CREATE:
|
|
107
103
|
return True
|
|
104
|
+
if event_type == ROOM_JOIN:
|
|
105
|
+
return context.get("join_request_approved", False)
|
|
108
106
|
if context.get("is_creator"):
|
|
109
107
|
return event_type in KNOWN_EVENT_TYPES
|
|
110
108
|
|
|
@@ -122,7 +120,7 @@ def can_submit_event(event_type: str, context: PermissionContext) -> bool:
|
|
|
122
120
|
|
|
123
121
|
def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
124
122
|
if state == "scheduled":
|
|
125
|
-
return event_type in {ROOM_JOIN,
|
|
123
|
+
return event_type in {ROOM_JOIN, ROOM_JOIN_REVIEW, ROOM_CANCEL}
|
|
126
124
|
if state == "active":
|
|
127
125
|
return event_type not in {ROOM_CREATE, ROOM_CANCEL}
|
|
128
126
|
if state == "ended":
|
|
@@ -143,22 +141,19 @@ def validate_room_write(event_type: str, state: RoomState, context: PermissionCo
|
|
|
143
141
|
|
|
144
142
|
def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
|
|
145
143
|
allowed = {
|
|
146
|
-
|
|
147
|
-
ROOM_INVITE_REVOKE,
|
|
144
|
+
ROOM_JOIN_REVIEW,
|
|
148
145
|
ROOM_CLOSE,
|
|
149
|
-
|
|
150
|
-
MESSAGE_MARKDOWN,
|
|
151
|
-
MESSAGE_DATA,
|
|
146
|
+
MESSAGE_CREATE,
|
|
152
147
|
SOURCE_ADD,
|
|
153
148
|
TURN_UPDATE,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
149
|
+
QUESTION_CREATE,
|
|
150
|
+
ROOM_STEER,
|
|
151
|
+
MAP_UPDATE,
|
|
152
|
+
ARTIFACT_CREATE,
|
|
153
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
154
|
+
MESSAGE_POLL_CREATE,
|
|
155
|
+
MESSAGE_POLL_VOTE,
|
|
156
|
+
MESSAGE_RESOLUTION_CREATE,
|
|
162
157
|
REACTION_CREATE,
|
|
163
158
|
ROOM_LEAVE,
|
|
164
159
|
}
|
|
@@ -167,24 +162,22 @@ def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
|
|
|
167
162
|
|
|
168
163
|
def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
|
|
169
164
|
allowed = {
|
|
170
|
-
|
|
171
|
-
MESSAGE_MARKDOWN,
|
|
172
|
-
MESSAGE_DATA,
|
|
165
|
+
MESSAGE_CREATE,
|
|
173
166
|
SOURCE_ADD,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
167
|
+
ROOM_STEER,
|
|
168
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
169
|
+
MESSAGE_POLL_CREATE,
|
|
170
|
+
MESSAGE_POLL_VOTE,
|
|
178
171
|
REACTION_CREATE,
|
|
179
172
|
ROOM_LEAVE,
|
|
180
173
|
}
|
|
181
|
-
policy_events = {
|
|
174
|
+
policy_events = {QUESTION_CREATE, MAP_UPDATE, ARTIFACT_CREATE, MESSAGE_RESOLUTION_CREATE}
|
|
182
175
|
return event_type in allowed or (policy_allowed and event_type in policy_events)
|
|
183
176
|
|
|
184
177
|
|
|
185
178
|
def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
|
|
186
179
|
return (
|
|
187
180
|
event_type in {REACTION_CREATE, ROOM_LEAVE}
|
|
188
|
-
or (context.get("observer_steering_allowed", False) and event_type ==
|
|
189
|
-
or (context.get("observer_poll_vote_allowed", False) and event_type ==
|
|
181
|
+
or (context.get("observer_steering_allowed", False) and event_type == ROOM_STEER)
|
|
182
|
+
or (context.get("observer_poll_vote_allowed", False) and event_type == MESSAGE_POLL_VOTE)
|
|
190
183
|
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote, urlencode
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import requests
|
|
8
|
+
except ImportError: # pragma: no cover
|
|
9
|
+
requests = None # type: ignore[assignment]
|
|
10
|
+
|
|
11
|
+
from .identity import AgentId, Envelope
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProfileClient:
|
|
15
|
+
def __init__(self, base_url: str, session: Any | None = None):
|
|
16
|
+
self.base_url = base_url.rstrip("/")
|
|
17
|
+
self.session = session or _requests_session()
|
|
18
|
+
|
|
19
|
+
def get_profile(self, agent_id: AgentId) -> dict[str, Any]:
|
|
20
|
+
return self._get(f"/v1/profiles/{agent_id}")
|
|
21
|
+
|
|
22
|
+
def get_profiles(self, agent_ids: list[AgentId]) -> dict[str, Any]:
|
|
23
|
+
return self._post("/v1/profiles/batch", {"ids": agent_ids})
|
|
24
|
+
|
|
25
|
+
def profile_events(self, agent_id: AgentId, limit: int = 1) -> dict[str, Any]:
|
|
26
|
+
return self._get(f"/v1/profiles/{agent_id}/events?limit={limit}")
|
|
27
|
+
|
|
28
|
+
def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
|
|
29
|
+
return self._post("/v1/profiles", envelope)
|
|
30
|
+
|
|
31
|
+
def _get(self, path: str) -> Any:
|
|
32
|
+
response = self.session.get(self.base_url + path)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
return response.json()
|
|
35
|
+
|
|
36
|
+
def _post(self, path: str, body: Any) -> dict[str, Any]:
|
|
37
|
+
response = self.session.post(self.base_url + path, json=body)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
return response.json()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DiscourseClient:
|
|
43
|
+
def __init__(self, base_url: str, session: Any | None = None):
|
|
44
|
+
self.base_url = base_url.rstrip("/")
|
|
45
|
+
self.session = session or _requests_session()
|
|
46
|
+
|
|
47
|
+
def protocol(self) -> dict[str, Any]:
|
|
48
|
+
return self._get("/.well-known/agent-discourse")
|
|
49
|
+
|
|
50
|
+
def create_room(self, envelope: Envelope) -> dict[str, Any]:
|
|
51
|
+
return self._post("/v1/rooms", envelope)
|
|
52
|
+
|
|
53
|
+
def room(self, room_id: str) -> dict[str, Any]:
|
|
54
|
+
return self._get(f"/v1/rooms/{room_id}")
|
|
55
|
+
|
|
56
|
+
def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
|
|
58
|
+
|
|
59
|
+
def join_request(self, room_id: str, request_id: str, jwt: str) -> dict[str, Any]:
|
|
60
|
+
return self._get(f"/v1/rooms/{room_id}/join-requests/{request_id}", jwt=jwt)
|
|
61
|
+
|
|
62
|
+
def join_requests(self, room_id: str, jwt: str) -> list[dict[str, Any]]:
|
|
63
|
+
return self._get(f"/v1/rooms/{room_id}/join-requests", jwt=jwt)
|
|
64
|
+
|
|
65
|
+
def join_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
66
|
+
return self._post(f"/v1/rooms/{room_id}", envelope)
|
|
67
|
+
|
|
68
|
+
def leave_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
69
|
+
return self._post(f"/v1/rooms/{room_id}", envelope)
|
|
70
|
+
|
|
71
|
+
def submit_event(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
72
|
+
return self._post(f"/v1/rooms/{room_id}", envelope)
|
|
73
|
+
|
|
74
|
+
def events(
|
|
75
|
+
self,
|
|
76
|
+
room_id: str,
|
|
77
|
+
*,
|
|
78
|
+
after_seq: int | None = None,
|
|
79
|
+
limit: int | None = None,
|
|
80
|
+
cursor: str | None = None,
|
|
81
|
+
jwt: str | None = None,
|
|
82
|
+
) -> list[dict[str, Any]]:
|
|
83
|
+
query = urlencode(
|
|
84
|
+
{
|
|
85
|
+
key: value
|
|
86
|
+
for key, value in {
|
|
87
|
+
"after_seq": after_seq,
|
|
88
|
+
"limit": limit,
|
|
89
|
+
"cursor": cursor,
|
|
90
|
+
}.items()
|
|
91
|
+
if value is not None
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
suffix = f"?{query}" if query else ""
|
|
95
|
+
return self._get(f"/v1/rooms/{room_id}/events{suffix}", jwt=jwt)
|
|
96
|
+
|
|
97
|
+
def websocket_events_url(self, room_id: str, jwt: str) -> str:
|
|
98
|
+
return websocket_events_url(self.base_url, room_id, jwt)
|
|
99
|
+
|
|
100
|
+
def archive(self, room_id: str) -> dict[str, Any]:
|
|
101
|
+
return self._get(f"/v1/rooms/{room_id}/archive")
|
|
102
|
+
|
|
103
|
+
def _get(self, path: str, jwt: str | None = None) -> Any:
|
|
104
|
+
response = self.session.get(self.base_url + path, headers=_auth_headers(jwt))
|
|
105
|
+
response.raise_for_status()
|
|
106
|
+
return response.json()
|
|
107
|
+
|
|
108
|
+
def _post(self, path: str, body: Any, jwt: str | None = None) -> Any:
|
|
109
|
+
response = self.session.post(self.base_url + path, json=body, headers=_auth_headers(jwt))
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
return response.json()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def websocket_events_url(base_url: str, room_id: str, jwt: str) -> str:
|
|
115
|
+
websocket_base = base_url.rstrip("/")
|
|
116
|
+
if websocket_base.startswith("https://"):
|
|
117
|
+
websocket_base = "wss://" + websocket_base[len("https://") :]
|
|
118
|
+
elif websocket_base.startswith("http://"):
|
|
119
|
+
websocket_base = "ws://" + websocket_base[len("http://") :]
|
|
120
|
+
return f"{websocket_base}/v1/rooms/{quote(room_id, safe='')}/events/live?access_token={quote(jwt, safe='')}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _auth_headers(jwt: str | None) -> dict[str, str] | None:
|
|
124
|
+
return {"Authorization": f"Bearer {jwt}"} if jwt else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _requests_session() -> Any:
|
|
128
|
+
if requests is None:
|
|
129
|
+
raise RuntimeError("Install agent-protocols[http] to use HTTP clients")
|
|
130
|
+
return requests.Session()
|
|
@@ -3,12 +3,11 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
|
-
import
|
|
6
|
+
import re
|
|
7
7
|
import time
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from typing import Any, MutableMapping, Protocol
|
|
10
10
|
|
|
11
|
-
import base58
|
|
12
11
|
import rfc8785
|
|
13
12
|
from cryptography.exceptions import InvalidSignature
|
|
14
13
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
@@ -17,10 +16,10 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
|
17
16
|
from .errors import AgentProtocolError
|
|
18
17
|
|
|
19
18
|
AGENT_ID_PREFIX = "did:agent:"
|
|
20
|
-
EVENT_ID_PREFIX = "evt_"
|
|
21
19
|
DEFAULT_LIVE_WRITE_WINDOW_MS = 300_000
|
|
20
|
+
DEFAULT_NONCE_TTL_MS = 300_000
|
|
22
21
|
DEFAULT_REQUEST_JWT_TTL_SECS = 300
|
|
23
|
-
|
|
22
|
+
MAX_NONCE_HEADER = "Max-Seen-Nonce"
|
|
24
23
|
|
|
25
24
|
Event = dict[str, Any]
|
|
26
25
|
Envelope = dict[str, Any]
|
|
@@ -30,13 +29,13 @@ AgentId = str
|
|
|
30
29
|
def agent_id_from_public_key(public_key: bytes) -> AgentId:
|
|
31
30
|
if len(public_key) != 32:
|
|
32
31
|
raise AgentProtocolError("invalid_public_key", f"public key must be 32 bytes, got {len(public_key)}")
|
|
33
|
-
return f"{AGENT_ID_PREFIX}{
|
|
32
|
+
return f"{AGENT_ID_PREFIX}{_base64url_encode(public_key)}"
|
|
34
33
|
|
|
35
34
|
|
|
36
35
|
def public_key_bytes(agent_id: AgentId) -> bytes:
|
|
37
36
|
if not agent_id.startswith(AGENT_ID_PREFIX):
|
|
38
37
|
raise AgentProtocolError("invalid_agent_id", "agent id must start with did:agent:")
|
|
39
|
-
data =
|
|
38
|
+
data = _base64url_decode_no_pad(agent_id[len(AGENT_ID_PREFIX):])
|
|
40
39
|
if len(data) != 32:
|
|
41
40
|
raise AgentProtocolError("invalid_public_key", f"agent id public key must be 32 bytes, got {len(data)}")
|
|
42
41
|
return data
|
|
@@ -50,13 +49,10 @@ def validate_agent_id(agent_id: AgentId) -> AgentId:
|
|
|
50
49
|
@dataclass(frozen=True)
|
|
51
50
|
class RequestBinding:
|
|
52
51
|
audience: str
|
|
53
|
-
method: str
|
|
54
|
-
host: str
|
|
55
|
-
path: str
|
|
56
52
|
|
|
57
53
|
@classmethod
|
|
58
|
-
def create(cls, audience: str
|
|
59
|
-
return cls(audience=audience
|
|
54
|
+
def create(cls, audience: str) -> "RequestBinding":
|
|
55
|
+
return cls(audience=audience)
|
|
60
56
|
|
|
61
57
|
|
|
62
58
|
class AgentSigner:
|
|
@@ -81,7 +77,7 @@ class AgentSigner:
|
|
|
81
77
|
|
|
82
78
|
def sign_event(self, event: Event) -> Envelope:
|
|
83
79
|
return {
|
|
84
|
-
"
|
|
80
|
+
"hash": event_hash(event),
|
|
85
81
|
"event": event,
|
|
86
82
|
"signature": sign_event(self._private_key, event),
|
|
87
83
|
}
|
|
@@ -99,21 +95,63 @@ class AgentSigner:
|
|
|
99
95
|
|
|
100
96
|
|
|
101
97
|
class NonceStore(Protocol):
|
|
102
|
-
def
|
|
98
|
+
def check_and_update(self, actor: AgentId, nonce: int, now_ms: int, ttl_ms: int) -> int: ...
|
|
99
|
+
|
|
100
|
+
def max_nonce(self, actor: AgentId, now_ms: int) -> int | None: ...
|
|
103
101
|
|
|
104
102
|
|
|
105
103
|
class MemoryNonceStore:
|
|
106
104
|
def __init__(self) -> None:
|
|
107
|
-
self.
|
|
108
|
-
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
self._records: dict[AgentId, tuple[int, int]] = {}
|
|
106
|
+
|
|
107
|
+
def check_and_update(self, actor: AgentId, nonce: int, now_ms: int, ttl_ms: int) -> int:
|
|
108
|
+
validate_nonce(nonce)
|
|
109
|
+
if ttl_ms < 0:
|
|
110
|
+
raise AgentProtocolError("invalid_nonce", "nonce cache ttl must be non-negative")
|
|
111
|
+
record = self._records.get(actor)
|
|
112
|
+
if record is not None:
|
|
113
|
+
max_nonce, expires_at = record
|
|
114
|
+
if expires_at > now_ms and nonce <= max_nonce:
|
|
115
|
+
raise AgentProtocolError("nonce_not_greater", f"nonce must be greater than accepted max nonce {max_nonce}")
|
|
116
|
+
self._records[actor] = (nonce, now_ms + ttl_ms)
|
|
117
|
+
return nonce
|
|
118
|
+
|
|
119
|
+
def max_nonce(self, actor: AgentId, now_ms: int) -> int | None:
|
|
120
|
+
record = self._records.get(actor)
|
|
121
|
+
if record is None:
|
|
122
|
+
return None
|
|
123
|
+
max_nonce, expires_at = record
|
|
124
|
+
return max_nonce if expires_at > now_ms else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ClientNonceManager:
|
|
128
|
+
def __init__(self, next_nonce: int = 1) -> None:
|
|
129
|
+
validate_nonce(next_nonce)
|
|
130
|
+
self._next_nonce = next_nonce
|
|
131
|
+
|
|
132
|
+
def peek(self) -> int:
|
|
133
|
+
return self._next_nonce
|
|
134
|
+
|
|
135
|
+
def next_nonce(self) -> int:
|
|
136
|
+
nonce = self._next_nonce
|
|
137
|
+
self._next_nonce += 1
|
|
138
|
+
return nonce
|
|
139
|
+
|
|
140
|
+
def observe_max_nonce(self, max_nonce: int | str | None) -> None:
|
|
141
|
+
if max_nonce is None or max_nonce == "":
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
parsed = int(max_nonce)
|
|
145
|
+
except (TypeError, ValueError) as exc:
|
|
146
|
+
raise AgentProtocolError("invalid_nonce", "invalid max nonce header") from exc
|
|
147
|
+
validate_nonce(parsed)
|
|
148
|
+
if parsed >= self._next_nonce:
|
|
149
|
+
self._next_nonce = parsed + 1
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_event(protocol: str, event_type: str, actor: AgentId, created_at: int, nonce: int, payload: Any) -> Event:
|
|
116
153
|
validate_agent_id(actor)
|
|
154
|
+
validate_nonce(nonce)
|
|
117
155
|
return {
|
|
118
156
|
"protocol": protocol,
|
|
119
157
|
"type": event_type,
|
|
@@ -135,20 +173,20 @@ def canonical_event_bytes(event: Event) -> bytes:
|
|
|
135
173
|
return canonical if isinstance(canonical, bytes) else canonical.encode()
|
|
136
174
|
|
|
137
175
|
|
|
138
|
-
def
|
|
139
|
-
digest = hashlib.
|
|
140
|
-
return
|
|
176
|
+
def event_hash(event: Event) -> str:
|
|
177
|
+
digest = hashlib.sha3_256(canonical_event_bytes(event)).digest()
|
|
178
|
+
return _base64url_encode(digest)
|
|
141
179
|
|
|
142
180
|
|
|
143
181
|
def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
|
|
144
182
|
return _base64url_encode(private_key.sign(canonical_event_bytes(event)))
|
|
145
183
|
|
|
146
184
|
|
|
147
|
-
def
|
|
148
|
-
expected =
|
|
149
|
-
actual = envelope["
|
|
185
|
+
def verify_event_hash(envelope: Envelope) -> None:
|
|
186
|
+
expected = event_hash(envelope["event"])
|
|
187
|
+
actual = envelope["hash"]
|
|
150
188
|
if expected != actual:
|
|
151
|
-
raise AgentProtocolError("
|
|
189
|
+
raise AgentProtocolError("invalid_event_hash", f"invalid event hash: expected {expected}, got {actual}")
|
|
152
190
|
|
|
153
191
|
|
|
154
192
|
def verify_signature(envelope: Envelope) -> None:
|
|
@@ -163,7 +201,7 @@ def verify_signature(envelope: Envelope) -> None:
|
|
|
163
201
|
|
|
164
202
|
|
|
165
203
|
def verify_envelope(envelope: Envelope) -> None:
|
|
166
|
-
|
|
204
|
+
verify_event_hash(envelope)
|
|
167
205
|
verify_signature(envelope)
|
|
168
206
|
|
|
169
207
|
|
|
@@ -172,32 +210,24 @@ def verify_timestamp(created_at: int, now_ms: int, window_ms: int) -> None:
|
|
|
172
210
|
raise AgentProtocolError("timestamp_out_of_window", "timestamp is outside the allowed live-write window")
|
|
173
211
|
|
|
174
212
|
|
|
175
|
-
def
|
|
176
|
-
|
|
177
|
-
return (event["actor"], event["protocol"], room_id, event["nonce"])
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def verify_live_envelope(envelope: Envelope, nonce_store: NonceStore, *, now_ms: int | None = None, window_ms: int = DEFAULT_LIVE_WRITE_WINDOW_MS, nonce_scope: str = "actor_protocol") -> None:
|
|
213
|
+
def verify_live_envelope(envelope: Envelope, nonce_store: NonceStore, *, now_ms: int | None = None, window_ms: int = DEFAULT_LIVE_WRITE_WINDOW_MS, nonce_ttl_ms: int = DEFAULT_NONCE_TTL_MS) -> int:
|
|
214
|
+
current_now_ms = now_ms if now_ms is not None else unix_ms()
|
|
181
215
|
verify_envelope(envelope)
|
|
182
|
-
verify_timestamp(envelope["event"]["created_at"],
|
|
183
|
-
nonce_store.
|
|
216
|
+
verify_timestamp(envelope["event"]["created_at"], current_now_ms, window_ms)
|
|
217
|
+
return nonce_store.check_and_update(envelope["event"]["actor"], envelope["event"]["nonce"], current_now_ms, nonce_ttl_ms)
|
|
184
218
|
|
|
185
219
|
|
|
186
|
-
def create_request_jwt_claims(agent_id: AgentId, binding: RequestBinding, issued_at: int, ttl_secs: int
|
|
220
|
+
def create_request_jwt_claims(agent_id: AgentId, binding: RequestBinding, issued_at: int, ttl_secs: int) -> dict[str, Any]:
|
|
187
221
|
return {
|
|
188
222
|
"iss": agent_id,
|
|
189
223
|
"sub": agent_id,
|
|
190
224
|
"aud": binding.audience,
|
|
191
225
|
"iat": issued_at,
|
|
192
226
|
"exp": issued_at + ttl_secs,
|
|
193
|
-
"jti": jti,
|
|
194
|
-
"method": binding.method.upper(),
|
|
195
|
-
"host": binding.host,
|
|
196
|
-
"path": binding.path,
|
|
197
227
|
}
|
|
198
228
|
|
|
199
229
|
|
|
200
|
-
def verify_request_jwt(token: str, *, audience: str,
|
|
230
|
+
def verify_request_jwt(token: str, *, audience: str, now_secs: int | None = None, max_ttl_secs: int = DEFAULT_REQUEST_JWT_TTL_SECS) -> dict[str, Any]:
|
|
201
231
|
parts = token.split(".")
|
|
202
232
|
if len(parts) != 3:
|
|
203
233
|
raise AgentProtocolError("invalid_jwt", "expected three compact JWS parts")
|
|
@@ -219,17 +249,10 @@ def verify_request_jwt(token: str, *, audience: str, method: str, host: str, pat
|
|
|
219
249
|
except InvalidSignature as exc:
|
|
220
250
|
raise AgentProtocolError("invalid_signature", "JWT signature verification failed") from exc
|
|
221
251
|
|
|
222
|
-
expected_method = method.upper()
|
|
223
252
|
if claims.get("aud") != audience:
|
|
224
253
|
raise AgentProtocolError("invalid_jwt_claim", "aud mismatch")
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if claims.get("host") != host:
|
|
228
|
-
raise AgentProtocolError("invalid_jwt_claim", "host mismatch")
|
|
229
|
-
if claims.get("path") != path:
|
|
230
|
-
raise AgentProtocolError("invalid_jwt_claim", "path mismatch")
|
|
231
|
-
|
|
232
|
-
now = now_secs if now_secs is not None else unix_time_secs()
|
|
254
|
+
|
|
255
|
+
now = now_secs if now_secs is not None else unix_secs()
|
|
233
256
|
if claims["iat"] > now or claims["exp"] < now:
|
|
234
257
|
raise AgentProtocolError("invalid_jwt_claim", "iat/exp outside valid time window")
|
|
235
258
|
if claims["exp"] - claims["iat"] > max_ttl_secs:
|
|
@@ -237,32 +260,17 @@ def verify_request_jwt(token: str, *, audience: str, method: str, host: str, pat
|
|
|
237
260
|
return claims
|
|
238
261
|
|
|
239
262
|
|
|
240
|
-
def
|
|
241
|
-
claims = verify_request_jwt(token, **context)
|
|
242
|
-
nonce_store.check_and_insert((claims["iss"], _REQUEST_AUTH_REPLAY_SCOPE, None, claims["jti"]))
|
|
243
|
-
return claims
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def unix_time_millis() -> int:
|
|
263
|
+
def unix_ms() -> int:
|
|
247
264
|
return int(time.time() * 1000)
|
|
248
265
|
|
|
249
266
|
|
|
250
|
-
def
|
|
267
|
+
def unix_secs() -> int:
|
|
251
268
|
return int(time.time())
|
|
252
269
|
|
|
253
270
|
|
|
254
|
-
def
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _base58btc_encode(data: bytes) -> str:
|
|
259
|
-
return "z" + base58.b58encode(data).decode()
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _base58btc_decode(value: str) -> bytes:
|
|
263
|
-
if not value.startswith("z"):
|
|
264
|
-
raise AgentProtocolError("invalid_encoding", "expected base58btc multibase value")
|
|
265
|
-
return base58.b58decode(value[1:])
|
|
271
|
+
def validate_nonce(nonce: int) -> None:
|
|
272
|
+
if not isinstance(nonce, int) or nonce < 1 or nonce > 0x1FFFFFFFFFFFFF:
|
|
273
|
+
raise AgentProtocolError("invalid_nonce", "nonce must be a positive integer less than or equal to 9007199254740991")
|
|
266
274
|
|
|
267
275
|
|
|
268
276
|
def _base64url_encode(data: bytes) -> str:
|
|
@@ -272,3 +280,9 @@ def _base64url_encode(data: bytes) -> str:
|
|
|
272
280
|
def _base64url_decode(value: str) -> bytes:
|
|
273
281
|
padding = "=" * ((4 - len(value) % 4) % 4)
|
|
274
282
|
return base64.urlsafe_b64decode(value + padding)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _base64url_decode_no_pad(value: str) -> bytes:
|
|
286
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
287
|
+
raise AgentProtocolError("invalid_encoding", "expected base64url without padding")
|
|
288
|
+
return _base64url_decode(value)
|
|
@@ -12,34 +12,36 @@ ProfileUpdatePayload = dict[str, Any]
|
|
|
12
12
|
AgentProfile = dict[str, Any]
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def profile_update_event(actor: AgentId, created_at: int, nonce:
|
|
15
|
+
def profile_update_event(actor: AgentId, created_at: int, nonce: int, payload: ProfileUpdatePayload) -> Event:
|
|
16
16
|
return create_event(PROFILE_PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def validate_profile_update(envelope: Envelope) -> None:
|
|
20
20
|
verify_envelope(envelope)
|
|
21
21
|
event = envelope["event"]
|
|
22
|
+
payload_id = event["payload"].get("id") or event["payload"].get("agent_id")
|
|
22
23
|
if event["protocol"] != PROFILE_PROTOCOL:
|
|
23
24
|
raise AgentProtocolError("invalid_event_protocol", f"expected {PROFILE_PROTOCOL}, got {event['protocol']}")
|
|
24
25
|
if event["type"] != PROFILE_UPDATE:
|
|
25
26
|
raise AgentProtocolError("invalid_event_type", f"expected {PROFILE_UPDATE}, got {event['type']}")
|
|
26
|
-
if event["actor"] !=
|
|
27
|
-
raise AgentProtocolError("invalid_actor", "profile update actor must match payload.
|
|
27
|
+
if event["actor"] != payload_id:
|
|
28
|
+
raise AgentProtocolError("invalid_actor", "profile update actor must match payload.id")
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def materialize_profile(envelope: Envelope) -> AgentProfile:
|
|
31
32
|
validate_profile_update(envelope)
|
|
32
33
|
payload = envelope["event"]["payload"]
|
|
34
|
+
payload_id = payload.get("id") or payload.get("agent_id")
|
|
33
35
|
return {
|
|
34
|
-
"
|
|
36
|
+
"id": payload_id,
|
|
35
37
|
"name": payload["name"],
|
|
36
38
|
"description": payload.get("description"),
|
|
37
39
|
"avatar_url": payload.get("avatar_url"),
|
|
38
40
|
"provider": payload.get("provider"),
|
|
39
41
|
"capabilities": payload.get("capabilities", []),
|
|
40
42
|
"service_endpoints": payload.get("service_endpoints", []),
|
|
41
|
-
"links": payload.get("links",
|
|
42
|
-
"
|
|
43
|
+
"links": payload.get("links", []),
|
|
44
|
+
"extra": payload.get("extra", {}),
|
|
43
45
|
"updated_at": envelope["event"]["created_at"],
|
|
44
|
-
"
|
|
46
|
+
"event_id": envelope["hash"],
|
|
45
47
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
7
7
|
Keywords: agent,protocol,ed25519,sdk
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
|
-
Requires-Dist: base58<3,>=2.1
|
|
11
10
|
Requires-Dist: cryptography>=42
|
|
12
11
|
Requires-Dist: rfc8785<0.2,>=0.1.4
|
|
13
12
|
Provides-Extra: http
|
|
@@ -19,22 +18,23 @@ Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse prot
|
|
|
19
18
|
|
|
20
19
|
## Modules
|
|
21
20
|
|
|
22
|
-
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event
|
|
21
|
+
- `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
|
|
23
22
|
- `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
|
|
24
|
-
- `agent_protocols.discourse`: ADP event constants,
|
|
23
|
+
- `agent_protocols.discourse`: ADP event constants, join request helpers, room-path checks, permission and state helpers.
|
|
25
24
|
- `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
|
|
26
25
|
|
|
27
26
|
## Example
|
|
28
27
|
|
|
29
28
|
```python
|
|
30
|
-
from agent_protocols import AgentSigner, materialize_profile, profile_update_event,
|
|
29
|
+
from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
|
|
31
30
|
|
|
32
31
|
signer = AgentSigner.generate()
|
|
32
|
+
nonces = ClientNonceManager()
|
|
33
33
|
event = profile_update_event(
|
|
34
34
|
signer.agent_id(),
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
{"
|
|
35
|
+
unix_ms(),
|
|
36
|
+
nonces.next_nonce(),
|
|
37
|
+
{"id": signer.agent_id(), "name": "ResearchAgent-v3"},
|
|
38
38
|
)
|
|
39
39
|
envelope = signer.sign_event(event)
|
|
40
40
|
profile = materialize_profile(envelope)
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import unittest
|
|
2
2
|
|
|
3
3
|
from agent_protocols.discourse import (
|
|
4
|
-
|
|
4
|
+
MESSAGE_CREATE,
|
|
5
5
|
REACTION_CREATE,
|
|
6
6
|
ROOM_CANCEL,
|
|
7
7
|
ROOM_CREATE,
|
|
8
|
+
ROOM_JOIN,
|
|
9
|
+
ROOM_JOIN_REVIEW,
|
|
8
10
|
can_accept_room_write,
|
|
9
11
|
can_submit_event,
|
|
10
12
|
room_create_event,
|
|
11
13
|
validate_discourse_envelope,
|
|
14
|
+
validate_room_path,
|
|
12
15
|
)
|
|
16
|
+
from agent_protocols.http_client import websocket_events_url
|
|
13
17
|
from agent_protocols.identity import AgentSigner, create_event
|
|
14
18
|
|
|
15
19
|
|
|
@@ -19,16 +23,24 @@ class DiscourseTests(unittest.TestCase):
|
|
|
19
23
|
event = room_create_event(
|
|
20
24
|
signer.agent_id(),
|
|
21
25
|
100,
|
|
22
|
-
|
|
26
|
+
1,
|
|
23
27
|
{"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
|
|
24
28
|
)
|
|
25
29
|
envelope = signer.sign_event(event)
|
|
26
30
|
|
|
27
31
|
validate_discourse_envelope(envelope)
|
|
32
|
+
validate_room_path(envelope, "d8ftedhpqhsusbg001tg")
|
|
28
33
|
|
|
29
34
|
def test_rejects_room_event_without_room_id(self):
|
|
30
35
|
signer = AgentSigner.from_seed(bytes([15]) * 32)
|
|
31
|
-
event = create_event(
|
|
36
|
+
event = create_event(
|
|
37
|
+
"agent-discourse/1.0",
|
|
38
|
+
MESSAGE_CREATE,
|
|
39
|
+
signer.agent_id(),
|
|
40
|
+
100,
|
|
41
|
+
1,
|
|
42
|
+
{"content_type": "text/plain", "content": "hello"},
|
|
43
|
+
)
|
|
32
44
|
envelope = signer.sign_event(event)
|
|
33
45
|
|
|
34
46
|
with self.assertRaises(Exception):
|
|
@@ -36,17 +48,29 @@ class DiscourseTests(unittest.TestCase):
|
|
|
36
48
|
|
|
37
49
|
def test_applies_permission_matrix(self):
|
|
38
50
|
self.assertTrue(can_submit_event(REACTION_CREATE, {"role": "observer"}))
|
|
39
|
-
self.assertFalse(can_submit_event(
|
|
51
|
+
self.assertFalse(can_submit_event(MESSAGE_CREATE, {"role": "observer"}))
|
|
52
|
+
self.assertFalse(can_submit_event(ROOM_JOIN, {"role": "observer"}))
|
|
53
|
+
self.assertTrue(can_submit_event(ROOM_JOIN, {"join_request_approved": True}))
|
|
54
|
+
self.assertTrue(can_submit_event(ROOM_JOIN_REVIEW, {"role": "moderator"}))
|
|
55
|
+
self.assertFalse(can_submit_event(ROOM_JOIN_REVIEW, {"role": "participant"}))
|
|
40
56
|
self.assertFalse(can_submit_event(ROOM_CANCEL, {"role": "moderator"}))
|
|
41
57
|
self.assertTrue(can_submit_event(ROOM_CANCEL, {"role": "moderator", "moderator_authorized": True}))
|
|
42
58
|
self.assertTrue(can_submit_event(ROOM_CREATE, {}))
|
|
43
59
|
|
|
44
60
|
def test_applies_state_restrictions(self):
|
|
45
|
-
self.assertTrue(can_accept_room_write(
|
|
46
|
-
self.assertFalse(can_accept_room_write(
|
|
61
|
+
self.assertTrue(can_accept_room_write(MESSAGE_CREATE, "active", {"role": "participant"}))
|
|
62
|
+
self.assertFalse(can_accept_room_write(MESSAGE_CREATE, "scheduled", {"role": "participant"}))
|
|
63
|
+
self.assertTrue(can_accept_room_write(ROOM_JOIN_REVIEW, "scheduled", {"role": "moderator"}))
|
|
64
|
+
self.assertTrue(can_accept_room_write(ROOM_JOIN, "scheduled", {"join_request_approved": True}))
|
|
47
65
|
self.assertFalse(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}))
|
|
48
66
|
self.assertTrue(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}, post_end_reaction_allowed=True))
|
|
49
67
|
|
|
68
|
+
def test_builds_websocket_event_stream_url(self):
|
|
69
|
+
self.assertEqual(
|
|
70
|
+
websocket_events_url("https://api.example.com", "room123", "jwt.token"),
|
|
71
|
+
"wss://api.example.com/v1/rooms/room123/events/live?access_token=jwt.token",
|
|
72
|
+
)
|
|
73
|
+
|
|
50
74
|
|
|
51
75
|
if __name__ == "__main__":
|
|
52
76
|
unittest.main()
|
|
@@ -2,13 +2,14 @@ import unittest
|
|
|
2
2
|
|
|
3
3
|
from agent_protocols.identity import (
|
|
4
4
|
AgentSigner,
|
|
5
|
+
ClientNonceManager,
|
|
5
6
|
MemoryNonceStore,
|
|
6
7
|
RequestBinding,
|
|
7
8
|
create_event,
|
|
8
9
|
create_request_jwt_claims,
|
|
9
10
|
verify_envelope,
|
|
10
11
|
verify_live_envelope,
|
|
11
|
-
|
|
12
|
+
verify_request_jwt,
|
|
12
13
|
)
|
|
13
14
|
|
|
14
15
|
|
|
@@ -20,19 +21,20 @@ class IdentityTests(unittest.TestCase):
|
|
|
20
21
|
"profile.update",
|
|
21
22
|
signer.agent_id(),
|
|
22
23
|
1_779_753_600_000,
|
|
23
|
-
|
|
24
|
+
1,
|
|
24
25
|
{"agent_id": signer.agent_id(), "name": "ResearchAgent"},
|
|
25
26
|
)
|
|
26
27
|
|
|
27
28
|
envelope = signer.sign_event(event)
|
|
28
29
|
|
|
29
|
-
self.
|
|
30
|
+
self.assertEqual(len(envelope["hash"]), 43)
|
|
31
|
+
self.assertFalse(envelope["hash"].startswith("evt_"))
|
|
30
32
|
verify_envelope(envelope)
|
|
31
33
|
|
|
32
34
|
def test_rejects_tampered_payloads(self):
|
|
33
35
|
signer = AgentSigner.from_seed(bytes([8]) * 32)
|
|
34
36
|
envelope = signer.sign_event(
|
|
35
|
-
create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000,
|
|
37
|
+
create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, 1, {"name": "before"})
|
|
36
38
|
)
|
|
37
39
|
envelope["event"]["payload"] = {"name": "after"}
|
|
38
40
|
|
|
@@ -42,44 +44,37 @@ class IdentityTests(unittest.TestCase):
|
|
|
42
44
|
def test_rejects_nonce_reuse(self):
|
|
43
45
|
signer = AgentSigner.from_seed(bytes([9]) * 32)
|
|
44
46
|
envelope = signer.sign_event(
|
|
45
|
-
create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000,
|
|
47
|
+
create_event("agent-profile/1.0", "profile.update", signer.agent_id(), 1000, 1, {"name": "ResearchAgent"})
|
|
46
48
|
)
|
|
47
49
|
store = MemoryNonceStore()
|
|
48
50
|
|
|
49
|
-
verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
|
|
51
|
+
self.assertEqual(verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000), 1)
|
|
50
52
|
with self.assertRaises(Exception):
|
|
51
53
|
verify_live_envelope(envelope, store, now_ms=1000, window_ms=1000)
|
|
52
54
|
|
|
55
|
+
def test_client_nonce_manager_observes_server_max(self):
|
|
56
|
+
manager = ClientNonceManager()
|
|
57
|
+
|
|
58
|
+
self.assertEqual(manager.next_nonce(), 1)
|
|
59
|
+
manager.observe_max_nonce("5")
|
|
60
|
+
|
|
61
|
+
self.assertEqual(manager.peek(), 6)
|
|
62
|
+
self.assertEqual(manager.next_nonce(), 6)
|
|
63
|
+
|
|
53
64
|
def test_signs_and_verifies_request_jwts(self):
|
|
54
65
|
signer = AgentSigner.from_seed(bytes([10]) * 32)
|
|
55
|
-
binding = RequestBinding.create("https://api.example.com"
|
|
56
|
-
claims = create_request_jwt_claims(signer.agent_id(), binding, 100, 300
|
|
66
|
+
binding = RequestBinding.create("https://api.example.com")
|
|
67
|
+
claims = create_request_jwt_claims(signer.agent_id(), binding, 100, 300)
|
|
57
68
|
token = signer.sign_request_jwt(claims)
|
|
58
|
-
store = MemoryNonceStore()
|
|
59
69
|
|
|
60
|
-
verified =
|
|
70
|
+
verified = verify_request_jwt(
|
|
61
71
|
token,
|
|
62
|
-
store,
|
|
63
72
|
audience=binding.audience,
|
|
64
|
-
method=binding.method,
|
|
65
|
-
host=binding.host,
|
|
66
|
-
path=binding.path,
|
|
67
73
|
now_secs=120,
|
|
68
74
|
max_ttl_secs=300,
|
|
69
75
|
)
|
|
70
76
|
|
|
71
|
-
self.assertEqual(verified["
|
|
72
|
-
with self.assertRaises(Exception):
|
|
73
|
-
verify_request_jwt_live(
|
|
74
|
-
token,
|
|
75
|
-
store,
|
|
76
|
-
audience=binding.audience,
|
|
77
|
-
method=binding.method,
|
|
78
|
-
host=binding.host,
|
|
79
|
-
path=binding.path,
|
|
80
|
-
now_secs=120,
|
|
81
|
-
max_ttl_secs=300,
|
|
82
|
-
)
|
|
77
|
+
self.assertEqual(verified["iss"], signer.agent_id())
|
|
83
78
|
|
|
84
79
|
|
|
85
80
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from agent_protocols.identity import AgentSigner
|
|
4
|
+
from agent_protocols.profile import materialize_profile, profile_update_event, validate_profile_update
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ProfileTests(unittest.TestCase):
|
|
8
|
+
def test_materializes_valid_profile_update(self):
|
|
9
|
+
signer = AgentSigner.from_seed(bytes([11]) * 32)
|
|
10
|
+
payload = {
|
|
11
|
+
"id": signer.agent_id(),
|
|
12
|
+
"name": "ResearchAgent-v3",
|
|
13
|
+
"capabilities": ["research"],
|
|
14
|
+
"extra": {"domain": "research"},
|
|
15
|
+
"links": [{"name": "Homepage", "url": "https://example.com", "rel": "homepage"}],
|
|
16
|
+
}
|
|
17
|
+
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload))
|
|
18
|
+
|
|
19
|
+
profile = materialize_profile(envelope)
|
|
20
|
+
|
|
21
|
+
self.assertEqual(profile["id"], signer.agent_id())
|
|
22
|
+
self.assertEqual(profile["name"], "ResearchAgent-v3")
|
|
23
|
+
self.assertEqual(profile["links"], payload["links"])
|
|
24
|
+
self.assertEqual(profile["extra"], payload["extra"])
|
|
25
|
+
self.assertEqual(profile["updated_at"], 1_779_753_600_000)
|
|
26
|
+
self.assertEqual(profile["event_id"], envelope["hash"])
|
|
27
|
+
|
|
28
|
+
def test_rejects_actor_payload_mismatch(self):
|
|
29
|
+
signer = AgentSigner.from_seed(bytes([12]) * 32)
|
|
30
|
+
other = AgentSigner.from_seed(bytes([13]) * 32)
|
|
31
|
+
payload = {"id": other.agent_id(), "name": "Imposter"}
|
|
32
|
+
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload))
|
|
33
|
+
|
|
34
|
+
with self.assertRaises(Exception):
|
|
35
|
+
validate_profile_update(envelope)
|
|
36
|
+
|
|
37
|
+
def test_materializes_legacy_agent_id_payload(self):
|
|
38
|
+
signer = AgentSigner.from_seed(bytes([14]) * 32)
|
|
39
|
+
payload = {"agent_id": signer.agent_id(), "name": "LegacyAgent"}
|
|
40
|
+
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_001, 1, payload))
|
|
41
|
+
|
|
42
|
+
profile = materialize_profile(envelope)
|
|
43
|
+
|
|
44
|
+
self.assertEqual(profile["id"], signer.agent_id())
|
|
45
|
+
self.assertEqual(profile["name"], "LegacyAgent")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
unittest.main()
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
try:
|
|
6
|
-
import requests
|
|
7
|
-
except ImportError: # pragma: no cover
|
|
8
|
-
requests = None # type: ignore[assignment]
|
|
9
|
-
|
|
10
|
-
from .identity import AgentId, Envelope
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class ProfileClient:
|
|
14
|
-
def __init__(self, base_url: str, session: Any | None = None):
|
|
15
|
-
self.base_url = base_url.rstrip("/")
|
|
16
|
-
self.session = session or _requests_session()
|
|
17
|
-
|
|
18
|
-
def get_profile(self, agent_id: AgentId) -> dict[str, Any]:
|
|
19
|
-
return self._get(f"/v1/profiles/{agent_id}")
|
|
20
|
-
|
|
21
|
-
def submit_profile_update(self, envelope: Envelope) -> dict[str, Any]:
|
|
22
|
-
return self._post("/v1/profiles", envelope)
|
|
23
|
-
|
|
24
|
-
def _get(self, path: str) -> dict[str, Any]:
|
|
25
|
-
response = self.session.get(self.base_url + path)
|
|
26
|
-
response.raise_for_status()
|
|
27
|
-
return response.json()
|
|
28
|
-
|
|
29
|
-
def _post(self, path: str, body: Any) -> dict[str, Any]:
|
|
30
|
-
response = self.session.post(self.base_url + path, json=body)
|
|
31
|
-
response.raise_for_status()
|
|
32
|
-
return response.json()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class DiscourseClient:
|
|
36
|
-
def __init__(self, base_url: str, session: Any | None = None):
|
|
37
|
-
self.base_url = base_url.rstrip("/")
|
|
38
|
-
self.session = session or _requests_session()
|
|
39
|
-
|
|
40
|
-
def protocol(self) -> dict[str, Any]:
|
|
41
|
-
return self._get("/v1/protocol")
|
|
42
|
-
|
|
43
|
-
def create_room(self, envelope: Envelope) -> dict[str, Any]:
|
|
44
|
-
return self._post("/v1/rooms", envelope)
|
|
45
|
-
|
|
46
|
-
def join_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
47
|
-
return self._post(f"/v1/rooms/{room_id}/join", envelope)
|
|
48
|
-
|
|
49
|
-
def leave_room(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
50
|
-
return self._post(f"/v1/rooms/{room_id}/leave", envelope)
|
|
51
|
-
|
|
52
|
-
def submit_event(self, room_id: str, envelope: Envelope) -> dict[str, Any]:
|
|
53
|
-
return self._post(f"/v1/rooms/{room_id}/events", envelope)
|
|
54
|
-
|
|
55
|
-
def events(self, room_id: str) -> list[dict[str, Any]]:
|
|
56
|
-
return self._get(f"/v1/rooms/{room_id}/events")
|
|
57
|
-
|
|
58
|
-
def archive(self, room_id: str) -> dict[str, Any]:
|
|
59
|
-
return self._get(f"/v1/rooms/{room_id}/archive")
|
|
60
|
-
|
|
61
|
-
def _get(self, path: str) -> Any:
|
|
62
|
-
response = self.session.get(self.base_url + path)
|
|
63
|
-
response.raise_for_status()
|
|
64
|
-
return response.json()
|
|
65
|
-
|
|
66
|
-
def _post(self, path: str, body: Any) -> Any:
|
|
67
|
-
response = self.session.post(self.base_url + path, json=body)
|
|
68
|
-
response.raise_for_status()
|
|
69
|
-
return response.json()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def _requests_session() -> Any:
|
|
73
|
-
if requests is None:
|
|
74
|
-
raise RuntimeError("Install agent-protocols[http] to use HTTP clients")
|
|
75
|
-
return requests.Session()
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
|
|
3
|
-
from agent_protocols.identity import AgentSigner
|
|
4
|
-
from agent_protocols.profile import materialize_profile, profile_update_event, validate_profile_update
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ProfileTests(unittest.TestCase):
|
|
8
|
-
def test_materializes_valid_profile_update(self):
|
|
9
|
-
signer = AgentSigner.from_seed(bytes([11]) * 32)
|
|
10
|
-
payload = {"agent_id": signer.agent_id(), "name": "ResearchAgent-v3", "capabilities": ["research"]}
|
|
11
|
-
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload))
|
|
12
|
-
|
|
13
|
-
profile = materialize_profile(envelope)
|
|
14
|
-
|
|
15
|
-
self.assertEqual(profile["name"], "ResearchAgent-v3")
|
|
16
|
-
self.assertEqual(profile["updated_at"], 1_779_753_600_000)
|
|
17
|
-
self.assertEqual(profile["profile_event_id"], envelope["event_id"])
|
|
18
|
-
|
|
19
|
-
def test_rejects_actor_payload_mismatch(self):
|
|
20
|
-
signer = AgentSigner.from_seed(bytes([12]) * 32)
|
|
21
|
-
other = AgentSigner.from_seed(bytes([13]) * 32)
|
|
22
|
-
payload = {"agent_id": other.agent_id(), "name": "Imposter"}
|
|
23
|
-
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, "n_profile", payload))
|
|
24
|
-
|
|
25
|
-
with self.assertRaises(Exception):
|
|
26
|
-
validate_profile_update(envelope)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if __name__ == "__main__":
|
|
30
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_protocols-0.1.0 → agent_protocols-0.2.0}/src/agent_protocols.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|