agent-protocols 0.4.0__tar.gz → 0.4.1__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 (24) hide show
  1. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/PKG-INFO +6 -5
  2. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/README.md +4 -3
  3. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/pyproject.toml +2 -2
  4. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols/__init__.py +22 -0
  5. agent_protocols-0.4.1/src/agent_protocols/delegation.py +150 -0
  6. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols/discourse.py +15 -1
  7. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols/http_client.py +42 -0
  8. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols/profile.py +1 -0
  9. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/PKG-INFO +6 -5
  10. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/SOURCES.txt +2 -0
  11. agent_protocols-0.4.1/tests/test_delegation.py +124 -0
  12. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/tests/test_http_client.py +38 -0
  13. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/tests/test_profile.py +9 -0
  14. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/setup.cfg +0 -0
  15. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols/errors.py +0 -0
  16. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols/identity.py +0 -0
  17. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  18. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/requires.txt +0 -0
  19. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/top_level.txt +0 -0
  20. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/tests/test_discourse.py +0 -0
  21. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/tests/test_discourse_coverage.py +0 -0
  22. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/tests/test_identity.py +0 -0
  23. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/tests/test_identity_coverage.py +0 -0
  24. {agent_protocols-0.4.0 → agent_protocols-0.4.1}/tests/test_profile_coverage.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.4.0
4
- Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
3
+ Version: 0.4.1
4
+ Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
7
7
  Keywords: agent,protocol,ed25519,sdk
@@ -15,14 +15,15 @@ Requires-Dist: requests<3,>=2.31; extra == "http"
15
15
 
16
16
  # agent-protocols Python SDK
17
17
 
18
- Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
18
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
19
19
 
20
20
  ## Modules
21
21
 
22
22
  - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
- - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
23
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
24
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
24
25
  - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
25
- - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
26
27
 
27
28
  ## Example
28
29
 
@@ -1,13 +1,14 @@
1
1
  # agent-protocols Python SDK
2
2
 
3
- Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
3
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
4
4
 
5
5
  ## Modules
6
6
 
7
7
  - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
8
- - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
8
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
9
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
9
10
  - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
10
- - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
11
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
11
12
 
12
13
  ## Example
13
14
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.4.0"
4
- description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
3
+ version = "0.4.1"
4
+ description = "Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = { text = "MIT" }
@@ -32,9 +32,24 @@ from .identity import (
32
32
  with_room_id,
33
33
  )
34
34
  from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, profile_update_event, validate_profile_update
35
+ from .delegation import (
36
+ DELEGATION_GRANT,
37
+ DELEGATION_PROTOCOL,
38
+ DELEGATION_REVOKE,
39
+ delegation_grant_event,
40
+ delegation_revoke_event,
41
+ materialize_delegation_credential,
42
+ validate_delegation_envelope,
43
+ validate_delegation_grant_payload,
44
+ validate_delegation_revoke_payload,
45
+ validate_principal_document,
46
+ )
35
47
 
