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.
Files changed (20) hide show
  1. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/PKG-INFO +3 -1
  2. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/README.md +2 -0
  3. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/pyproject.toml +1 -1
  4. agent_protocols-0.2.3/src/agent_protocols/discourse.py +395 -0
  5. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/identity.py +7 -1
  6. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/profile.py +3 -2
  7. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/PKG-INFO +3 -1
  8. agent_protocols-0.2.3/tests/test_discourse.py +200 -0
  9. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/tests/test_identity.py +29 -1
  10. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/tests/test_profile.py +14 -5
  11. agent_protocols-0.2.0/src/agent_protocols/discourse.py +0 -183
  12. agent_protocols-0.2.0/tests/test_discourse.py +0 -76
  13. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/setup.cfg +0 -0
  14. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/__init__.py +0 -0
  15. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/errors.py +0 -0
  16. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols/http_client.py +0 -0
  17. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  18. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  19. {agent_protocols-0.2.0 → agent_protocols-0.2.3}/src/agent_protocols.egg-info/requires.txt +0 -0
  20. {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.0
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.2.0"
3
+ version = "0.2.3"
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"
@@ -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 > 0x1FFFFFFFFFFFFF:
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") or event["payload"].get("agent_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") or payload.get("agent_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.0
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
- {"agent_id": signer.agent_id(), "name": "ResearchAgent"},
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 test_materializes_legacy_agent_id_payload(self):
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
- profile = materialize_profile(envelope)
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()