agent-protocols 0.3.1__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.
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/PKG-INFO +8 -5
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/README.md +6 -3
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/pyproject.toml +2 -2
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/__init__.py +28 -0
- agent_protocols-0.4.1/src/agent_protocols/delegation.py +150 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/discourse.py +117 -14
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/http_client.py +97 -9
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/identity.py +25 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/profile.py +1 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/PKG-INFO +8 -5
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/SOURCES.txt +2 -0
- agent_protocols-0.4.1/tests/test_delegation.py +124 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_discourse.py +17 -5
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_discourse_coverage.py +16 -5
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_http_client.py +84 -11
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_identity_coverage.py +13 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_profile.py +9 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/setup.cfg +0 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols/errors.py +0 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/requires.txt +0 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/top_level.txt +0 -0
- {agent_protocols-0.3.1 → agent_protocols-0.4.1}/tests/test_identity.py +0 -0
- {agent_protocols-0.3.1 → 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
|
-
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
|
|
|
@@ -42,3 +43,5 @@ profile = materialize_profile(envelope)
|
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
46
|
+
|
|
47
|
+
ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
|
|
@@ -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
|
|
|
@@ -27,3 +28,5 @@ profile = materialize_profile(envelope)
|
|
|
27
28
|
```
|
|
28
29
|
|
|
29
30
|
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
31
|
+
|
|
32
|
+
ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agent-protocols"
|
|
3
|
-
version = "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" }
|
|
@@ -26,12 +26,30 @@ from .identity import (
|
|
|
26
26
|
verify_request_jwt,
|
|
27
27
|
verify_signature,
|
|
28
28
|
verify_timestamp,
|
|
29
|
+
with_mention,
|
|
30
|
+
with_mentions,
|
|
31
|
+
with_room_head,
|
|
29
32
|
with_room_id,
|
|
30
33
|
)
|
|
31
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
|
+
)
|
|
32
47
|
|
|
33
48
|
__all__ = [
|
|
34
49
|
"AGENT_ID_PREFIX",
|
|
50
|
+
"DELEGATION_GRANT",
|
|
51
|
+
"DELEGATION_PROTOCOL",
|
|
52
|
+
"DELEGATION_REVOKE",
|
|
35
53
|
"MAX_NONCE_HEADER",
|
|
36
54
|
"PROFILE_PROTOCOL",
|
|
37
55
|
"PROFILE_UPDATE",
|
|
@@ -43,10 +61,13 @@ __all__ = [
|
|
|
43
61
|
"agent_id_from_public_key",
|
|
44
62
|
"canonical_event_bytes",
|
|
45
63
|
"create_event",
|
|
64
|
+
"delegation_grant_event",
|
|
65
|
+
"delegation_revoke_event",
|
|
46
66
|
"create_request_jwt_claims",
|
|
47
67
|
"event_hash",
|
|
48
68
|
"event_hash_bytes",
|
|
49
69
|
"materialize_profile",
|
|
70
|
+
"materialize_delegation_credential",
|
|
50
71
|
"profile_update_event",
|
|
51
72
|
"public_key_bytes",
|
|
52
73
|
"sign_event",
|
|
@@ -54,7 +75,11 @@ __all__ = [
|
|
|
54
75
|
"unix_ms",
|
|
55
76
|
"unix_secs",
|
|
56
77
|
"validate_agent_id",
|
|
78
|
+
"validate_delegation_envelope",
|
|
79
|
+
"validate_delegation_grant_payload",
|
|
80
|
+
"validate_delegation_revoke_payload",
|
|
57
81
|
"validate_nonce",
|
|
82
|
+
"validate_principal_document",
|
|
58
83
|
"validate_profile_update",
|
|
59
84
|
"verify_envelope",
|
|
60
85
|
"verify_event_hash",
|
|
@@ -63,5 +88,8 @@ __all__ = [
|
|
|
63
88
|
"verify_request_jwt",
|
|
64
89
|
"verify_signature",
|
|
65
90
|
"verify_timestamp",
|
|
91
|
+
"with_mention",
|
|
92
|
+
"with_mentions",
|
|
93
|
+
"with_room_head",
|
|
66
94
|
"with_room_id",
|
|
67
95
|
]
|
|
@@ -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")
|
|
@@ -18,7 +18,17 @@ import rfc8785
|
|
|
18
18
|
from jsonschema.validators import Draft202012Validator
|
|
19
19
|
|
|
20
20
|
from .errors import AgentProtocolError
|
|
21
|
-
from .identity import
|
|
21
|
+
from .identity import (
|
|
22
|
+
AgentId,
|
|
23
|
+
Envelope,
|
|
24
|
+
Event,
|
|
25
|
+
MAX_SAFE_NONCE,
|
|
26
|
+
create_event,
|
|
27
|
+
validate_agent_id,
|
|
28
|
+
verify_envelope,
|
|
29
|
+
with_room_head,
|
|
30
|
+
with_room_id,
|
|
31
|
+
)
|
|
22
32
|
|
|
23
33
|
DISCOURSE_PROTOCOL = "agent-discourse/1.0"
|
|
24
34
|
|
|
@@ -68,6 +78,8 @@ Role = Literal["moderator", "speaker", "observer"]
|
|
|
68
78
|
TypeKind = Literal["message", "signal", "control"]
|
|
69
79
|
TypeStatus = Literal["active", "deprecated", "disabled"]
|
|
70
80
|
JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
|
|
81
|
+
JoinDecision = Literal["approve", "reject"]
|
|
82
|
+
Visibility = Literal["public", "restricted", "private"]
|
|
71
83
|
|
|
72
84
|
ROLES = ("moderator", "speaker", "observer")
|
|
73
85
|
TYPE_KINDS = ("message", "signal", "control")
|
|
@@ -82,24 +94,73 @@ class PermissionContext(TypedDict, total=False):
|
|
|
82
94
|
|
|
83
95
|
role: Role
|
|
84
96
|
is_creator: bool
|
|
97
|
+
public_join_allowed: bool
|
|
85
98
|
join_request_approved: bool
|
|
86
99
|
|
|
87
100
|
|
|
101
|
+
class AgentStatusInput(TypedDict, total=False):
|
|
102
|
+
state: str
|
|
103
|
+
summary: str
|
|
104
|
+
seen_seq: int
|
|
105
|
+
seen_hash: str
|
|
106
|
+
claim_id: str
|
|
107
|
+
activity: str
|
|
108
|
+
expires_at: int
|
|
109
|
+
extra: dict[str, Any]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AgentStatus(TypedDict, total=False):
|
|
113
|
+
room_id: str
|
|
114
|
+
agent_id: AgentId
|
|
115
|
+
state: str
|
|
116
|
+
summary: str
|
|
117
|
+
seen_seq: int
|
|
118
|
+
seen_hash: str
|
|
119
|
+
claim_id: str
|
|
120
|
+
activity: str
|
|
121
|
+
expires_at: int
|
|
122
|
+
updated_at: int
|
|
123
|
+
extra: dict[str, Any]
|
|
124
|
+
|
|
125
|
+
|
|
88
126
|
def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
|
|
89
127
|
return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
|
|
90
128
|
|
|
91
129
|
|
|
92
130
|
def type_define_event(
|
|
93
|
-
actor: AgentId,
|
|
131
|
+
actor: AgentId,
|
|
132
|
+
created_at: int,
|
|
133
|
+
nonce: int,
|
|
134
|
+
room_id: str,
|
|
135
|
+
base_seq: int,
|
|
136
|
+
base_hash: str,
|
|
137
|
+
declaration: dict[str, Any],
|
|
94
138
|
) -> Event:
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
139
|
+
return with_room_head(
|
|
140
|
+
with_room_id(
|
|
141
|
+
create_event(DISCOURSE_PROTOCOL, TYPE_DEFINE, actor, created_at, nonce, declaration),
|
|
142
|
+
room_id,
|
|
143
|
+
),
|
|
144
|
+
base_seq,
|
|
145
|
+
base_hash,
|
|
98
146
|
)
|
|
99
147
|
|
|
100
148
|
|
|
101
|
-
def discourse_event(
|
|
102
|
-
|
|
149
|
+
def discourse_event(
|
|
150
|
+
event_type: str,
|
|
151
|
+
actor: AgentId,
|
|
152
|
+
created_at: int,
|
|
153
|
+
nonce: int,
|
|
154
|
+
room_id: str,
|
|
155
|
+
base_seq: int,
|
|
156
|
+
base_hash: str,
|
|
157
|
+
payload: Any,
|
|
158
|
+
) -> Event:
|
|
159
|
+
return with_room_head(
|
|
160
|
+
with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id),
|
|
161
|
+
base_seq,
|
|
162
|
+
base_hash,
|
|
163
|
+
)
|
|
103
164
|
|
|
104
165
|
|
|
105
166
|
def is_builtin_event_type(event_type: str) -> bool:
|
|
@@ -117,25 +178,54 @@ def validate_discourse_envelope(envelope: Envelope) -> None:
|
|
|
117
178
|
if protocol != DISCOURSE_PROTOCOL:
|
|
118
179
|
raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
|
|
119
180
|
if event["type"] == ROOM_CREATE:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
181
|
+
_validate_room_create_event_fields(event)
|
|
182
|
+
else:
|
|
183
|
+
if "room_id" not in event:
|
|
184
|
+
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
185
|
+
validate_room_head_precondition(event)
|
|
186
|
+
_validate_mentions(event.get("mentions"))
|
|
124
187
|
|
|
125
188
|
|
|
126
189
|
def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
|
|
127
190
|
event = envelope["event"]
|
|
128
191
|
actual = event.get("room_id")
|
|
129
192
|
if event["type"] == ROOM_CREATE:
|
|
130
|
-
|
|
131
|
-
raise AgentProtocolError("invalid_event", "room.create must not include room_id")
|
|
193
|
+
_validate_room_create_event_fields(event)
|
|
132
194
|
return
|
|
195
|
+
validate_room_head_precondition(event)
|
|
196
|
+
_validate_mentions(event.get("mentions"))
|
|
133
197
|
if actual is None:
|
|
134
198
|
raise AgentProtocolError("missing_room_id", "event requires a room_id")
|
|
135
199
|
if actual != path_room_id:
|
|
136
200
|
raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
|
|
137
201
|
|
|
138
202
|
|
|
203
|
+
def _validate_room_create_event_fields(event: Event) -> None:
|
|
204
|
+
if any(field in event for field in ("room_id", "base_seq", "base_hash", "mentions")):
|
|
205
|
+
raise AgentProtocolError(
|
|
206
|
+
"invalid_event",
|
|
207
|
+
"room.create must not include room_id, base_seq, base_hash, or mentions",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_room_head_precondition(event: Event) -> None:
|
|
212
|
+
base_seq = event.get("base_seq")
|
|
213
|
+
base_hash = event.get("base_hash")
|
|
214
|
+
if not isinstance(base_seq, int) or base_seq < 1 or base_seq > MAX_SAFE_NONCE:
|
|
215
|
+
raise AgentProtocolError("invalid_event", "base_seq must be a positive safe JSON integer")
|
|
216
|
+
if not isinstance(base_hash, str) or not base_hash.strip():
|
|
217
|
+
raise AgentProtocolError("invalid_event", "base_hash must not be empty")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _validate_mentions(mentions: Any) -> None:
|
|
221
|
+
if mentions is None:
|
|
222
|
+
return
|
|
223
|
+
if not isinstance(mentions, list):
|
|
224
|
+
raise AgentProtocolError("invalid_event", "mentions must be an Agent ID array")
|
|
225
|
+
for mention in mentions:
|
|
226
|
+
validate_agent_id(mention)
|
|
227
|
+
|
|
228
|
+
|
|
139
229
|
def validate_custom_event_type_name(name: str) -> None:
|
|
140
230
|
"""Checks the shape of a custom event type name: lowercase dot-separated,
|
|
141
231
|
at least two segments, not built-in, not under a reserved prefix."""
|
|
@@ -349,6 +439,19 @@ def validate_message_create_payload(payload: dict[str, Any]) -> None:
|
|
|
349
439
|
raise AgentProtocolError("invalid_event", "content_type must not be empty")
|
|
350
440
|
|
|
351
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
|
+
|
|
352
455
|
def server_record_hash_payload(
|
|
353
456
|
room_id: str,
|
|
354
457
|
seq: int,
|
|
@@ -448,7 +551,7 @@ def can_submit_event(
|
|
|
448
551
|
if event_type == ROOM_CREATE:
|
|
449
552
|
return True
|
|
450
553
|
if event_type == ROOM_JOIN:
|
|
451
|
-
return bool(context.get("join_request_approved"))
|
|
554
|
+
return bool(context.get("public_join_allowed") or context.get("join_request_approved"))
|
|
452
555
|
if event_type == ROOM_LEAVE:
|
|
453
556
|
return is_creator or role is not None
|
|
454
557
|
if event_type in {ROOM_JOIN_REVIEW, ROOM_MEMBER_ROLE_UPDATE, ROOM_CLOSE, ROOM_CANCEL, TYPE_DEFINE}:
|
|
@@ -53,6 +53,43 @@ class DiscourseClient:
|
|
|
53
53
|
def room(self, room_id: str) -> dict[str, Any]:
|
|
54
54
|
return self._get(f"/v1/rooms/{room_id}")
|
|
55
55
|
|
|
56
|
+
def public_rooms(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
status: str | None = None,
|
|
60
|
+
tag: str | None = None,
|
|
61
|
+
keyword: str | None = None,
|
|
62
|
+
creator: str | None = None,
|
|
63
|
+
starts_after: int | None = None,
|
|
64
|
+
ends_before: int | None = None,
|
|
65
|
+
language: str | None = None,
|
|
66
|
+
limit: int | None = None,
|
|
67
|
+
cursor: str | None = None,
|
|
68
|
+
) -> list[dict[str, Any]]:
|
|
69
|
+
query = urlencode(
|
|
70
|
+
{
|
|
71
|
+
key: value
|
|
72
|
+
for key, value in {
|
|
73
|
+
"status": status,
|
|
74
|
+
"tag": tag,
|
|
75
|
+
"keyword": keyword,
|
|
76
|
+
"creator": creator,
|
|
77
|
+
"starts_after": starts_after,
|
|
78
|
+
"ends_before": ends_before,
|
|
79
|
+
"language": language,
|
|
80
|
+
"limit": limit,
|
|
81
|
+
"cursor": cursor,
|
|
82
|
+
}.items()
|
|
83
|
+
if value is not None
|
|
84
|
+
},
|
|
85
|
+
quote_via=quote,
|
|
86
|
+
)
|
|
87
|
+
suffix = f"?{query}" if query else ""
|
|
88
|
+
return self._get(f"/v1/rooms/public{suffix}")
|
|
89
|
+
|
|
90
|
+
def my_rooms(self, jwt: str) -> list[dict[str, Any]]:
|
|
91
|
+
return self._get("/v1/me/rooms", jwt=jwt)
|
|
92
|
+
|
|
56
93
|
def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
57
94
|
return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
|
|
58
95
|
|
|
@@ -95,8 +132,17 @@ class DiscourseClient:
|
|
|
95
132
|
suffix = f"?{query}" if query else ""
|
|
96
133
|
return self._get(f"/v1/rooms/{room_id}/events{suffix}", jwt=jwt)
|
|
97
134
|
|
|
98
|
-
def
|
|
99
|
-
return
|
|
135
|
+
def agent_statuses(self, room_id: str, jwt: str | None = None) -> dict[str, Any]:
|
|
136
|
+
return self._get(f"/v1/rooms/{room_id}/agent-status", jwt=jwt)
|
|
137
|
+
|
|
138
|
+
def agent_status(self, room_id: str, agent_id: AgentId, jwt: str | None = None) -> dict[str, Any]:
|
|
139
|
+
return self._get(f"/v1/rooms/{room_id}/agent-status/{agent_id}", jwt=jwt)
|
|
140
|
+
|
|
141
|
+
def set_agent_status(self, room_id: str, jwt: str, status: dict[str, Any]) -> dict[str, Any]:
|
|
142
|
+
return self._put(f"/v1/rooms/{room_id}/agent-status", status, jwt=jwt)
|
|
143
|
+
|
|
144
|
+
def sse_events_url(self, room_id: str) -> str:
|
|
145
|
+
return sse_events_url(self.base_url, room_id)
|
|
100
146
|
|
|
101
147
|
def archive(self, room_id: str) -> dict[str, Any]:
|
|
102
148
|
return self._get(f"/v1/rooms/{room_id}/archive")
|
|
@@ -111,14 +157,56 @@ class DiscourseClient:
|
|
|
111
157
|
response.raise_for_status()
|
|
112
158
|
return response.json()
|
|
113
159
|
|
|
160
|
+
def _put(self, path: str, body: Any, jwt: str | None = None) -> Any:
|
|
161
|
+
response = self.session.put(self.base_url + path, json=body, headers=_auth_headers(jwt))
|
|
162
|
+
response.raise_for_status()
|
|
163
|
+
return response.json()
|
|
164
|
+
|
|
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
|
+
|
|
114
207
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
if websocket_base.startswith("https://"):
|
|
118
|
-
websocket_base = "wss://" + websocket_base[len("https://") :]
|
|
119
|
-
elif websocket_base.startswith("http://"):
|
|
120
|
-
websocket_base = "ws://" + websocket_base[len("http://") :]
|
|
121
|
-
return f"{websocket_base}/v1/rooms/{quote(room_id, safe='')}/events/live?access_token={quote(jwt, safe='')}"
|
|
208
|
+
def sse_events_url(base_url: str, room_id: str) -> str:
|
|
209
|
+
return f"{base_url.rstrip('/')}/v1/rooms/{quote(room_id, safe='')}/events/live"
|
|
122
210
|
|
|
123
211
|
|
|
124
212
|
def _auth_headers(jwt: str | None) -> dict[str, str] | None:
|
|
@@ -171,6 +171,31 @@ def with_room_id(event: Event, room_id: str) -> Event:
|
|
|
171
171
|
return next_event
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
def with_room_head(event: Event, base_seq: int, base_hash: str) -> Event:
|
|
175
|
+
validate_nonce(base_seq)
|
|
176
|
+
if not isinstance(base_hash, str) or not base_hash.strip():
|
|
177
|
+
raise AgentProtocolError("invalid_event", "base_hash must not be empty")
|
|
178
|
+
next_event = dict(event)
|
|
179
|
+
next_event["base_seq"] = base_seq
|
|
180
|
+
next_event["base_hash"] = base_hash
|
|
181
|
+
return next_event
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def with_mentions(event: Event, mentions: list[AgentId]) -> Event:
|
|
185
|
+
for mention in mentions:
|
|
186
|
+
validate_agent_id(mention)
|
|
187
|
+
next_event = dict(event)
|
|
188
|
+
next_event["mentions"] = list(mentions)
|
|
189
|
+
return next_event
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def with_mention(event: Event, agent_id: AgentId) -> Event:
|
|
193
|
+
validate_agent_id(agent_id)
|
|
194
|
+
next_event = dict(event)
|
|
195
|
+
next_event["mentions"] = [*next_event.get("mentions", []), agent_id]
|
|
196
|
+
return next_event
|
|
197
|
+
|
|
198
|
+
|
|
174
199
|
def canonical_event_bytes(event: Event) -> bytes:
|
|
175
200
|
canonical = rfc8785.dumps(event)
|
|
176
201
|
return canonical if isinstance(canonical, bytes) else canonical.encode()
|
|
@@ -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
|
-
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
|
|
|
@@ -42,3 +43,5 @@ profile = materialize_profile(envelope)
|
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
`username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
|
|
46
|
+
|
|
47
|
+
ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
|
|
@@ -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()
|
|
@@ -42,7 +42,7 @@ from agent_protocols.discourse import (
|
|
|
42
42
|
verify_server_record_chain,
|
|
43
43
|
)
|
|
44
44
|
from agent_protocols.errors import AgentProtocolError
|
|
45
|
-
from agent_protocols.http_client import
|
|
45
|
+
from agent_protocols.http_client import sse_events_url
|
|
46
46
|
from agent_protocols.identity import AgentSigner, create_event
|
|
47
47
|
|
|
48
48
|
PACKS_PATH = Path(__file__).resolve().parents[3] / "docs/protocols/agent-discourse/1.0.packs.json"
|
|
@@ -143,6 +143,8 @@ class DiscourseTests(unittest.TestCase):
|
|
|
143
143
|
},
|
|
144
144
|
)
|
|
145
145
|
event["room_id"] = "d8ftedhpqhsusbg001tg"
|
|
146
|
+
event["base_seq"] = 17
|
|
147
|
+
event["base_hash"] = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs"
|
|
146
148
|
envelope = moderator.sign_event(event)
|
|
147
149
|
validator = Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
|
|
148
150
|
|
|
@@ -336,7 +338,15 @@ class DiscourseTests(unittest.TestCase):
|
|
|
336
338
|
|
|
337
339
|
def test_signs_and_validates_type_define_envelopes(self):
|
|
338
340
|
signer = AgentSigner.from_seed(bytes([16]) * 32)
|
|
339
|
-
event = type_define_event(
|
|
341
|
+
event = type_define_event(
|
|
342
|
+
signer.agent_id(),
|
|
343
|
+
100,
|
|
344
|
+
1,
|
|
345
|
+
"d8ftedhpqhsusbg001tg",
|
|
346
|
+
1,
|
|
347
|
+
"room-create-head",
|
|
348
|
+
dict(FINDING_DEF),
|
|
349
|
+
)
|
|
340
350
|
envelope = signer.sign_event(event)
|
|
341
351
|
validate_discourse_envelope(envelope)
|
|
342
352
|
|
|
@@ -371,6 +381,8 @@ class DiscourseTests(unittest.TestCase):
|
|
|
371
381
|
{"content_type": "text/plain", "content": "hello"},
|
|
372
382
|
)
|
|
373
383
|
event2["room_id"] = "room123"
|
|
384
|
+
event2["base_seq"] = 1
|
|
385
|
+
event2["base_hash"] = record1["hash"]
|
|
374
386
|
envelope2 = signer.sign_event(event2)
|
|
375
387
|
record2 = build_server_record("room123", 2, record1["hash"], 130, envelope2)
|
|
376
388
|
|
|
@@ -383,10 +395,10 @@ class DiscourseTests(unittest.TestCase):
|
|
|
383
395
|
with self.assertRaises(AgentProtocolError):
|
|
384
396
|
verify_server_record_chain([{**record2, "pre_hash": "bad"}])
|
|
385
397
|
|
|
386
|
-
def
|
|
398
|
+
def test_builds_sse_event_stream_url(self):
|
|
387
399
|
self.assertEqual(
|
|
388
|
-
|
|
389
|
-
"
|
|
400
|
+
sse_events_url("https://api.example.com", "room123"),
|
|
401
|
+
"https://api.example.com/v1/rooms/room123/events/live",
|
|
390
402
|
)
|
|
391
403
|
|
|
392
404
|
|
|
@@ -40,13 +40,15 @@ class HelperBranchTests(unittest.TestCase):
|
|
|
40
40
|
signer = AgentSigner.from_seed(bytes([60]) * 32)
|
|
41
41
|
self.assertTrue(event_requires_room_id(MESSAGE_CREATE))
|
|
42
42
|
self.assertFalse(event_requires_room_id(ROOM_CREATE))
|
|
43
|
-
event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
|
|
43
|
+
event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
|
|
44
44
|
self.assertEqual(event["room_id"], "room1")
|
|
45
|
+
self.assertEqual(event["base_seq"], 1)
|
|
46
|
+
self.assertEqual(event["base_hash"], "room-create-head")
|
|
45
47
|
self.assertEqual(event["protocol"], DISCOURSE_PROTOCOL)
|
|
46
48
|
|
|
47
49
|
def test_validate_discourse_envelope_rejects_foreign_protocol(self):
|
|
48
50
|
signer = AgentSigner.from_seed(bytes([61]) * 32)
|
|
49
|
-
event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
|
|
51
|
+
event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
|
|
50
52
|
event["protocol"] = "other/1.0"
|
|
51
53
|
envelope = signer.sign_event(event)
|
|
52
54
|
with self.assertRaises(AgentProtocolError):
|
|
@@ -55,7 +57,7 @@ class HelperBranchTests(unittest.TestCase):
|
|
|
55
57
|
def test_validate_room_path_matches_mismatches_and_requires_room_id(self):
|
|
56
58
|
signer = AgentSigner.from_seed(bytes([62]) * 32)
|
|
57
59
|
in_room = signer.sign_event(
|
|
58
|
-
discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
|
|
60
|
+
discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
|
|
59
61
|
)
|
|
60
62
|
validate_room_path(in_room, "room1")
|
|
61
63
|
with self.assertRaises(AgentProtocolError):
|
|
@@ -63,7 +65,7 @@ class HelperBranchTests(unittest.TestCase):
|
|
|
63
65
|
|
|
64
66
|
no_room = {
|
|
65
67
|
"hash": "h",
|
|
66
|
-
"event": {"type": MESSAGE_CREATE, "protocol": DISCOURSE_PROTOCOL},
|
|
68
|
+
"event": {"type": MESSAGE_CREATE, "protocol": DISCOURSE_PROTOCOL, "base_seq": 1, "base_hash": "room-create-head"},
|
|
67
69
|
"signature": "s",
|
|
68
70
|
}
|
|
69
71
|
with self.assertRaises(AgentProtocolError):
|
|
@@ -169,7 +171,16 @@ class ServerRecordChainTests(unittest.TestCase):
|
|
|
169
171
|
|
|
170
172
|
def make(seq, nonce, pre_hash):
|
|
171
173
|
envelope = signer.sign_event(
|
|
172
|
-
discourse_event(
|
|
174
|
+
discourse_event(
|
|
175
|
+
MESSAGE_CREATE,
|
|
176
|
+
signer.agent_id(),
|
|
177
|
+
100,
|
|
178
|
+
nonce,
|
|
179
|
+
"room1",
|
|
180
|
+
1 if seq == 1 else seq - 1,
|
|
181
|
+
pre_hash or "room-create-head",
|
|
182
|
+
{"a": 1},
|
|
183
|
+
)
|
|
173
184
|
)
|
|
174
185
|
return build_server_record("room1", seq, pre_hash, 100 + seq, envelope)
|
|
175
186
|
|
|
@@ -2,9 +2,10 @@ 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,
|
|
8
9
|
)
|
|
9
10
|
from agent_protocols.identity import AgentSigner
|
|
10
11
|
|
|
@@ -37,6 +38,10 @@ class FakeSession:
|
|
|
37
38
|
self.calls.append(("POST", url, headers, json))
|
|
38
39
|
return self._responses.pop(0)
|
|
39
40
|
|
|
41
|
+
def put(self, url, json=None, headers=None):
|
|
42
|
+
self.calls.append(("PUT", url, headers, json))
|
|
43
|
+
return self._responses.pop(0)
|
|
44
|
+
|
|
40
45
|
|
|
41
46
|
class ProfileClientTests(unittest.TestCase):
|
|
42
47
|
def test_every_endpoint_uses_the_expected_method_and_path(self):
|
|
@@ -119,28 +124,96 @@ class DiscourseClientTests(unittest.TestCase):
|
|
|
119
124
|
self.assertEqual(session.calls[10][2], {"Authorization": "Bearer jwt-d"})
|
|
120
125
|
self.assertEqual(urls[11], "https://api.example.com/v1/rooms/room1/archive")
|
|
121
126
|
|
|
122
|
-
def
|
|
127
|
+
def test_sse_events_url_method(self):
|
|
123
128
|
session = FakeSession([])
|
|
124
129
|
client = DiscourseClient("https://api.example.com", session=session)
|
|
125
130
|
self.assertEqual(
|
|
126
|
-
client.
|
|
127
|
-
|
|
131
|
+
client.sse_events_url("room1"),
|
|
132
|
+
sse_events_url("https://api.example.com", "room1"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def test_public_rooms_my_rooms_and_agent_status_endpoints(self):
|
|
136
|
+
session = FakeSession([FakeResponse({"ok": True}) for _ in range(5)])
|
|
137
|
+
client = DiscourseClient("https://api.example.com/", session=session)
|
|
138
|
+
|
|
139
|
+
client.public_rooms(
|
|
140
|
+
status="active",
|
|
141
|
+
tag="code review",
|
|
142
|
+
starts_after=10,
|
|
143
|
+
ends_before=20,
|
|
144
|
+
limit=5,
|
|
145
|
+
cursor="next page",
|
|
146
|
+
)
|
|
147
|
+
client.my_rooms("jwt-me")
|
|
148
|
+
client.agent_statuses("room1", jwt="jwt-statuses")
|
|
149
|
+
client.agent_status("room1", AGENT_ID, jwt="jwt-status")
|
|
150
|
+
client.set_agent_status("room1", "jwt-set", {"state": "idle", "expires_at": 2})
|
|
151
|
+
|
|
152
|
+
urls = [call[1] for call in session.calls]
|
|
153
|
+
self.assertEqual(
|
|
154
|
+
urls[0],
|
|
155
|
+
"https://api.example.com/v1/rooms/public?status=active&tag=code%20review&starts_after=10&ends_before=20&limit=5&cursor=next%20page",
|
|
128
156
|
)
|
|
157
|
+
self.assertEqual(urls[1], "https://api.example.com/v1/me/rooms")
|
|
158
|
+
self.assertEqual(session.calls[1][2], {"Authorization": "Bearer jwt-me"})
|
|
159
|
+
self.assertEqual(urls[2], "https://api.example.com/v1/rooms/room1/agent-status")
|
|
160
|
+
self.assertEqual(session.calls[2][2], {"Authorization": "Bearer jwt-statuses"})
|
|
161
|
+
self.assertEqual(urls[3], f"https://api.example.com/v1/rooms/room1/agent-status/{AGENT_ID}")
|
|
162
|
+
self.assertEqual(session.calls[4][0], "PUT")
|
|
163
|
+
self.assertEqual(session.calls[4][2], {"Authorization": "Bearer jwt-set"})
|
|
164
|
+
self.assertEqual(session.calls[4][3], {"state": "idle", "expires_at": 2})
|
|
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")
|
|
129
202
|
|
|
130
203
|
|
|
131
204
|
class HelperTests(unittest.TestCase):
|
|
132
|
-
def
|
|
205
|
+
def test_sse_events_url_preserves_http_schemes(self):
|
|
133
206
|
self.assertEqual(
|
|
134
|
-
|
|
135
|
-
"
|
|
207
|
+
sse_events_url("https://api.example.com", "room123"),
|
|
208
|
+
"https://api.example.com/v1/rooms/room123/events/live",
|
|
136
209
|
)
|
|
137
210
|
self.assertEqual(
|
|
138
|
-
|
|
139
|
-
"
|
|
211
|
+
sse_events_url("http://api.example.com/", "room 1"),
|
|
212
|
+
"http://api.example.com/v1/rooms/room%201/events/live",
|
|
140
213
|
)
|
|
141
214
|
self.assertEqual(
|
|
142
|
-
|
|
143
|
-
"ftp://api.example.com/v1/rooms/r/events/live
|
|
215
|
+
sse_events_url("ftp://api.example.com", "r"),
|
|
216
|
+
"ftp://api.example.com/v1/rooms/r/events/live",
|
|
144
217
|
)
|
|
145
218
|
|
|
146
219
|
def test_requests_session_factory(self):
|
|
@@ -21,9 +21,12 @@ from agent_protocols.identity import (
|
|
|
21
21
|
unix_ms,
|
|
22
22
|
unix_secs,
|
|
23
23
|
validate_agent_id,
|
|
24
|
+
verify_envelope,
|
|
24
25
|
verify_event_hash_signature,
|
|
25
26
|
verify_request_jwt,
|
|
26
27
|
verify_timestamp,
|
|
28
|
+
with_mention,
|
|
29
|
+
with_room_head,
|
|
27
30
|
with_room_id,
|
|
28
31
|
)
|
|
29
32
|
from agent_protocols.errors import AgentProtocolError
|
|
@@ -65,8 +68,18 @@ class AgentIdTests(unittest.TestCase):
|
|
|
65
68
|
class EventTests(unittest.TestCase):
|
|
66
69
|
def test_create_event_and_with_room_id(self):
|
|
67
70
|
signer = AgentSigner.from_seed(bytes([21]) * 32)
|
|
71
|
+
target = AgentSigner.from_seed(bytes([22]) * 32)
|
|
68
72
|
event = create_event("p", "t", signer.agent_id(), 1000, 1, {"a": 1})
|
|
69
73
|
self.assertEqual(with_room_id(event, "room1")["room_id"], "room1")
|
|
74
|
+
room_event = with_mention(with_room_head(with_room_id(event, "room1"), 1, "room-create-head"), target.agent_id())
|
|
75
|
+
self.assertEqual(room_event["base_seq"], 1)
|
|
76
|
+
self.assertEqual(room_event["base_hash"], "room-create-head")
|
|
77
|
+
self.assertEqual(room_event["mentions"], [target.agent_id()])
|
|
78
|
+
verify_envelope(signer.sign_event(room_event))
|
|
79
|
+
with self.assertRaises(AgentProtocolError):
|
|
80
|
+
with_room_head(event, 0, "head")
|
|
81
|
+
with self.assertRaises(AgentProtocolError):
|
|
82
|
+
with_mention(event, "bad-agent")
|
|
70
83
|
with self.assertRaises(AgentProtocolError):
|
|
71
84
|
create_event("p", "t", "bad-actor", 1, 1, {})
|
|
72
85
|
|
|
@@ -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"])
|
|
File without changes
|
|
File without changes
|
{agent_protocols-0.3.1 → agent_protocols-0.4.1}/src/agent_protocols.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|