36
48
  __all__ = [
37
49
  "AGENT_ID_PREFIX",
50
+ "DELEGATION_GRANT",
51
+ "DELEGATION_PROTOCOL",
52
+ "DELEGATION_REVOKE",
38
53
  "MAX_NONCE_HEADER",
39
54
  "PROFILE_PROTOCOL",
40
55
  "PROFILE_UPDATE",
@@ -46,10 +61,13 @@ __all__ = [
46
61
  "agent_id_from_public_key",
47
62
  "canonical_event_bytes",
48
63
  "create_event",
64
+ "delegation_grant_event",
65
+ "delegation_revoke_event",
49
66
  "create_request_jwt_claims",
50
67
  "event_hash",
51
68
  "event_hash_bytes",
52
69
  "materialize_profile",
70
+ "materialize_delegation_credential",
53
71
  "profile_update_event",
54
72
  "public_key_bytes",
55
73
  "sign_event",
@@ -57,7 +75,11 @@ __all__ = [
57
75
  "unix_ms",
58
76
  "unix_secs",
59
77
  "validate_agent_id",
78
+ "validate_delegation_envelope",
79
+ "validate_delegation_grant_payload",
80
+ "validate_delegation_revoke_payload",
60
81
  "validate_nonce",
82
+ "validate_principal_document",
61
83
  "validate_profile_update",
62
84
  "verify_envelope",
63
85
  "verify_event_hash",
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import urlparse
5
+
6
+ from .errors import AgentProtocolError
7
+ from .identity import AgentId, Envelope, Event, create_event, validate_agent_id, verify_envelope
8
+
9
+ DELEGATION_PROTOCOL = "agent-delegation/1.0"
10
+ DELEGATION_GRANT = "delegation.grant"
11
+ DELEGATION_REVOKE = "delegation.revoke"
12
+
13
+ DelegationGrantPayload = dict[str, Any]
14
+ DelegationRevokePayload = dict[str, Any]
15
+ DelegationCredential = dict[str, Any]
16
+ PrincipalDocument = dict[str, Any]
17
+
18
+
19
+ def delegation_grant_event(
20
+ actor: AgentId,
21
+ created_at: int,
22
+ nonce: int,
23
+ payload: DelegationGrantPayload,
24
+ ) -> Event:
25
+ return create_event(DELEGATION_PROTOCOL, DELEGATION_GRANT, actor, created_at, nonce, payload)
26
+
27
+
28
+ def delegation_revoke_event(
29
+ actor: AgentId,
30
+ created_at: int,
31
+ nonce: int,
32
+ payload: DelegationRevokePayload,
33
+ ) -> Event:
34
+ return create_event(DELEGATION_PROTOCOL, DELEGATION_REVOKE, actor, created_at, nonce, payload)
35
+
36
+
37
+ def validate_principal_document(document: PrincipalDocument) -> None:
38
+ _validate_https_url(document.get("id"), "principal.id")
39
+ controllers = document.get("controllers")
40
+ if not isinstance(controllers, list) or not controllers:
41
+ raise AgentProtocolError(
42
+ "invalid_principal",
43
+ "principal document controllers must be non-empty",
44
+ )
45
+ for controller in controllers:
46
+ validate_agent_id(controller)
47
+ if document.get("delegations_url") is not None:
48
+ _validate_https_url(document["delegations_url"], "delegations_url")
49
+
50
+
51
+ def validate_delegation_grant_payload(
52
+ payload: DelegationGrantPayload,
53
+ created_at: int | None = None,
54
+ ) -> None:
55
+ _validate_non_empty(payload.get("id"), "payload.id")
56
+ _validate_principal_descriptor(payload.get("principal"))
57
+ validate_agent_id(payload.get("subject"))
58
+ scopes = payload.get("scopes")
59
+ if not isinstance(scopes, list) or not scopes:
60
+ raise AgentProtocolError("invalid_delegation", "delegation scopes must be non-empty")
61
+ for scope in scopes:
62
+ _validate_non_empty(scope, "scope")
63
+ constraints = payload.get("constraints")
64
+ if constraints is not None and not isinstance(constraints, dict):
65
+ raise AgentProtocolError("invalid_delegation", "constraints must be an object")
66
+ expires_at = payload.get("expires_at")
67
+ if expires_at is not None:
68
+ not_before = payload.get("not_before", created_at)
69
+ if not_before is not None and expires_at <= not_before:
70
+ raise AgentProtocolError(
71
+ "invalid_delegation",
72
+ "expires_at must be greater than not_before or created_at",
73
+ )
74
+
75
+
76
+ def validate_delegation_revoke_payload(payload: DelegationRevokePayload) -> None:
77
+ _validate_non_empty(payload.get("id"), "payload.id")
78
+ _validate_https_url(payload.get("principal_id"), "principal_id")
79
+
80
+
81
+ def validate_delegation_envelope(envelope: Envelope) -> None:
82
+ verify_envelope(envelope)
83
+ event = envelope["event"]
84
+ if event["protocol"] != DELEGATION_PROTOCOL:
85
+ raise AgentProtocolError(
86
+ "invalid_event_protocol",
87
+ f"expected {DELEGATION_PROTOCOL}, got {event['protocol']}",
88
+ )
89
+ if event["type"] == DELEGATION_GRANT:
90
+ validate_delegation_grant_payload(event["payload"], event["created_at"])
91
+ elif event["type"] == DELEGATION_REVOKE:
92
+ validate_delegation_revoke_payload(event["payload"])
93
+ else:
94
+ raise AgentProtocolError(
95
+ "invalid_event_type",
96
+ f"expected {DELEGATION_GRANT} or {DELEGATION_REVOKE}, got {event['type']}",
97
+ )
98
+
99
+
100
+ def materialize_delegation_credential(
101
+ envelope: Envelope,
102
+ *,
103
+ status: str = "active",
104
+ status_url: str | None = None,
105
+ updated_at: int | None = None,
106
+ ) -> DelegationCredential:
107
+ validate_delegation_envelope(envelope)
108
+ event = envelope["event"]
109
+ if event["type"] != DELEGATION_GRANT:
110
+ raise AgentProtocolError(
111
+ "invalid_event_type",
112
+ "delegation credential materialization requires a grant event",
113
+ )
114
+ payload = event["payload"]
115
+ credential = {
116
+ "id": payload["id"],
117
+ "protocol": DELEGATION_PROTOCOL,
118
+ "principal": payload["principal"],
119
+ "controller": event["actor"],
120
+ "subject": payload["subject"],
121
+ "relationship": payload.get("relationship"),
122
+ "scopes": payload["scopes"],
123
+ "constraints": payload.get("constraints"),
124
+ "not_before": payload.get("not_before"),
125
+ "expires_at": payload.get("expires_at"),
126
+ "status": status,
127
+ "status_url": status_url,
128
+ "updated_at": updated_at if updated_at is not None else event["created_at"],
129
+ "event_id": envelope["hash"],
130
+ }
131
+ return credential
132
+
133
+
134
+ def _validate_principal_descriptor(value: Any) -> None:
135
+ if not isinstance(value, dict):
136
+ raise AgentProtocolError("invalid_principal", "principal must be an object")
137
+ _validate_https_url(value.get("id"), "principal.id")
138
+
139
+
140
+ def _validate_https_url(value: Any, field: str) -> None:
141
+ if not isinstance(value, str):
142
+ raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
143
+ parsed = urlparse(value)
144
+ if parsed.scheme != "https" or not parsed.netloc:
145
+ raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
146
+
147
+
148
+ def _validate_non_empty(value: Any, field: str) -> None:
149
+ if not isinstance(value, str) or not value.strip():
150
+ raise AgentProtocolError("invalid_delegation", f"{field} must not be empty")
@@ -94,6 +94,7 @@ class PermissionContext(TypedDict, total=False):
94
94
 
95
95
  role: Role
96
96
  is_creator: bool
97
+ public_join_allowed: bool
97
98
  join_request_approved: bool
98
99
 
99
100
 
@@ -438,6 +439,19 @@ def validate_message_create_payload(payload: dict[str, Any]) -> None:
438
439
  raise AgentProtocolError("invalid_event", "content_type must not be empty")
439
440
 
440
441
 
442
+ def validate_room_join_payload(payload: dict[str, Any]) -> None:
443
+ if payload.get("role") not in ROLES:
444
+ raise AgentProtocolError("invalid_event", f"invalid room role: {payload.get('role')}")
445
+ request_id = payload.get("request_id")
446
+ if request_id is not None and not str(request_id).strip():
447
+ raise AgentProtocolError("invalid_event", "request_id must not be empty")
448
+ if request_id is not None and "perspective" in payload:
449
+ raise AgentProtocolError(
450
+ "invalid_event",
451
+ "room.join payload cannot include both request_id and perspective",
452
+ )
453
+
454
+
441
455
  def server_record_hash_payload(
442
456
  room_id: str,
443
457
  seq: int,
@@ -537,7 +551,7 @@ def can_submit_event(
537
551
  if event_type == ROOM_CREATE:
538
552
  return True
539
553
  if event_type == ROOM_JOIN:
540
- return bool(context.get("join_request_approved"))
554
+ return bool(context.get("public_join_allowed") or context.get("join_request_approved"))
541
555
  if event_type == ROOM_LEAVE:
542
556
  return is_creator or role is not None
543
557
  if event_type in {ROOM_JOIN_REVIEW, ROOM_MEMBER_ROLE_UPDATE, ROOM_CLOSE, ROOM_CANCEL, TYPE_DEFINE}:
@@ -163,6 +163,48 @@ class DiscourseClient:
163
163
  return response.json()
164
164
 
165
165
 
166
+ class DelegationClient:
167
+ def __init__(self, base_url: str, session: Any | None = None):
168
+ self.base_url = base_url.rstrip("/")
169
+ self.session = session or _requests_session()
170
+
171
+ def protocol(self) -> dict[str, Any]:
172
+ return self._get("/.well-known/agent-delegation")
173
+
174
+ def principal(self, principal_url: str | None = None) -> dict[str, Any]:
175
+ response = self.session.get(
176
+ principal_url or self.base_url,
177
+ headers={"Accept": "application/json"},
178
+ )
179
+ response.raise_for_status()
180
+ return response.json()
181
+
182
+ def delegation(self, delegation_id: str) -> dict[str, Any]:
183
+ return self._get(f"/v1/delegations/{delegation_id}")
184
+
185
+ def delegation_status(self, delegation_id: str) -> dict[str, Any]:
186
+ return self._get(f"/v1/delegations/{delegation_id}/status")
187
+
188
+ def delegation_events(self, delegation_id: str) -> dict[str, Any]:
189
+ return self._get(f"/v1/delegations/{delegation_id}/events")
190
+
191
+ def submit_delegation_event(self, envelope: Envelope) -> dict[str, Any]:
192
+ return self._post("/v1/delegations", envelope)
193
+
194
+ def query_delegations(self, request: dict[str, Any]) -> dict[str, Any]:
195
+ return self._post("/v1/delegations/query", request)
196
+
197
+ def _get(self, path: str) -> Any:
198
+ response = self.session.get(self.base_url + path)
199
+ response.raise_for_status()
200
+ return response.json()
201
+
202
+ def _post(self, path: str, body: Any) -> dict[str, Any]:
203
+ response = self.session.post(self.base_url + path, json=body)
204
+ response.raise_for_status()
205
+ return response.json()
206
+
207
+
166
208
  def sse_events_url(base_url: str, room_id: str) -> str:
167
209
  return f"{base_url.rstrip('/')}/v1/rooms/{quote(room_id, safe='')}/events/live"
168
210
 
@@ -42,6 +42,7 @@ def materialize_profile(envelope: Envelope) -> AgentProfile:
42
42
  "capabilities": payload.get("capabilities", []),
43
43
  "service_endpoints": payload.get("service_endpoints", []),
44
44
  "links": payload.get("links", []),
45
+ "delegations": payload.get("delegations", []),
45
46
  "extra": payload.get("extra", {}),
46
47
  "updated_at": envelope["event"]["created_at"],
47
48
  "event_id": envelope["hash"],
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.4.0
4
- Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
3
+ Version: 0.4.1
4
+ Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
7
7
  Keywords: agent,protocol,ed25519,sdk
@@ -15,14 +15,15 @@ Requires-Dist: requests<3,>=2.31; extra == "http"
15
15
 
16
16
  # agent-protocols Python SDK
17
17
 
18
- Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
18
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
19
19
 
20
20
  ## Modules
21
21
 
22
22
  - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
- - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
23
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
24
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
24
25
  - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
25
- - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
26
27
 
27
28
  ## Example
28
29
 
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/agent_protocols/__init__.py
4
+ src/agent_protocols/delegation.py
4
5
  src/agent_protocols/discourse.py
5
6
  src/agent_protocols/errors.py
6
7
  src/agent_protocols/http_client.py
@@ -11,6 +12,7 @@ src/agent_protocols.egg-info/SOURCES.txt
11
12
  src/agent_protocols.egg-info/dependency_links.txt
12
13
  src/agent_protocols.egg-info/requires.txt
13
14
  src/agent_protocols.egg-info/top_level.txt
15
+ tests/test_delegation.py
14
16
  tests/test_discourse.py
15
17
  tests/test_discourse_coverage.py
16
18
  tests/test_http_client.py
@@ -0,0 +1,124 @@
1
+ import unittest
2
+
3
+ from agent_protocols.delegation import (
4
+ DELEGATION_GRANT,
5
+ DELEGATION_PROTOCOL,
6
+ DELEGATION_REVOKE,
7
+ delegation_grant_event,
8
+ delegation_revoke_event,
9
+ materialize_delegation_credential,
10
+ validate_delegation_envelope,
11
+ validate_delegation_grant_payload,
12
+ validate_principal_document,
13
+ )
14
+ from agent_protocols.identity import AgentSigner, create_event
15
+
16
+
17
+ class DelegationTests(unittest.TestCase):
18
+ def test_grant_event_materializes_credential(self):
19
+ controller = AgentSigner.from_seed(bytes([31]) * 32)
20
+ subject = AgentSigner.from_seed(bytes([32]) * 32)
21
+ payload = {
22
+ "id": "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
23
+ "principal": {
24
+ "id": "https://al.ink/yan",
25
+ "type": "person",
26
+ "name": "Yan",
27
+ },
28
+ "subject": subject.agent_id(),
29
+ "relationship": "primary_delegate",
30
+ "scopes": ["inbox.screen", "meeting.propose"],
31
+ "constraints": {"requires_human_approval": ["meeting.accept"]},
32
+ "not_before": 1_779_753_600_000,
33
+ "expires_at": 1_790_000_000_000,
34
+ }
35
+ envelope = controller.sign_event(
36
+ delegation_grant_event(controller.agent_id(), 1_779_753_600_000, 1, payload)
37
+ )
38
+
39
+ validate_delegation_envelope(envelope)
40
+ credential = materialize_delegation_credential(
41
+ envelope,
42
+ status_url="https://al.ink/v1/delegations/del/status",
43
+ )
44
+
45
+ self.assertEqual(envelope["event"]["type"], DELEGATION_GRANT)
46
+ self.assertEqual(credential["protocol"], DELEGATION_PROTOCOL)
47
+ self.assertEqual(credential["controller"], controller.agent_id())
48
+ self.assertEqual(credential["subject"], subject.agent_id())
49
+ self.assertEqual(credential["status"], "active")
50
+ self.assertEqual(credential["event_id"], envelope["hash"])
51
+
52
+ def test_revoke_event_and_invalid_payloads(self):
53
+ controller = AgentSigner.from_seed(bytes([33]) * 32)
54
+ envelope = controller.sign_event(
55
+ delegation_revoke_event(
56
+ controller.agent_id(),
57
+ 1_779_753_700_000,
58
+ 2,
59
+ {
60
+ "id": "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
61
+ "principal_id": "https://al.ink/yan",
62
+ "reason": "rotated_primary_agent",
63
+ },
64
+ )
65
+ )
66
+
67
+ self.assertEqual(envelope["event"]["type"], DELEGATION_REVOKE)
68
+ validate_delegation_envelope(envelope)
69
+ with self.assertRaisesRegex(Exception, "HTTPS|scopes"):
70
+ validate_delegation_grant_payload(
71
+ {
72
+ "id": "del",
73
+ "principal": {"id": "http://example.com"},
74
+ "subject": controller.agent_id(),
75
+ "scopes": [],
76
+ }
77
+ )
78
+
79
+ def test_principal_document_and_event_type_validation(self):
80
+ controller = AgentSigner.from_seed(bytes([34]) * 32)
81
+ validate_principal_document(
82
+ {
83
+ "id": "https://profiles.example.com/org/acme",
84
+ "controllers": [controller.agent_id()],
85
+ "delegations_url": "https://profiles.example.com/v1/delegations/query",
86
+ }
87
+ )
88
+ with self.assertRaises(Exception):
89
+ validate_principal_document(
90
+ {
91
+ "id": "https://profiles.example.com/org/acme",
92
+ "controllers": [],
93
+ }
94
+ )
95
+
96
+ wrong = controller.sign_event(
97
+ create_event(
98
+ DELEGATION_PROTOCOL,
99
+ "delegation.unknown",
100
+ controller.agent_id(),
101
+ 1,
102
+ 1,
103
+ {"id": "del"},
104
+ )
105
+ )
106
+ with self.assertRaisesRegex(Exception, "delegation.grant"):
107
+ validate_delegation_envelope(wrong)
108
+
109
+ def test_rejects_malformed_https_like_principal_urls(self):
110
+ signer = AgentSigner.from_seed(bytes([35]) * 32)
111
+ for principal_id in ("https://", "https://[::1", "http://example.com"):
112
+ with self.assertRaises(Exception):
113
+ validate_delegation_grant_payload(
114
+ {
115
+ "id": "del",
116
+ "principal": {"id": principal_id},
117
+ "subject": signer.agent_id(),
118
+ "scopes": ["scope"],
119
+ }
120
+ )
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -2,6 +2,7 @@ import unittest
2
2
 
3
3
  import agent_protocols.http_client as http_client
4
4
  from agent_protocols.http_client import (
5
+ DelegationClient,
5
6
  DiscourseClient,
6
7
  ProfileClient,
7
8
  sse_events_url,
@@ -163,6 +164,43 @@ class DiscourseClientTests(unittest.TestCase):
163
164
  self.assertEqual(session.calls[4][3], {"state": "idle", "expires_at": 2})
164
165
 
165
166
 
167
+ class DelegationClientTests(unittest.TestCase):
168
+ def test_every_endpoint_uses_expected_method_and_path(self):
169
+ session = FakeSession([FakeResponse({"ok": True}) for _ in range(7)])
170
+ client = DelegationClient("https://al.ink/", session=session)
171
+ envelope = {
172
+ "hash": "h",
173
+ "event": {
174
+ "protocol": "agent-delegation/1.0",
175
+ "type": "delegation.revoke",
176
+ "actor": AGENT_ID,
177
+ "created_at": 1,
178
+ "nonce": 1,
179
+ "payload": {"id": "del_1", "principal_id": "https://al.ink/yan"},
180
+ },
181
+ "signature": "s",
182
+ }
183
+
184
+ client.protocol()
185
+ client.principal("https://al.ink/yan")
186
+ client.delegation("del_1")
187
+ client.delegation_status("del_1")
188
+ client.delegation_events("del_1")
189
+ client.submit_delegation_event(envelope)
190
+ client.query_delegations({"subject": AGENT_ID, "status": "active", "limit": 20})
191
+
192
+ urls = [call[1] for call in session.calls]
193
+ self.assertEqual(urls[0], "https://al.ink/.well-known/agent-delegation")
194
+ self.assertEqual(urls[1], "https://al.ink/yan")
195
+ self.assertEqual(session.calls[1][2], {"Accept": "application/json"})
196
+ self.assertEqual(urls[2], "https://al.ink/v1/delegations/del_1")
197
+ self.assertEqual(urls[3], "https://al.ink/v1/delegations/del_1/status")
198
+ self.assertEqual(urls[4], "https://al.ink/v1/delegations/del_1/events")
199
+ self.assertEqual((session.calls[5][0], urls[5]), ("POST", "https://al.ink/v1/delegations"))
200
+ self.assertEqual((session.calls[6][0], urls[6]), ("POST", "https://al.ink/v1/delegations/query"))
201
+ self.assertEqual(session.calls[6][3]["status"], "active")
202
+
203
+
166
204
  class HelperTests(unittest.TestCase):
167
205
  def test_sse_events_url_preserves_http_schemes(self):
168
206
  self.assertEqual(
@@ -13,6 +13,14 @@ class ProfileTests(unittest.TestCase):
13
13
  "capabilities": ["research"],
14
14
  "extra": {"domain": "research"},
15
15
  "links": [{"name": "Homepage", "url": "https://example.com", "rel": "homepage"}],
16
+ "delegations": [
17
+ {
18
+ "principal": {"id": "https://al.ink/yan", "type": "person", "name": "Yan"},
19
+ "relationship": "primary_delegate",
20
+ "scopes": ["inbox.screen"],
21
+ "credential_url": "https://al.ink/v1/delegations/del_1",
22
+ }
23
+ ],
16
24
  }
17
25
  envelope = signer.sign_event(profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload))
18
26
 
@@ -22,6 +30,7 @@ class ProfileTests(unittest.TestCase):
22
30
  self.assertEqual(profile["name"], "ResearchAgent-v3")
23
31
  self.assertIsNone(profile["username"])
24
32
  self.assertEqual(profile["links"], payload["links"])
33
+ self.assertEqual(profile["delegations"], payload["delegations"])
25
34
  self.assertEqual(profile["extra"], payload["extra"])
26
35
  self.assertEqual(profile["updated_at"], 1_779_753_600_000)
27
36
  self.assertEqual(profile["event_id"], envelope["hash"])