agent-protocols 0.2.0__tar.gz → 0.2.3__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.2.0 → agent_protocols-0.2.3}/PKG-INFO +3 -1
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/README.md +2 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/pyproject.toml +1 -1
- agent_protocols-0.2.3/src/agent_protocols/discourse.py +395 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/identity.py +7 -1
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/profile.py +3 -2
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/PKG-INFO +3 -1
- agent_protocols-0.2.3/tests/test_discourse.py +200 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/tests/test_identity.py +29 -1
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/tests/test_profile.py +14 -5
- agent_protocols-0.2.0/src/agent_protocols/discourse.py +0 -183
- agent_protocols-0.2.0/tests/test_discourse.py +0 -76
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/setup.cfg +0 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/__init__.py +0 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/errors.py +0 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/http_client.py +0 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/requires.txt +0 -0
- {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
@@ -39,3 +39,5 @@ event = profile_update_event(
|
|
|
39
39
|
envelope = signer.sign_event(event)
|
|
40
40
|
profile = materialize_profile(envelope)
|
|
41
41
|
```
|
|
42
|
+
|
|
43
|
+
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
@@ -25,3 +25,5 @@ event = profile_update_event(
|
|
|
25
25
|
envelope = signer.sign_event(event)
|
|
26
26
|
profile = materialize_profile(envelope)
|
|
27
27
|
```
|
|
28
|
+
|
|
29
|
+
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
import rfc8785
|
|
8
|
+
|
|
9
|
+
from .errors import AgentProtocolError
|
|
10
|
+
from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
|
|
11
|
+
|
|
12
|
+
DISCOURSE_PROTOCOL = "agent-discourse/1.0"
|
|
13
|
+
|
|
14
|
+
ROOM_CREATE = "room.create"
|
|
15
|
+
ROOM_JOIN = "room.join"
|
|
16
|
+
ROOM_JOIN_REVIEW = "room.join.review"
|
|
17
|
+
ROOM_LEAVE = "room.leave"
|
|
18
|
+
ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
|
|
19
|
+
ROOM_CLOSE = "room.close"
|
|
20
|
+
ROOM_CANCEL = "room.cancel"
|
|
21
|
+
MESSAGE_CREATE = "message.create"
|
|
22
|
+
REACTION_CREATE = "reaction.create"
|
|
23
|
+
MESSAGE_PROPOSAL_CREATE = "message.proposal.create"
|
|
24
|
+
MESSAGE_POLL_CREATE = "message.poll.create"
|
|
25
|
+
MESSAGE_POLL_VOTE = "message.poll.vote"
|
|
26
|
+
MESSAGE_RESOLUTION_CREATE = "message.resolution.create"
|
|
27
|
+
SOURCE_ADD = "source.add"
|
|
28
|
+
TURN_UPDATE = "turn.update"
|
|
29
|
+
QUESTION_CREATE = "question.create"
|
|
30
|
+
ROOM_STEER = "room.steer"
|
|
31
|
+
MAP_UPDATE = "map.update"
|
|
32
|
+
ARTIFACT_CREATE = "artifact.create"
|
|
33
|
+
SESSION_OFFER = "session.offer"
|
|
34
|
+
SESSION_ANSWER = "session.answer"
|
|
35
|
+
SESSION_CANDIDATE = "session.candidate"
|
|
36
|
+
SESSION_CLOSE = "session.close"
|
|
37
|
+
|
|
38
|
+
KNOWN_EVENT_TYPES = {
|
|
39
|
+
ROOM_CREATE,
|
|
40
|
+
ROOM_JOIN,
|
|
41
|
+
ROOM_JOIN_REVIEW,
|
|
42
|
+
ROOM_LEAVE,
|
|
43
|
+
ROOM_MEMBER_ROLE_UPDATE,
|
|
44
|
+
ROOM_CLOSE,
|
|
45
|
+
ROOM_CANCEL,
|
|
46
|
+
MESSAGE_CREATE,
|
|
47
|
+
REACTION_CREATE,
|
|
48
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
49
|
+
MESSAGE_POLL_CREATE,
|
|
50
|
+
MESSAGE_POLL_VOTE,
|
|
51
|
+
MESSAGE_RESOLUTION_CREATE,
|
|
52
|
+
SOURCE_ADD,
|
|
53
|
+
TURN_UPDATE,
|
|
54
|
+
QUESTION_CREATE,
|
|
55
|
+
ROOM_STEER,
|
|
56
|
+
MAP_UPDATE,
|
|
57
|
+
ARTIFACT_CREATE,
|
|
58
|
+
SESSION_OFFER,
|
|
59
|
+
SESSION_ANSWER,
|
|
60
|
+
SESSION_CANDIDATE,
|
|
61
|
+
SESSION_CLOSE,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
RoomState = Literal["scheduled", "active", "ended", "cancelled"]
|
|
65
|
+
Role = Literal["moderator", "expert", "participant", "observer"]
|
|
66
|
+
JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
|
|
67
|
+
SESSION_MEDIA_KINDS = {"audio", "video", "screen", "data", "file"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PermissionContext(TypedDict, total=False):
|
|
71
|
+
role: Role
|
|
72
|
+
is_creator: bool
|
|
73
|
+
join_request_approved: bool
|
|
74
|
+
moderator_authorized: bool
|
|
75
|
+
expert_policy_allowed: bool
|
|
76
|
+
participant_policy_allowed: bool
|
|
77
|
+
observer_steering_allowed: bool
|
|
78
|
+
observer_poll_vote_allowed: bool
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
|
|
82
|
+
return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
|
|
86
|
+
return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_discourse_envelope(envelope: Envelope) -> None:
|
|
90
|
+
verify_envelope(envelope)
|
|
91
|
+
event = envelope["event"]
|
|
92
|
+
protocol = event["protocol"]
|
|
93
|
+
if protocol != DISCOURSE_PROTOCOL:
|
|
94
|
+
raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
|
|
95
|
+
if event_requires_room_id(event["type"]) and "room_id" not in event:
|
|
96
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
|
|
100
|
+
actual = envelope["event"].get("room_id")
|
|
101
|
+
if actual is None and envelope["event"]["type"] == ROOM_CREATE:
|
|
102
|
+
return
|
|
103
|
+
if actual is None:
|
|
104
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
105
|
+
if actual != path_room_id:
|
|
106
|
+
raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def event_requires_room_id(event_type: str) -> bool:
|
|
110
|
+
return event_type != ROOM_CREATE
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def validate_room_create_payload(payload: dict[str, Any]) -> None:
|
|
114
|
+
if not str(payload.get("topic", "")).strip():
|
|
115
|
+
raise AgentProtocolError("invalid_room", "room topic must not be empty")
|
|
116
|
+
if payload.get("start_time", 0) >= payload.get("end_time", 0):
|
|
117
|
+
raise AgentProtocolError("invalid_room", "start_time must be before end_time")
|
|
118
|
+
policy = payload.get("policy") or {}
|
|
119
|
+
max_participants = policy.get("max_participants")
|
|
120
|
+
if max_participants is not None and (
|
|
121
|
+
not isinstance(max_participants, int) or max_participants < 1
|
|
122
|
+
):
|
|
123
|
+
raise AgentProtocolError("invalid_room", "max_participants must be a positive integer")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_poll_create_payload(payload: dict[str, Any]) -> None:
|
|
127
|
+
if not str(payload.get("poll_id", "")).strip() or not str(payload.get("question", "")).strip():
|
|
128
|
+
raise AgentProtocolError("invalid_poll", "poll_id and question are required")
|
|
129
|
+
options = payload.get("options", [])
|
|
130
|
+
if len(options) < 2:
|
|
131
|
+
raise AgentProtocolError("invalid_poll", "poll requires at least two options")
|
|
132
|
+
option_ids: set[str] = set()
|
|
133
|
+
for option in options:
|
|
134
|
+
option_id = str(option.get("id", ""))
|
|
135
|
+
label = str(option.get("label", ""))
|
|
136
|
+
if not option_id.strip() or not label.strip():
|
|
137
|
+
raise AgentProtocolError("invalid_poll", "option id and label are required")
|
|
138
|
+
if option_id in option_ids:
|
|
139
|
+
raise AgentProtocolError("invalid_poll", "poll option ids must be unique")
|
|
140
|
+
option_ids.add(option_id)
|
|
141
|
+
min_choices = payload.get("min_choices", 1)
|
|
142
|
+
max_choices = payload.get("max_choices", 1)
|
|
143
|
+
if min_choices < 1 or max_choices < min_choices:
|
|
144
|
+
raise AgentProtocolError("invalid_poll", "invalid poll choice limits")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def validate_poll_vote_payload(payload: dict[str, Any], poll: dict[str, Any], now_ms: int | None = None) -> None:
|
|
148
|
+
if poll.get("closes_at") is not None and now_ms is not None and now_ms > poll["closes_at"]:
|
|
149
|
+
raise AgentProtocolError("poll_closed", "poll is closed")
|
|
150
|
+
min_choices = poll.get("min_choices", 1)
|
|
151
|
+
max_choices = poll.get("max_choices", 1)
|
|
152
|
+
option_ids = {option["id"] for option in poll.get("options", [])}
|
|
153
|
+
selected = payload.get("option_ids", [])
|
|
154
|
+
selected_set = set(selected)
|
|
155
|
+
if len(selected_set) != len(selected):
|
|
156
|
+
raise AgentProtocolError("invalid_poll_vote", "duplicate poll options")
|
|
157
|
+
if len(selected_set) < min_choices or len(selected_set) > max_choices:
|
|
158
|
+
raise AgentProtocolError("invalid_poll_vote", "invalid number of options")
|
|
159
|
+
if any(option_id not in option_ids for option_id in selected_set):
|
|
160
|
+
raise AgentProtocolError("invalid_poll_vote", "unknown poll option")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_session_offer_payload(payload: dict[str, Any]) -> None:
|
|
164
|
+
_validate_session_id(payload.get("session_id"))
|
|
165
|
+
if payload.get("session_type") != "webrtc":
|
|
166
|
+
raise AgentProtocolError("invalid_session", "session_type must be webrtc")
|
|
167
|
+
media = payload.get("media", [])
|
|
168
|
+
if not isinstance(media, list) or not media:
|
|
169
|
+
raise AgentProtocolError("invalid_session", "media must not be empty")
|
|
170
|
+
if any(media_kind not in SESSION_MEDIA_KINDS for media_kind in media):
|
|
171
|
+
raise AgentProtocolError("invalid_session", "unsupported media kind")
|
|
172
|
+
_validate_session_description(payload.get("description"), "offer")
|
|
173
|
+
_validate_session_transfers(payload.get("transfers", []))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_session_answer_payload(payload: dict[str, Any]) -> None:
|
|
177
|
+
_validate_session_id(payload.get("session_id"))
|
|
178
|
+
if not str(payload.get("offer_event_id", "")).strip():
|
|
179
|
+
raise AgentProtocolError("invalid_session", "offer_event_id is required")
|
|
180
|
+
_validate_session_description(payload.get("description"), "answer")
|
|
181
|
+
_validate_session_transfers(payload.get("transfers", []))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def validate_session_candidate_payload(payload: dict[str, Any]) -> None:
|
|
185
|
+
_validate_session_id(payload.get("session_id"))
|
|
186
|
+
if payload.get("end_of_candidates"):
|
|
187
|
+
return
|
|
188
|
+
candidate = payload.get("candidate") or {}
|
|
189
|
+
if not str(candidate.get("candidate", "")).strip():
|
|
190
|
+
raise AgentProtocolError("invalid_session", "candidate is required unless end_of_candidates is true")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def server_record_hash_payload(
|
|
194
|
+
room_id: str,
|
|
195
|
+
seq: int,
|
|
196
|
+
pre_hash: str | None,
|
|
197
|
+
envelope_hash: str,
|
|
198
|
+
received_at: int,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
return {
|
|
201
|
+
"room_id": room_id,
|
|
202
|
+
"seq": seq,
|
|
203
|
+
"pre_hash": pre_hash,
|
|
204
|
+
"envelope_hash": envelope_hash,
|
|
205
|
+
"received_at": received_at,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def server_record_hash(
|
|
210
|
+
room_id: str,
|
|
211
|
+
seq: int,
|
|
212
|
+
pre_hash: str | None,
|
|
213
|
+
envelope_hash: str,
|
|
214
|
+
received_at: int,
|
|
215
|
+
) -> str:
|
|
216
|
+
return _hash_canonical_json(server_record_hash_payload(room_id, seq, pre_hash, envelope_hash, received_at))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def build_server_record(
|
|
220
|
+
room_id: str,
|
|
221
|
+
seq: int,
|
|
222
|
+
pre_hash: str | None,
|
|
223
|
+
received_at: int,
|
|
224
|
+
envelope: Envelope,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
"room_id": room_id,
|
|
228
|
+
"seq": seq,
|
|
229
|
+
"pre_hash": pre_hash,
|
|
230
|
+
"hash": server_record_hash(room_id, seq, pre_hash, envelope["hash"], received_at),
|
|
231
|
+
"received_at": received_at,
|
|
232
|
+
"envelope": envelope,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def verify_server_record(record: dict[str, Any]) -> None:
|
|
237
|
+
expected = server_record_hash(
|
|
238
|
+
record["room_id"],
|
|
239
|
+
record["seq"],
|
|
240
|
+
record.get("pre_hash"),
|
|
241
|
+
record["envelope"]["hash"],
|
|
242
|
+
record["received_at"],
|
|
243
|
+
)
|
|
244
|
+
if record["hash"] != expected:
|
|
245
|
+
raise AgentProtocolError("invalid_record_hash", f"invalid server record hash: expected {expected}, got {record['hash']}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def verify_server_record_chain(records: list[dict[str, Any]]) -> None:
|
|
249
|
+
previous: dict[str, Any] | None = None
|
|
250
|
+
for record in records:
|
|
251
|
+
verify_server_record(record)
|
|
252
|
+
if previous is None:
|
|
253
|
+
if record["seq"] != 1:
|
|
254
|
+
raise AgentProtocolError("invalid_record_chain", "first seq must be 1")
|
|
255
|
+
if record.get("pre_hash") is not None:
|
|
256
|
+
raise AgentProtocolError("invalid_record_chain", "first pre_hash must be null")
|
|
257
|
+
else:
|
|
258
|
+
if record["seq"] != previous["seq"] + 1:
|
|
259
|
+
raise AgentProtocolError("invalid_record_chain", "seq must increase by 1")
|
|
260
|
+
if record.get("pre_hash") != previous["hash"]:
|
|
261
|
+
raise AgentProtocolError("invalid_record_chain", "pre_hash mismatch")
|
|
262
|
+
previous = record
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def archive_events_digest(records: list[dict[str, Any]]) -> str:
|
|
266
|
+
return _hash_canonical_json(records)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def can_submit_event(event_type: str, context: PermissionContext) -> bool:
|
|
270
|
+
if event_type == ROOM_CREATE:
|
|
271
|
+
return True
|
|
272
|
+
if event_type == ROOM_JOIN:
|
|
273
|
+
return context.get("join_request_approved", False)
|
|
274
|
+
if context.get("is_creator"):
|
|
275
|
+
return event_type in KNOWN_EVENT_TYPES
|
|
276
|
+
|
|
277
|
+
role = context.get("role")
|
|
278
|
+
if role == "moderator":
|
|
279
|
+
return _moderator_can_submit(event_type, context.get("moderator_authorized", False))
|
|
280
|
+
if role == "expert":
|
|
281
|
+
return _speaker_can_submit(event_type, context.get("expert_policy_allowed", False))
|
|
282
|
+
if role == "participant":
|
|
283
|
+
return _speaker_can_submit(event_type, context.get("participant_policy_allowed", False))
|
|
284
|
+
if role == "observer":
|
|
285
|
+
return _observer_can_submit(event_type, context)
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
290
|
+
if state == "scheduled":
|
|
291
|
+
return event_type in {ROOM_JOIN, ROOM_JOIN_REVIEW, ROOM_CANCEL}
|
|
292
|
+
if state == "active":
|
|
293
|
+
return event_type not in {ROOM_CREATE, ROOM_CANCEL}
|
|
294
|
+
if state == "ended":
|
|
295
|
+
return post_end_reaction_allowed and event_type == REACTION_CREATE
|
|
296
|
+
if state == "cancelled":
|
|
297
|
+
return False
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def can_accept_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
302
|
+
return can_submit_event(event_type, context) and can_write_in_state(event_type, state, post_end_reaction_allowed=post_end_reaction_allowed)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def validate_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> None:
|
|
306
|
+
if not can_accept_room_write(event_type, state, context, post_end_reaction_allowed=post_end_reaction_allowed):
|
|
307
|
+
raise AgentProtocolError("permission_denied", "actor lacks permission or state is not writable")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
|
|
311
|
+
allowed = {
|
|
312
|
+
ROOM_JOIN_REVIEW,
|
|
313
|
+
ROOM_CLOSE,
|
|
314
|
+
MESSAGE_CREATE,
|
|
315
|
+
SOURCE_ADD,
|
|
316
|
+
TURN_UPDATE,
|
|
317
|
+
QUESTION_CREATE,
|
|
318
|
+
ROOM_STEER,
|
|
319
|
+
MAP_UPDATE,
|
|
320
|
+
ARTIFACT_CREATE,
|
|
321
|
+
SESSION_OFFER,
|
|
322
|
+
SESSION_ANSWER,
|
|
323
|
+
SESSION_CANDIDATE,
|
|
324
|
+
SESSION_CLOSE,
|
|
325
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
326
|
+
MESSAGE_POLL_CREATE,
|
|
327
|
+
MESSAGE_POLL_VOTE,
|
|
328
|
+
MESSAGE_RESOLUTION_CREATE,
|
|
329
|
+
REACTION_CREATE,
|
|
330
|
+
ROOM_LEAVE,
|
|
331
|
+
}
|
|
332
|
+
return event_type in allowed or (moderator_authorized and event_type in {ROOM_MEMBER_ROLE_UPDATE, ROOM_CANCEL})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
|
|
336
|
+
allowed = {
|
|
337
|
+
MESSAGE_CREATE,
|
|
338
|
+
SOURCE_ADD,
|
|
339
|
+
ROOM_STEER,
|
|
340
|
+
MESSAGE_PROPOSAL_CREATE,
|
|
341
|
+
MESSAGE_POLL_CREATE,
|
|
342
|
+
MESSAGE_POLL_VOTE,
|
|
343
|
+
SESSION_OFFER,
|
|
344
|
+
SESSION_ANSWER,
|
|
345
|
+
SESSION_CANDIDATE,
|
|
346
|
+
SESSION_CLOSE,
|
|
347
|
+
REACTION_CREATE,
|
|
348
|
+
ROOM_LEAVE,
|
|
349
|
+
}
|
|
350
|
+
policy_events = {QUESTION_CREATE, MAP_UPDATE, ARTIFACT_CREATE, MESSAGE_RESOLUTION_CREATE}
|
|
351
|
+
return event_type in allowed or (policy_allowed and event_type in policy_events)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
|
|
355
|
+
return (
|
|
356
|
+
event_type in {REACTION_CREATE, ROOM_LEAVE}
|
|
357
|
+
or (context.get("observer_steering_allowed", False) and event_type == ROOM_STEER)
|
|
358
|
+
or (context.get("observer_poll_vote_allowed", False) and event_type == MESSAGE_POLL_VOTE)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _validate_session_id(session_id: Any) -> None:
|
|
363
|
+
if not str(session_id or "").strip():
|
|
364
|
+
raise AgentProtocolError("invalid_session", "session_id is required")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _validate_session_description(description: Any, expected_type: str) -> None:
|
|
368
|
+
if not isinstance(description, dict):
|
|
369
|
+
raise AgentProtocolError("invalid_session", "session description is required")
|
|
370
|
+
if description.get("type") != expected_type:
|
|
371
|
+
raise AgentProtocolError("invalid_session", f"session description type must be {expected_type}")
|
|
372
|
+
if not str(description.get("sdp", "")).strip():
|
|
373
|
+
raise AgentProtocolError("invalid_session", "session description sdp is required")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _validate_session_transfers(transfers: Any) -> None:
|
|
377
|
+
if transfers is None:
|
|
378
|
+
return
|
|
379
|
+
if not isinstance(transfers, list):
|
|
380
|
+
raise AgentProtocolError("invalid_session", "transfers must be an array")
|
|
381
|
+
for transfer in transfers:
|
|
382
|
+
if not isinstance(transfer, dict):
|
|
383
|
+
raise AgentProtocolError("invalid_session", "transfer must be an object")
|
|
384
|
+
if not str(transfer.get("transfer_id", "")).strip():
|
|
385
|
+
raise AgentProtocolError("invalid_session", "transfer_id is required")
|
|
386
|
+
size_bytes = transfer.get("size_bytes")
|
|
387
|
+
if size_bytes is not None and (not isinstance(size_bytes, int) or size_bytes < 0):
|
|
388
|
+
raise AgentProtocolError("invalid_session", "size_bytes must be a non-negative integer")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _hash_canonical_json(value: Any) -> str:
|
|
392
|
+
canonical = rfc8785.dumps(value)
|
|
393
|
+
data = canonical if isinstance(canonical, bytes) else canonical.encode()
|
|
394
|
+
digest = hashlib.sha3_256(data).digest()
|
|
395
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
@@ -20,6 +20,7 @@ DEFAULT_LIVE_WRITE_WINDOW_MS = 300_000
|
|
|
20
20
|
DEFAULT_NONCE_TTL_MS = 300_000
|
|
21
21
|
DEFAULT_REQUEST_JWT_TTL_SECS = 300
|
|
22
22
|
MAX_NONCE_HEADER = "Max-Seen-Nonce"
|
|
23
|
+
MAX_SAFE_NONCE = 0x1FFFFFFFFFFFFF
|
|
23
24
|
|
|
24
25
|
Event = dict[str, Any]
|
|
25
26
|
Envelope = dict[str, Any]
|
|
@@ -134,6 +135,7 @@ class ClientNonceManager:
|
|
|
134
135
|
|
|
135
136
|
def next_nonce(self) -> int:
|
|
136
137
|
nonce = self._next_nonce
|
|
138
|
+
validate_nonce(nonce)
|
|
137
139
|
self._next_nonce += 1
|
|
138
140
|
return nonce
|
|
139
141
|
|
|
@@ -174,11 +176,13 @@ def canonical_event_bytes(event: Event) -> bytes:
|
|
|
174
176
|
|
|
175
177
|
|
|
176
178
|
def event_hash(event: Event) -> str:
|
|
179
|
+
validate_nonce(event["nonce"])
|
|
177
180
|
digest = hashlib.sha3_256(canonical_event_bytes(event)).digest()
|
|
178
181
|
return _base64url_encode(digest)
|
|
179
182
|
|
|
180
183
|
|
|
181
184
|
def sign_event(private_key: Ed25519PrivateKey, event: Event) -> str:
|
|
185
|
+
validate_nonce(event["nonce"])
|
|
182
186
|
return _base64url_encode(private_key.sign(canonical_event_bytes(event)))
|
|
183
187
|
|
|
184
188
|
|
|
@@ -253,6 +257,8 @@ def verify_request_jwt(token: str, *, audience: str, now_secs: int | None = None
|
|
|
253
257
|
raise AgentProtocolError("invalid_jwt_claim", "aud mismatch")
|
|
254
258
|
|
|
255
259
|
now = now_secs if now_secs is not None else unix_secs()
|
|
260
|
+
if claims["exp"] <= claims["iat"]:
|
|
261
|
+
raise AgentProtocolError("invalid_jwt_claim", "exp must be greater than iat")
|
|
256
262
|
if claims["iat"] > now or claims["exp"] < now:
|
|
257
263
|
raise AgentProtocolError("invalid_jwt_claim", "iat/exp outside valid time window")
|
|
258
264
|
if claims["exp"] - claims["iat"] > max_ttl_secs:
|
|
@@ -269,7 +275,7 @@ def unix_secs() -> int:
|
|
|
269
275
|
|
|
270
276
|
|
|
271
277
|
def validate_nonce(nonce: int) -> None:
|
|
272
|
-
if not isinstance(nonce, int) or nonce < 1 or nonce >
|
|
278
|
+
if not isinstance(nonce, int) or nonce < 1 or nonce > MAX_SAFE_NONCE:
|
|
273
279
|
raise AgentProtocolError("invalid_nonce", "nonce must be a positive integer less than or equal to 9007199254740991")
|
|
274
280
|
|
|
275
281
|
|
|
@@ -19,7 +19,7 @@ def profile_update_event(actor: AgentId, created_at: int, nonce: int, payload: P
|
|
|
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")
|
|
22
|
+
payload_id = event["payload"].get("id")
|
|
23
23
|
if event["protocol"] != PROFILE_PROTOCOL:
|
|
24
24
|
raise AgentProtocolError("invalid_event_protocol", f"expected {PROFILE_PROTOCOL}, got {event['protocol']}")
|
|
25
25
|
if event["type"] != PROFILE_UPDATE:
|
|
@@ -31,10 +31,11 @@ def validate_profile_update(envelope: Envelope) -> None:
|
|
|
31
31
|
def materialize_profile(envelope: Envelope) -> AgentProfile:
|
|
32
32
|
validate_profile_update(envelope)
|
|
33
33
|
payload = envelope["event"]["payload"]
|
|
34
|
-
payload_id = payload.get("id")
|
|
34
|
+
payload_id = payload.get("id")
|
|
35
35
|
return {
|
|
36
36
|
"id": payload_id,
|
|
37
37
|
"name": payload["name"],
|
|
38
|
+
"username": None,
|
|
38
39
|
"description": payload.get("description"),
|
|
39
40
|
"avatar_url": payload.get("avatar_url"),
|
|
40
41
|
"provider": payload.get("provider"),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-protocols
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
|
|
5
5
|
Author: LDCLabs
|
|
6
6
|
License: MIT
|
|
@@ -39,3 +39,5 @@ event = profile_update_event(
|
|
|
39
39
|
envelope = signer.sign_event(event)
|
|
40
40
|
profile = materialize_profile(envelope)
|
|
41
41
|
```
|
|
42
|
+
|
|
43
|
+
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from agent_protocols.discourse import (
|
|
4
|
+
MESSAGE_CREATE,
|
|
5
|
+
REACTION_CREATE,
|
|
6
|
+
ROOM_CANCEL,
|
|
7
|
+
ROOM_CREATE,
|
|
8
|
+
ROOM_JOIN,
|
|
9
|
+
ROOM_JOIN_REVIEW,
|
|
10
|
+
SESSION_CANDIDATE,
|
|
11
|
+
SESSION_OFFER,
|
|
12
|
+
archive_events_digest,
|
|
13
|
+
build_server_record,
|
|
14
|
+
can_accept_room_write,
|
|
15
|
+
can_submit_event,
|
|
16
|
+
room_create_event,
|
|
17
|
+
server_record_hash,
|
|
18
|
+
validate_poll_create_payload,
|
|
19
|
+
validate_poll_vote_payload,
|
|
20
|
+
validate_discourse_envelope,
|
|
21
|
+
validate_room_create_payload,
|
|
22
|
+
validate_room_path,
|
|
23
|
+
validate_session_answer_payload,
|
|
24
|
+
validate_session_candidate_payload,
|
|
25
|
+
validate_session_offer_payload,
|
|
26
|
+
verify_server_record,
|
|
27
|
+
verify_server_record_chain,
|
|
28
|
+
)
|
|
29
|
+
from agent_protocols.http_client import websocket_events_url
|
|
30
|
+
from agent_protocols.identity import AgentSigner, create_event
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DiscourseTests(unittest.TestCase):
|
|
34
|
+
def test_validates_room_create_without_room_id(self):
|
|
35
|
+
signer = AgentSigner.from_seed(bytes([14]) * 32)
|
|
36
|
+
event = room_create_event(
|
|
37
|
+
signer.agent_id(),
|
|
38
|
+
100,
|
|
39
|
+
1,
|
|
40
|
+
{"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
|
|
41
|
+
)
|
|
42
|
+
envelope = signer.sign_event(event)
|
|
43
|
+
|
|
44
|
+
validate_discourse_envelope(envelope)
|
|
45
|
+
validate_room_path(envelope, "d8ftedhpqhsusbg001tg")
|
|
46
|
+
|
|
47
|
+
def test_rejects_room_event_without_room_id(self):
|
|
48
|
+
signer = AgentSigner.from_seed(bytes([15]) * 32)
|
|
49
|
+
event = create_event(
|
|
50
|
+
"agent-discourse/1.0",
|
|
51
|
+
MESSAGE_CREATE,
|
|
52
|
+
signer.agent_id(),
|
|
53
|
+
100,
|
|
54
|
+
1,
|
|
55
|
+
{"content_type": "text/plain", "content": "hello"},
|
|
56
|
+
)
|
|
57
|
+
envelope = signer.sign_event(event)
|
|
58
|
+
|
|
59
|
+
with self.assertRaises(Exception):
|
|
60
|
+
validate_discourse_envelope(envelope)
|
|
61
|
+
|
|
62
|
+
def test_applies_permission_matrix(self):
|
|
63
|
+
self.assertTrue(can_submit_event(REACTION_CREATE, {"role": "observer"}))
|
|
64
|
+
self.assertFalse(can_submit_event(MESSAGE_CREATE, {"role": "observer"}))
|
|
65
|
+
self.assertFalse(can_submit_event(ROOM_JOIN, {"role": "observer"}))
|
|
66
|
+
self.assertTrue(can_submit_event(ROOM_JOIN, {"join_request_approved": True}))
|
|
67
|
+
self.assertTrue(can_submit_event(ROOM_JOIN_REVIEW, {"role": "moderator"}))
|
|
68
|
+
self.assertFalse(can_submit_event(ROOM_JOIN_REVIEW, {"role": "participant"}))
|
|
69
|
+
self.assertFalse(can_submit_event(ROOM_CANCEL, {"role": "moderator"}))
|
|
70
|
+
self.assertTrue(can_submit_event(ROOM_CANCEL, {"role": "moderator", "moderator_authorized": True}))
|
|
71
|
+
self.assertTrue(can_submit_event(SESSION_OFFER, {"role": "participant"}))
|
|
72
|
+
self.assertFalse(can_submit_event(SESSION_CANDIDATE, {"role": "observer"}))
|
|
73
|
+
self.assertTrue(can_submit_event(ROOM_CREATE, {}))
|
|
74
|
+
|
|
75
|
+
def test_applies_state_restrictions(self):
|
|
76
|
+
self.assertTrue(can_accept_room_write(MESSAGE_CREATE, "active", {"role": "participant"}))
|
|
77
|
+
self.assertFalse(can_accept_room_write(MESSAGE_CREATE, "scheduled", {"role": "participant"}))
|
|
78
|
+
self.assertTrue(can_accept_room_write(ROOM_JOIN_REVIEW, "scheduled", {"role": "moderator"}))
|
|
79
|
+
self.assertTrue(can_accept_room_write(ROOM_JOIN, "scheduled", {"join_request_approved": True}))
|
|
80
|
+
self.assertFalse(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}))
|
|
81
|
+
self.assertTrue(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}, post_end_reaction_allowed=True))
|
|
82
|
+
|
|
83
|
+
def test_validates_room_creation_payloads(self):
|
|
84
|
+
validate_room_create_payload(
|
|
85
|
+
{
|
|
86
|
+
"topic": "Research room",
|
|
87
|
+
"visibility": "public",
|
|
88
|
+
"start_time": 1000,
|
|
89
|
+
"end_time": 2000,
|
|
90
|
+
"policy": {"max_participants": 2},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
with self.assertRaises(Exception):
|
|
94
|
+
validate_room_create_payload({"topic": " ", "visibility": "public", "start_time": 1000, "end_time": 2000})
|
|
95
|
+
with self.assertRaises(Exception):
|
|
96
|
+
validate_room_create_payload(
|
|
97
|
+
{"topic": "Research room", "visibility": "public", "start_time": 2000, "end_time": 1000}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def test_validates_poll_payloads_and_votes(self):
|
|
101
|
+
poll = {
|
|
102
|
+
"poll_id": "poll_review_order",
|
|
103
|
+
"question": "Which review order?",
|
|
104
|
+
"options": [{"id": "a", "label": "Correctness first"}, {"id": "b", "label": "Security first"}],
|
|
105
|
+
"min_choices": 1,
|
|
106
|
+
"max_choices": 1,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
validate_poll_create_payload(poll)
|
|
110
|
+
validate_poll_vote_payload({"event_id": "evt", "option_ids": ["a"]}, poll)
|
|
111
|
+
with self.assertRaises(Exception):
|
|
112
|
+
validate_poll_vote_payload({"event_id": "evt", "option_ids": ["a", "b"]}, poll)
|
|
113
|
+
with self.assertRaises(Exception):
|
|
114
|
+
validate_poll_create_payload(
|
|
115
|
+
{
|
|
116
|
+
**poll,
|
|
117
|
+
"options": [{"id": "a", "label": "Correctness first"}, {"id": "a", "label": "Duplicate"}],
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def test_validates_webrtc_session_payloads(self):
|
|
122
|
+
offer = {
|
|
123
|
+
"session_id": "sess_live_review",
|
|
124
|
+
"session_type": "webrtc",
|
|
125
|
+
"media": ["audio", "video", "file"],
|
|
126
|
+
"description": {"type": "offer", "sdp": "v=0\r\n..."},
|
|
127
|
+
"transfers": [
|
|
128
|
+
{
|
|
129
|
+
"transfer_id": "file_1",
|
|
130
|
+
"file_name": "trace.har",
|
|
131
|
+
"size_bytes": 1024,
|
|
132
|
+
"mime_type": "application/json",
|
|
133
|
+
"content_digest": "sha256:abc",
|
|
134
|
+
}
|
|
135
|
+
],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
validate_session_offer_payload(offer)
|
|
139
|
+
validate_session_answer_payload(
|
|
140
|
+
{
|
|
141
|
+
"session_id": "sess_live_review",
|
|
142
|
+
"offer_event_id": "evt_offer",
|
|
143
|
+
"description": {"type": "answer", "sdp": "v=0\r\n..."},
|
|
144
|
+
"accepted_media": ["audio", "file"],
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
validate_session_candidate_payload(
|
|
148
|
+
{
|
|
149
|
+
"session_id": "sess_live_review",
|
|
150
|
+
"candidate": {"candidate": "candidate:1 1 udp 1 127.0.0.1 3478 typ host"},
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
validate_session_candidate_payload({"session_id": "sess_live_review", "end_of_candidates": True})
|
|
154
|
+
|
|
155
|
+
with self.assertRaisesRegex(Exception, "offer"):
|
|
156
|
+
validate_session_offer_payload({**offer, "description": {"type": "answer", "sdp": "v=0\r\n..."}})
|
|
157
|
+
with self.assertRaisesRegex(Exception, "candidate"):
|
|
158
|
+
validate_session_candidate_payload({"session_id": "sess_live_review"})
|
|
159
|
+
|
|
160
|
+
def test_builds_and_verifies_server_record_chains(self):
|
|
161
|
+
signer = AgentSigner.from_seed(bytes([18]) * 32)
|
|
162
|
+
envelope1 = signer.sign_event(
|
|
163
|
+
room_create_event(
|
|
164
|
+
signer.agent_id(),
|
|
165
|
+
100,
|
|
166
|
+
1,
|
|
167
|
+
{"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
record1 = build_server_record("room123", 1, None, 110, envelope1)
|
|
171
|
+
event2 = create_event(
|
|
172
|
+
"agent-discourse/1.0",
|
|
173
|
+
MESSAGE_CREATE,
|
|
174
|
+
signer.agent_id(),
|
|
175
|
+
120,
|
|
176
|
+
2,
|
|
177
|
+
{"content_type": "text/plain", "content": "hello"},
|
|
178
|
+
)
|
|
179
|
+
event2["room_id"] = "room123"
|
|
180
|
+
envelope2 = signer.sign_event(event2)
|
|
181
|
+
record2 = build_server_record("room123", 2, record1["hash"], 130, envelope2)
|
|
182
|
+
|
|
183
|
+
self.assertEqual(record1["hash"], server_record_hash("room123", 1, None, envelope1["hash"], 110))
|
|
184
|
+
verify_server_record(record1)
|
|
185
|
+
verify_server_record_chain([record1, record2])
|
|
186
|
+
self.assertEqual(len(archive_events_digest([record1, record2])), 43)
|
|
187
|
+
with self.assertRaisesRegex(Exception, "first seq"):
|
|
188
|
+
verify_server_record_chain([record2])
|
|
189
|
+
with self.assertRaises(Exception):
|
|
190
|
+
verify_server_record_chain([{**record2, "pre_hash": "bad"}])
|
|
191
|
+
|
|
192
|
+
def test_builds_websocket_event_stream_url(self):
|
|
193
|
+
self.assertEqual(
|
|
194
|
+
websocket_events_url("https://api.example.com", "room123", "jwt.token"),
|
|
195
|
+
"wss://api.example.com/v1/rooms/room123/events/live?access_token=jwt.token",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
unittest.main()
|
|
@@ -3,6 +3,7 @@ import unittest
|
|
|
3
3
|
from agent_protocols.identity import (
|
|
4
4
|
AgentSigner,
|
|
5
5
|
ClientNonceManager,
|
|
6
|
+
MAX_SAFE_NONCE,
|
|
6
7
|
MemoryNonceStore,
|
|
7
8
|
RequestBinding,
|
|
8
9
|
create_event,
|
|
@@ -22,7 +23,7 @@ class IdentityTests(unittest.TestCase):
|
|
|
22
23
|
signer.agent_id(),
|
|
23
24
|
1_779_753_600_000,
|
|
24
25
|
1,
|
|
25
|
-
{"
|
|
26
|
+
{"id": signer.agent_id(), "name": "ResearchAgent"},
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
envelope = signer.sign_event(event)
|
|
@@ -61,6 +62,19 @@ class IdentityTests(unittest.TestCase):
|
|
|
61
62
|
self.assertEqual(manager.peek(), 6)
|
|
62
63
|
self.assertEqual(manager.next_nonce(), 6)
|
|
63
64
|
|
|
65
|
+
def test_rejects_nonce_values_outside_safe_json_integer_range(self):
|
|
66
|
+
signer = AgentSigner.from_seed(bytes([16]) * 32)
|
|
67
|
+
|
|
68
|
+
with self.assertRaises(Exception):
|
|
69
|
+
create_event(
|
|
70
|
+
"agent-profile/1.0",
|
|
71
|
+
"profile.update",
|
|
72
|
+
signer.agent_id(),
|
|
73
|
+
1000,
|
|
74
|
+
MAX_SAFE_NONCE + 1,
|
|
75
|
+
{"id": signer.agent_id(), "name": "ResearchAgent"},
|
|
76
|
+
)
|
|
77
|
+
|
|
64
78
|
def test_signs_and_verifies_request_jwts(self):
|
|
65
79
|
signer = AgentSigner.from_seed(bytes([10]) * 32)
|
|
66
80
|
binding = RequestBinding.create("https://api.example.com")
|
|
@@ -76,6 +90,20 @@ class IdentityTests(unittest.TestCase):
|
|
|
76
90
|
|
|
77
91
|
self.assertEqual(verified["iss"], signer.agent_id())
|
|
78
92
|
|
|
93
|
+
def test_rejects_request_jwts_with_non_positive_ttl(self):
|
|
94
|
+
signer = AgentSigner.from_seed(bytes([17]) * 32)
|
|
95
|
+
binding = RequestBinding.create("https://api.example.com")
|
|
96
|
+
claims = create_request_jwt_claims(signer.agent_id(), binding, 100, 0)
|
|
97
|
+
token = signer.sign_request_jwt(claims)
|
|
98
|
+
|
|
99
|
+
with self.assertRaises(Exception):
|
|
100
|
+
verify_request_jwt(
|
|
101
|
+
token,
|
|
102
|
+
audience=binding.audience,
|
|
103
|
+
now_secs=100,
|
|
104
|
+
max_ttl_secs=300,
|
|
105
|
+
)
|
|
106
|
+
|
|
79
107
|
|
|
80
108
|
if __name__ == "__main__":
|
|
81
109
|
unittest.main()
|
|
@@ -20,11 +20,22 @@ class ProfileTests(unittest.TestCase):
|
|
|
20
20
|
|
|
21
21
|
self.assertEqual(profile["id"], signer.agent_id())
|
|
22
22
|
self.assertEqual(profile["name"], "ResearchAgent-v3")
|
|
23
|
+
self.assertIsNone(profile["username"])
|
|
23
24
|
self.assertEqual(profile["links"], payload["links"])
|
|
24
25
|
self.assertEqual(profile["extra"], payload["extra"])
|
|
25
26
|
self.assertEqual(profile["updated_at"], 1_779_753_600_000)
|
|
26
27
|
self.assertEqual(profile["event_id"], envelope["hash"])
|
|
27
28
|
|
|
29
|
+
def test_does_not_materialize_unconfirmed_payload_username(self):
|
|
30
|
+
signer = AgentSigner.from_seed(bytes([15]) * 32)
|
|
31
|
+
payload = {"id": signer.agent_id(), "name": "ResearchAgent-v3", "username": "anda"}
|
|
32
|
+
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload))
|
|
33
|
+
|
|
34
|
+
profile = materialize_profile(envelope)
|
|
35
|
+
|
|
36
|
+
self.assertEqual(profile["id"], signer.agent_id())
|
|
37
|
+
self.assertIsNone(profile["username"])
|
|
38
|
+
|
|
28
39
|
def test_rejects_actor_payload_mismatch(self):
|
|
29
40
|
signer = AgentSigner.from_seed(bytes([12]) * 32)
|
|
30
41
|
other = AgentSigner.from_seed(bytes([13]) * 32)
|
|
@@ -34,15 +45,13 @@ class ProfileTests(unittest.TestCase):
|
|
|
34
45
|
with self.assertRaises(Exception):
|
|
35
46
|
validate_profile_update(envelope)
|
|
36
47
|
|
|
37
|
-
def
|
|
48
|
+
def test_rejects_legacy_agent_id_payload_without_id(self):
|
|
38
49
|
signer = AgentSigner.from_seed(bytes([14]) * 32)
|
|
39
50
|
payload = {"agent_id": signer.agent_id(), "name": "LegacyAgent"}
|
|
40
51
|
envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_001, 1, payload))
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
self.assertEqual(profile["id"], signer.agent_id())
|
|
45
|
-
self.assertEqual(profile["name"], "LegacyAgent")
|
|
53
|
+
with self.assertRaises(Exception):
|
|
54
|
+
materialize_profile(envelope)
|
|
46
55
|
|
|
47
56
|
|
|
48
57
|
if __name__ == "__main__":
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any, Literal, TypedDict
|
|
4
|
-
|
|
5
|
-
from .errors import AgentProtocolError
|
|
6
|
-
from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
|
|
7
|
-
|
|
8
|
-
DISCOURSE_PROTOCOL = "agent-discourse/1.0"
|
|
9
|
-
LEGACY_DISCOURSE_PROTOCOL = "adp/1.0"
|
|
10
|
-
|
|
11
|
-
ROOM_CREATE = "room.create"
|
|
12
|
-
ROOM_JOIN = "room.join"
|
|
13
|
-
ROOM_JOIN_REVIEW = "room.join.review"
|
|
14
|
-
ROOM_LEAVE = "room.leave"
|
|
15
|
-
ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
|
|
16
|
-
ROOM_CLOSE = "room.close"
|
|
17
|
-
ROOM_CANCEL = "room.cancel"
|
|
18
|
-
MESSAGE_CREATE = "message.create"
|
|
19
|
-
REACTION_CREATE = "reaction.create"
|
|
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"
|
|
24
|
-
SOURCE_ADD = "source.add"
|
|
25
|
-
TURN_UPDATE = "turn.update"
|
|
26
|
-
QUESTION_CREATE = "question.create"
|
|
27
|
-
ROOM_STEER = "room.steer"
|
|
28
|
-
MAP_UPDATE = "map.update"
|
|
29
|
-
ARTIFACT_CREATE = "artifact.create"
|
|
30
|
-
|
|
31
|
-
KNOWN_EVENT_TYPES = {
|
|
32
|
-
ROOM_CREATE,
|
|
33
|
-
ROOM_JOIN,
|
|
34
|
-
ROOM_JOIN_REVIEW,
|
|
35
|
-
ROOM_LEAVE,
|
|
36
|
-
ROOM_MEMBER_ROLE_UPDATE,
|
|
37
|
-
ROOM_CLOSE,
|
|
38
|
-
ROOM_CANCEL,
|
|
39
|
-
MESSAGE_CREATE,
|
|
40
|
-
REACTION_CREATE,
|
|
41
|
-
MESSAGE_PROPOSAL_CREATE,
|
|
42
|
-
MESSAGE_POLL_CREATE,
|
|
43
|
-
MESSAGE_POLL_VOTE,
|
|
44
|
-
MESSAGE_RESOLUTION_CREATE,
|
|
45
|
-
SOURCE_ADD,
|
|
46
|
-
TURN_UPDATE,
|
|
47
|
-
QUESTION_CREATE,
|
|
48
|
-
ROOM_STEER,
|
|
49
|
-
MAP_UPDATE,
|
|
50
|
-
ARTIFACT_CREATE,
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
RoomState = Literal["scheduled", "active", "ended", "cancelled"]
|
|
54
|
-
Role = Literal["moderator", "expert", "participant", "observer"]
|
|
55
|
-
JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
class PermissionContext(TypedDict, total=False):
|
|
59
|
-
role: Role
|
|
60
|
-
is_creator: bool
|
|
61
|
-
join_request_approved: bool
|
|
62
|
-
moderator_authorized: bool
|
|
63
|
-
expert_policy_allowed: bool
|
|
64
|
-
participant_policy_allowed: bool
|
|
65
|
-
observer_steering_allowed: bool
|
|
66
|
-
observer_poll_vote_allowed: bool
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
|
|
70
|
-
return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
|
|
74
|
-
return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def validate_discourse_envelope(envelope: Envelope, accept_legacy_protocol: bool = False) -> None:
|
|
78
|
-
verify_envelope(envelope)
|
|
79
|
-
event = envelope["event"]
|
|
80
|
-
protocol = event["protocol"]
|
|
81
|
-
if protocol != DISCOURSE_PROTOCOL and not (accept_legacy_protocol and protocol == LEGACY_DISCOURSE_PROTOCOL):
|
|
82
|
-
raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
|
|
83
|
-
if event_requires_room_id(event["type"]) and "room_id" not in event:
|
|
84
|
-
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
|
|
88
|
-
actual = envelope["event"].get("room_id")
|
|
89
|
-
if actual is None and envelope["event"]["type"] == ROOM_CREATE:
|
|
90
|
-
return
|
|
91
|
-
if actual is None:
|
|
92
|
-
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
93
|
-
if actual != path_room_id:
|
|
94
|
-
raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def event_requires_room_id(event_type: str) -> bool:
|
|
98
|
-
return event_type != ROOM_CREATE
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def can_submit_event(event_type: str, context: PermissionContext) -> bool:
|
|
102
|
-
if event_type == ROOM_CREATE:
|
|
103
|
-
return True
|
|
104
|
-
if event_type == ROOM_JOIN:
|
|
105
|
-
return context.get("join_request_approved", False)
|
|
106
|
-
if context.get("is_creator"):
|
|
107
|
-
return event_type in KNOWN_EVENT_TYPES
|
|
108
|
-
|
|
109
|
-
role = context.get("role")
|
|
110
|
-
if role == "moderator":
|
|
111
|
-
return _moderator_can_submit(event_type, context.get("moderator_authorized", False))
|
|
112
|
-
if role == "expert":
|
|
113
|
-
return _speaker_can_submit(event_type, context.get("expert_policy_allowed", False))
|
|
114
|
-
if role == "participant":
|
|
115
|
-
return _speaker_can_submit(event_type, context.get("participant_policy_allowed", False))
|
|
116
|
-
if role == "observer":
|
|
117
|
-
return _observer_can_submit(event_type, context)
|
|
118
|
-
return False
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def can_write_in_state(event_type: str, state: RoomState, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
122
|
-
if state == "scheduled":
|
|
123
|
-
return event_type in {ROOM_JOIN, ROOM_JOIN_REVIEW, ROOM_CANCEL}
|
|
124
|
-
if state == "active":
|
|
125
|
-
return event_type not in {ROOM_CREATE, ROOM_CANCEL}
|
|
126
|
-
if state == "ended":
|
|
127
|
-
return post_end_reaction_allowed and event_type == REACTION_CREATE
|
|
128
|
-
if state == "cancelled":
|
|
129
|
-
return False
|
|
130
|
-
return False
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def can_accept_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> bool:
|
|
134
|
-
return can_submit_event(event_type, context) and can_write_in_state(event_type, state, post_end_reaction_allowed=post_end_reaction_allowed)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def validate_room_write(event_type: str, state: RoomState, context: PermissionContext, *, post_end_reaction_allowed: bool = False) -> None:
|
|
138
|
-
if not can_accept_room_write(event_type, state, context, post_end_reaction_allowed=post_end_reaction_allowed):
|
|
139
|
-
raise AgentProtocolError("permission_denied", "actor lacks permission or state is not writable")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _moderator_can_submit(event_type: str, moderator_authorized: bool) -> bool:
|
|
143
|
-
allowed = {
|
|
144
|
-
ROOM_JOIN_REVIEW,
|
|
145
|
-
ROOM_CLOSE,
|
|
146
|
-
MESSAGE_CREATE,
|
|
147
|
-
SOURCE_ADD,
|
|
148
|
-
TURN_UPDATE,
|
|
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,
|
|
157
|
-
REACTION_CREATE,
|
|
158
|
-
ROOM_LEAVE,
|
|
159
|
-
}
|
|
160
|
-
return event_type in allowed or (moderator_authorized and event_type in {ROOM_MEMBER_ROLE_UPDATE, ROOM_CANCEL})
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _speaker_can_submit(event_type: str, policy_allowed: bool) -> bool:
|
|
164
|
-
allowed = {
|
|
165
|
-
MESSAGE_CREATE,
|
|
166
|
-
SOURCE_ADD,
|
|
167
|
-
ROOM_STEER,
|
|
168
|
-
MESSAGE_PROPOSAL_CREATE,
|
|
169
|
-
MESSAGE_POLL_CREATE,
|
|
170
|
-
MESSAGE_POLL_VOTE,
|
|
171
|
-
REACTION_CREATE,
|
|
172
|
-
ROOM_LEAVE,
|
|
173
|
-
}
|
|
174
|
-
policy_events = {QUESTION_CREATE, MAP_UPDATE, ARTIFACT_CREATE, MESSAGE_RESOLUTION_CREATE}
|
|
175
|
-
return event_type in allowed or (policy_allowed and event_type in policy_events)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _observer_can_submit(event_type: str, context: PermissionContext) -> bool:
|
|
179
|
-
return (
|
|
180
|
-
event_type in {REACTION_CREATE, ROOM_LEAVE}
|
|
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)
|
|
183
|
-
)
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
|
|
3
|
-
from agent_protocols.discourse import (
|
|
4
|
-
MESSAGE_CREATE,
|
|
5
|
-
REACTION_CREATE,
|
|
6
|
-
ROOM_CANCEL,
|
|
7
|
-
ROOM_CREATE,
|
|
8
|
-
ROOM_JOIN,
|
|
9
|
-
ROOM_JOIN_REVIEW,
|
|
10
|
-
can_accept_room_write,
|
|
11
|
-
can_submit_event,
|
|
12
|
-
room_create_event,
|
|
13
|
-
validate_discourse_envelope,
|
|
14
|
-
validate_room_path,
|
|
15
|
-
)
|
|
16
|
-
from agent_protocols.http_client import websocket_events_url
|
|
17
|
-
from agent_protocols.identity import AgentSigner, create_event
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class DiscourseTests(unittest.TestCase):
|
|
21
|
-
def test_validates_room_create_without_room_id(self):
|
|
22
|
-
signer = AgentSigner.from_seed(bytes([14]) * 32)
|
|
23
|
-
event = room_create_event(
|
|
24
|
-
signer.agent_id(),
|
|
25
|
-
100,
|
|
26
|
-
1,
|
|
27
|
-
{"topic": "Research room", "visibility": "public", "start_time": 1000, "end_time": 2000},
|
|
28
|
-
)
|
|
29
|
-
envelope = signer.sign_event(event)
|
|
30
|
-
|
|
31
|
-
validate_discourse_envelope(envelope)
|
|
32
|
-
validate_room_path(envelope, "d8ftedhpqhsusbg001tg")
|
|
33
|
-
|
|
34
|
-
def test_rejects_room_event_without_room_id(self):
|
|
35
|
-
signer = AgentSigner.from_seed(bytes([15]) * 32)
|
|
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
|
-
)
|
|
44
|
-
envelope = signer.sign_event(event)
|
|
45
|
-
|
|
46
|
-
with self.assertRaises(Exception):
|
|
47
|
-
validate_discourse_envelope(envelope)
|
|
48
|
-
|
|
49
|
-
def test_applies_permission_matrix(self):
|
|
50
|
-
self.assertTrue(can_submit_event(REACTION_CREATE, {"role": "observer"}))
|
|
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"}))
|
|
56
|
-
self.assertFalse(can_submit_event(ROOM_CANCEL, {"role": "moderator"}))
|
|
57
|
-
self.assertTrue(can_submit_event(ROOM_CANCEL, {"role": "moderator", "moderator_authorized": True}))
|
|
58
|
-
self.assertTrue(can_submit_event(ROOM_CREATE, {}))
|
|
59
|
-
|
|
60
|
-
def test_applies_state_restrictions(self):
|
|
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}))
|
|
65
|
-
self.assertFalse(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}))
|
|
66
|
-
self.assertTrue(can_accept_room_write(REACTION_CREATE, "ended", {"role": "participant"}, post_end_reaction_allowed=True))
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
if __name__ == "__main__":
|
|
76
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|