sealkeeper 0.1.0__py3-none-any.whl
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.
- sealkeeper/__init__.py +37 -0
- sealkeeper/_protocol.py +277 -0
- sealkeeper/_version.py +1 -0
- sealkeeper/client.py +303 -0
- sealkeeper/errors.py +23 -0
- sealkeeper/gate.py +259 -0
- sealkeeper/lookup.py +120 -0
- sealkeeper/py.typed +0 -0
- sealkeeper/seal.py +339 -0
- sealkeeper-0.1.0.dist-info/METADATA +162 -0
- sealkeeper-0.1.0.dist-info/RECORD +13 -0
- sealkeeper-0.1.0.dist-info/WHEEL +4 -0
- sealkeeper-0.1.0.dist-info/licenses/LICENSE +21 -0
sealkeeper/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Look up and verify SealKeeper SEALs, and gate calls on them."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import AsyncSealKeeper, SealKeeper
|
|
5
|
+
from .errors import BadTargetError, SealBroken, SealKeeperError, UnavailableError
|
|
6
|
+
from .gate import BLOCK_CODES, PASS_CODES, Block, Gate, Pass
|
|
7
|
+
from .lookup import Lookup
|
|
8
|
+
from .seal import (
|
|
9
|
+
SEAL_BROKEN_REASONS,
|
|
10
|
+
SEAL_VERSIONS,
|
|
11
|
+
VerifiedSeal,
|
|
12
|
+
agent_id_from_did_key,
|
|
13
|
+
read_keys,
|
|
14
|
+
verify_with_keys,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BLOCK_CODES",
|
|
19
|
+
"PASS_CODES",
|
|
20
|
+
"SEAL_BROKEN_REASONS",
|
|
21
|
+
"SEAL_VERSIONS",
|
|
22
|
+
"AsyncSealKeeper",
|
|
23
|
+
"BadTargetError",
|
|
24
|
+
"Block",
|
|
25
|
+
"Gate",
|
|
26
|
+
"Lookup",
|
|
27
|
+
"Pass",
|
|
28
|
+
"SealBroken",
|
|
29
|
+
"SealKeeper",
|
|
30
|
+
"SealKeeperError",
|
|
31
|
+
"UnavailableError",
|
|
32
|
+
"VerifiedSeal",
|
|
33
|
+
"__version__",
|
|
34
|
+
"agent_id_from_did_key",
|
|
35
|
+
"read_keys",
|
|
36
|
+
"verify_with_keys",
|
|
37
|
+
]
|
sealkeeper/_protocol.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""The requests the clients send and how they read the answers.
|
|
2
|
+
|
|
3
|
+
Pure functions and one small key cache. The sync and async clients only
|
|
4
|
+
move bytes, so both read every answer the same way.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import unquote, urlsplit
|
|
15
|
+
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .errors import BadTargetError, SealKeeperError, UnavailableError
|
|
18
|
+
from .seal import DID_KEY_PREFIX, agent_id_from_did_key, is_agent_id
|
|
19
|
+
|
|
20
|
+
MCP_URL = "https://api.sealkeeper.run/mcp"
|
|
21
|
+
KEYS_URL = "https://sealkeeper.run/.well-known/seal.json"
|
|
22
|
+
PAGE_ORIGIN = ("sealkeeper.run", "https")
|
|
23
|
+
API_ORIGIN = ("api.sealkeeper.run", "https")
|
|
24
|
+
MCP_PROTOCOL_VERSION = "2025-11-25"
|
|
25
|
+
|
|
26
|
+
TIMEOUT_SECONDS = 5.0
|
|
27
|
+
ANSWER_MAX_BYTES = 64 * 1024
|
|
28
|
+
# How long a copy of the keys is used before it is fetched again.
|
|
29
|
+
KEYS_MAX_AGE_SECONDS = 300
|
|
30
|
+
|
|
31
|
+
# The longest line of server text an exception carries.
|
|
32
|
+
_TEXT_MAX_CHARS = 500
|
|
33
|
+
_SUFFIX = re.compile(r"[!-~]+(?: [!-~]+)*")
|
|
34
|
+
_SUFFIX_MAX_CHARS = 128
|
|
35
|
+
_PAGE_PATH = re.compile(r"/agents/([^/]+)(?:/([^/]+))?/?")
|
|
36
|
+
_API_CARD_PATH = re.compile(r"/v1/agents/([A-Za-z0-9_-]{43})/card/?")
|
|
37
|
+
_URL = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*://)([^/?#]*)(.*)", re.DOTALL)
|
|
38
|
+
_HTTP_URL = re.compile(r"https?://", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def user_agent(suffix: str | None) -> str:
|
|
42
|
+
"""sealkeeper-py/<version>, then suffix, such as a wrapper's own name."""
|
|
43
|
+
base = f"sealkeeper-py/{__version__}"
|
|
44
|
+
if suffix is None:
|
|
45
|
+
return base
|
|
46
|
+
if (
|
|
47
|
+
not isinstance(suffix, str)
|
|
48
|
+
or len(suffix) > _SUFFIX_MAX_CHARS
|
|
49
|
+
or not _SUFFIX.fullmatch(suffix)
|
|
50
|
+
):
|
|
51
|
+
raise ValueError("user_agent_suffix must be printable ASCII words")
|
|
52
|
+
return f"{base} {suffix}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mcp_headers(agent: str) -> dict[str, str]:
|
|
56
|
+
return {
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
"accept": "application/json, text/event-stream",
|
|
59
|
+
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
60
|
+
"user-agent": agent,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def keys_headers(agent: str) -> dict[str, str]:
|
|
65
|
+
return {"accept": "application/json", "user-agent": agent}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def tool_call(name: str, arguments: Mapping[str, Any]) -> bytes:
|
|
69
|
+
"""One stateless JSON-RPC tools/call, the whole request body."""
|
|
70
|
+
message = {
|
|
71
|
+
"jsonrpc": "2.0",
|
|
72
|
+
"id": 1,
|
|
73
|
+
"method": "tools/call",
|
|
74
|
+
"params": {"name": name, "arguments": dict(arguments)},
|
|
75
|
+
}
|
|
76
|
+
return json.dumps(message, separators=(",", ":")).encode("utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def retry_wait(status: int, headers: Mapping[str, str], attempt: int) -> float | None:
|
|
80
|
+
"""The seconds to wait before the one retry of a 429, else None.
|
|
81
|
+
|
|
82
|
+
Only the first answer is retried, and only when Retry-After names
|
|
83
|
+
whole seconds no longer than the timeout.
|
|
84
|
+
"""
|
|
85
|
+
if status != 429 or attempt > 0:
|
|
86
|
+
return None
|
|
87
|
+
value = headers.get("retry-after", "").strip()
|
|
88
|
+
if not value.isascii() or not value.isdigit():
|
|
89
|
+
return None
|
|
90
|
+
seconds = int(value)
|
|
91
|
+
return float(seconds) if seconds <= TIMEOUT_SECONDS else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _short(text: str) -> str:
|
|
95
|
+
return text if len(text) <= _TEXT_MAX_CHARS else text[:_TEXT_MAX_CHARS] + "..."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _json(body: bytes) -> object:
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(body.decode("utf-8"))
|
|
101
|
+
except (ValueError, RecursionError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _error_message(parsed: object) -> str:
|
|
106
|
+
error = parsed.get("error") if isinstance(parsed, dict) else None
|
|
107
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
108
|
+
return _short(message) if isinstance(message, str) else "no message"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_answer(status: int, body: bytes) -> dict[str, Any]:
|
|
112
|
+
"""The structuredContent of a tools/call answer, or the error it is.
|
|
113
|
+
|
|
114
|
+
A 429 or a 5xx is UnavailableError. A tool error is BadTargetError with
|
|
115
|
+
the server's line. Any other status, a JSON-RPC error or an answer
|
|
116
|
+
that is not a tool result is SealKeeperError.
|
|
117
|
+
"""
|
|
118
|
+
if status == 429:
|
|
119
|
+
raise UnavailableError("SealKeeper is rate limiting this caller")
|
|
120
|
+
if status >= 500:
|
|
121
|
+
raise UnavailableError(f"SealKeeper answered HTTP {status}")
|
|
122
|
+
parsed = _json(body)
|
|
123
|
+
if status != 200:
|
|
124
|
+
raise SealKeeperError(
|
|
125
|
+
f"SealKeeper refused the request, HTTP {status}: {_error_message(parsed)}"
|
|
126
|
+
)
|
|
127
|
+
if not isinstance(parsed, dict):
|
|
128
|
+
raise SealKeeperError("SealKeeper's answer is not a JSON object")
|
|
129
|
+
if "error" in parsed:
|
|
130
|
+
raise SealKeeperError(f"SealKeeper answered an error: {_error_message(parsed)}")
|
|
131
|
+
result = parsed.get("result")
|
|
132
|
+
if not isinstance(result, dict):
|
|
133
|
+
raise SealKeeperError("SealKeeper's answer has no result")
|
|
134
|
+
if result.get("isError") is True:
|
|
135
|
+
raise BadTargetError(_first_text(result))
|
|
136
|
+
data = result.get("structuredContent")
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
raise SealKeeperError("SealKeeper's answer has no structuredContent")
|
|
139
|
+
return data
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _first_text(result: dict[str, Any]) -> str:
|
|
143
|
+
content = result.get("content")
|
|
144
|
+
if isinstance(content, list):
|
|
145
|
+
for item in content:
|
|
146
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
147
|
+
return _short(item["text"])
|
|
148
|
+
return "SealKeeper refused the input"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def read_keys_answer(status: int, body: bytes) -> object:
|
|
152
|
+
"""The key document of a 200 answer. Anything else is UnavailableError."""
|
|
153
|
+
if status != 200:
|
|
154
|
+
raise UnavailableError(f"could not load the SealKeeper keys, HTTP {status}")
|
|
155
|
+
parsed = _json(body)
|
|
156
|
+
if parsed is None:
|
|
157
|
+
raise UnavailableError("the SealKeeper key document is not JSON")
|
|
158
|
+
return parsed
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def page_url(agent_id: str) -> str:
|
|
162
|
+
return f"https://sealkeeper.run/agents/{agent_id}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def own_url_id(target: str) -> tuple[bool, str | None]:
|
|
166
|
+
"""Whether target is a SealKeeper page or API card URL, and its agent id.
|
|
167
|
+
|
|
168
|
+
A page is /agents/<first>/<name> or /agents/<id> on sealkeeper.run, a
|
|
169
|
+
card /v1/agents/<id>/card on api.sealkeeper.run. Only the id forms name
|
|
170
|
+
an id.
|
|
171
|
+
"""
|
|
172
|
+
if not _HTTP_URL.match(target):
|
|
173
|
+
return False, None
|
|
174
|
+
try:
|
|
175
|
+
parts = urlsplit(target)
|
|
176
|
+
port = parts.port
|
|
177
|
+
except ValueError:
|
|
178
|
+
return False, None
|
|
179
|
+
origin = (parts.hostname, parts.scheme) if port in (None, 443) else None
|
|
180
|
+
path = parts.path
|
|
181
|
+
if origin == PAGE_ORIGIN:
|
|
182
|
+
page = _PAGE_PATH.fullmatch(path)
|
|
183
|
+
if page is None:
|
|
184
|
+
return False, None
|
|
185
|
+
first = unquote(page.group(1))
|
|
186
|
+
return True, first if page.group(2) is None and is_agent_id(first) else None
|
|
187
|
+
if origin == API_ORIGIN:
|
|
188
|
+
card = _API_CARD_PATH.fullmatch(path)
|
|
189
|
+
if card is None:
|
|
190
|
+
return False, None
|
|
191
|
+
return True, card.group(1) if is_agent_id(card.group(1)) else None
|
|
192
|
+
return False, None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def trust_target(target: object) -> tuple[str, str | None]:
|
|
196
|
+
"""What get_trust is sent for target, and the agent id target names.
|
|
197
|
+
|
|
198
|
+
A bare agent id goes as its page URL, and a did:key as the page URL of
|
|
199
|
+
the id it names. Every other target goes as given.
|
|
200
|
+
"""
|
|
201
|
+
if not isinstance(target, str) or not target.strip():
|
|
202
|
+
raise BadTargetError("the target must be a non-empty string")
|
|
203
|
+
text = target.strip()
|
|
204
|
+
if text.startswith(DID_KEY_PREFIX):
|
|
205
|
+
agent_id = agent_id_from_did_key(text)
|
|
206
|
+
if agent_id is None:
|
|
207
|
+
raise BadTargetError("the target is not an Ed25519 did:key")
|
|
208
|
+
return page_url(agent_id), agent_id
|
|
209
|
+
if is_agent_id(text):
|
|
210
|
+
return page_url(text), text
|
|
211
|
+
return target, own_url_id(text)[1]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def is_http_url(target: str) -> bool:
|
|
215
|
+
return _HTTP_URL.match(target.strip()) is not None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def is_listed_url(target: str) -> bool:
|
|
219
|
+
"""Whether target is an http or https URL other than SealKeeper's own."""
|
|
220
|
+
return is_http_url(target) and not own_url_id(target.strip())[0]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def host_lowered(url: str) -> str:
|
|
224
|
+
"""url with only its host in lower case, and an empty path read as /."""
|
|
225
|
+
match = _URL.fullmatch(url.strip())
|
|
226
|
+
if match is None:
|
|
227
|
+
return url
|
|
228
|
+
scheme, authority, rest = match.groups()
|
|
229
|
+
userinfo, at, host = authority.rpartition("@")
|
|
230
|
+
return f"{scheme}{userinfo}{at}{host.lower()}{rest or '/'}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def host_of(url: str) -> str | None:
|
|
234
|
+
try:
|
|
235
|
+
return urlsplit(url.strip()).hostname
|
|
236
|
+
except ValueError:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def verify_arguments(
|
|
241
|
+
seal: str, agent: str | None, at: datetime | str | None
|
|
242
|
+
) -> dict[str, str]:
|
|
243
|
+
"""verify_seal's arguments. A datetime at must carry its time zone."""
|
|
244
|
+
arguments = {"seal": seal}
|
|
245
|
+
if agent is not None:
|
|
246
|
+
arguments["agent"] = agent
|
|
247
|
+
if isinstance(at, datetime):
|
|
248
|
+
if at.tzinfo is None:
|
|
249
|
+
raise ValueError("at must carry its time zone")
|
|
250
|
+
utc = at.astimezone(timezone.utc)
|
|
251
|
+
arguments["at"] = (
|
|
252
|
+
utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{utc.microsecond // 1000:03d}Z"
|
|
253
|
+
)
|
|
254
|
+
elif at is not None:
|
|
255
|
+
arguments["at"] = at
|
|
256
|
+
return arguments
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class KeyCache:
|
|
260
|
+
"""The last key document fetched, kid to x, and when it was fetched."""
|
|
261
|
+
|
|
262
|
+
def __init__(self) -> None:
|
|
263
|
+
self._keys: dict[str, str] = {}
|
|
264
|
+
self._fetched_at: float | None = None
|
|
265
|
+
|
|
266
|
+
def usable(self, kid: str, now: float) -> dict[str, str] | None:
|
|
267
|
+
"""The keys while the copy is younger than five minutes and knows kid.
|
|
268
|
+
|
|
269
|
+
A copy dated after now counts as too old.
|
|
270
|
+
"""
|
|
271
|
+
if self._fetched_at is None or kid not in self._keys:
|
|
272
|
+
return None
|
|
273
|
+
age = now - self._fetched_at
|
|
274
|
+
return self._keys if 0 <= age < KEYS_MAX_AGE_SECONDS else None
|
|
275
|
+
|
|
276
|
+
def store(self, keys: dict[str, str], now: float) -> None:
|
|
277
|
+
self._keys, self._fetched_at = keys, now
|
sealkeeper/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
sealkeeper/client.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""SealKeeper and AsyncSealKeeper, the two clients. They only do I/O.
|
|
2
|
+
|
|
3
|
+
Every request and every answer goes through _protocol, and every SEAL
|
|
4
|
+
through seal.verify_with_keys, so both clients behave the same.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from . import _protocol as p
|
|
18
|
+
from .errors import SealBroken, SealKeeperError, UnavailableError
|
|
19
|
+
from .lookup import Lookup, lookup_of, seal_to_check
|
|
20
|
+
from .seal import (
|
|
21
|
+
DID_KEY_PREFIX,
|
|
22
|
+
VerifiedSeal,
|
|
23
|
+
agent_id_from_did_key,
|
|
24
|
+
is_agent_id,
|
|
25
|
+
read_keys,
|
|
26
|
+
seal_kid,
|
|
27
|
+
verify_with_keys,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
Clock = Callable[[], float]
|
|
31
|
+
_Answer = tuple[int, Mapping[str, str], bytes]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _expected_id(expected_agent_id: str) -> str:
|
|
35
|
+
"""The raw agent id, from an agent id or its did:key."""
|
|
36
|
+
agent_id: object = expected_agent_id
|
|
37
|
+
if isinstance(agent_id, str) and agent_id.startswith(DID_KEY_PREFIX):
|
|
38
|
+
agent_id = agent_id_from_did_key(agent_id)
|
|
39
|
+
if not is_agent_id(agent_id):
|
|
40
|
+
raise ValueError("expected_agent_id must be an agent id or its did:key")
|
|
41
|
+
return agent_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _keys_of(status: int, body: bytes) -> dict[str, str]:
|
|
45
|
+
try:
|
|
46
|
+
return read_keys(p.read_keys_answer(status, body))
|
|
47
|
+
except ValueError as error:
|
|
48
|
+
raise UnavailableError(f"could not use the SealKeeper keys, {error}") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _too_large() -> SealKeeperError:
|
|
52
|
+
return SealKeeperError(
|
|
53
|
+
f"SealKeeper's answer is larger than {p.ANSWER_MAX_BYTES} bytes"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _timed_out() -> UnavailableError:
|
|
58
|
+
return UnavailableError(
|
|
59
|
+
f"SealKeeper did not answer within {p.TIMEOUT_SECONDS} seconds"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _unreachable(error: Exception) -> UnavailableError:
|
|
64
|
+
return UnavailableError(f"could not reach SealKeeper, {type(error).__name__}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_capped(chunks: Iterator[bytes], deadline: float) -> bytes:
|
|
68
|
+
body = bytearray()
|
|
69
|
+
for chunk in chunks:
|
|
70
|
+
body += chunk
|
|
71
|
+
if len(body) > p.ANSWER_MAX_BYTES:
|
|
72
|
+
raise _too_large()
|
|
73
|
+
if time.monotonic() > deadline:
|
|
74
|
+
raise _timed_out()
|
|
75
|
+
return bytes(body)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _aread_capped(chunks: AsyncIterator[bytes], deadline: float) -> bytes:
|
|
79
|
+
body = bytearray()
|
|
80
|
+
async for chunk in chunks:
|
|
81
|
+
body += chunk
|
|
82
|
+
if len(body) > p.ANSWER_MAX_BYTES:
|
|
83
|
+
raise _too_large()
|
|
84
|
+
if time.monotonic() > deadline:
|
|
85
|
+
raise _timed_out()
|
|
86
|
+
return bytes(body)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SealKeeper:
|
|
90
|
+
"""Reads trust from SealKeeper and verifies SEALs, one blocking call each.
|
|
91
|
+
|
|
92
|
+
http_client is an httpx.Client to send through, for a caller's own
|
|
93
|
+
transport or a test. One is made and closed with the client when none
|
|
94
|
+
is given. clock answers Unix seconds and decides whether a SEAL is
|
|
95
|
+
current. user_agent_suffix is added to the User-Agent, such as
|
|
96
|
+
"langchain-sealkeeper/0.1.0". sleep waits before the one retry of a 429.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
http_client: httpx.Client | None = None,
|
|
103
|
+
clock: Clock | None = None,
|
|
104
|
+
user_agent_suffix: str | None = None,
|
|
105
|
+
sleep: Callable[[float], None] | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
agent = p.user_agent(user_agent_suffix)
|
|
108
|
+
self._mcp_headers = p.mcp_headers(agent)
|
|
109
|
+
self._keys_headers = p.keys_headers(agent)
|
|
110
|
+
self._owns_http = http_client is None
|
|
111
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
112
|
+
self.clock: Clock = clock if clock is not None else time.time
|
|
113
|
+
self._sleep = sleep if sleep is not None else time.sleep
|
|
114
|
+
self._keys = p.KeyCache()
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
if self._owns_http:
|
|
118
|
+
self._http.close()
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> SealKeeper:
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(self, *_: object) -> None:
|
|
124
|
+
self.close()
|
|
125
|
+
|
|
126
|
+
def get_trust(self, target: str) -> dict[str, Any]:
|
|
127
|
+
"""get_trust's answer for a handle, a URL, an agent id or a did:key.
|
|
128
|
+
|
|
129
|
+
The answer as SealKeeper sends it. Its agent fields are unsigned,
|
|
130
|
+
so a decision reads lookup instead.
|
|
131
|
+
"""
|
|
132
|
+
return self._call("get_trust", {"agent": p.trust_target(target)[0]})
|
|
133
|
+
|
|
134
|
+
def verify_seal(
|
|
135
|
+
self, seal: str, agent: str | None = None, at: datetime | str | None = None
|
|
136
|
+
) -> dict[str, Any]:
|
|
137
|
+
"""SealKeeper's own check of a SEAL, with its record of holds.
|
|
138
|
+
|
|
139
|
+
agent is the agent id expected, at a time to check at in place of
|
|
140
|
+
now. The answer as SealKeeper sends it.
|
|
141
|
+
"""
|
|
142
|
+
return self._call("verify_seal", p.verify_arguments(seal, agent, at))
|
|
143
|
+
|
|
144
|
+
def verify(self, seal: str, expected_agent_id: str) -> VerifiedSeal:
|
|
145
|
+
"""Verifies a SEAL here, against SealKeeper's published keys.
|
|
146
|
+
|
|
147
|
+
Raises SealBroken with the reason, or UnavailableError when the keys
|
|
148
|
+
cannot be had and no copy younger than five minutes knows the kid.
|
|
149
|
+
"""
|
|
150
|
+
expected = _expected_id(expected_agent_id)
|
|
151
|
+
now = self.clock()
|
|
152
|
+
kid = seal_kid(seal)
|
|
153
|
+
keys = self._keys.usable(kid, now)
|
|
154
|
+
if keys is None:
|
|
155
|
+
status, _, body = self._send("GET", p.KEYS_URL, None, self._keys_headers)
|
|
156
|
+
keys = _keys_of(status, body)
|
|
157
|
+
self._keys.store(keys, now)
|
|
158
|
+
return verify_with_keys(seal, keys, now, expected)
|
|
159
|
+
|
|
160
|
+
def lookup(self, target: str) -> Lookup:
|
|
161
|
+
"""get_trust for target, with the SEAL in its answer verified here."""
|
|
162
|
+
argument, named_id = p.trust_target(target)
|
|
163
|
+
answer = self._call("get_trust", {"agent": argument})
|
|
164
|
+
check = seal_to_check(answer, named_id)
|
|
165
|
+
if check is None:
|
|
166
|
+
return lookup_of(answer, None, None)
|
|
167
|
+
try:
|
|
168
|
+
return lookup_of(answer, self.verify(*check), None)
|
|
169
|
+
except SealBroken as error:
|
|
170
|
+
return lookup_of(answer, None, error.reason)
|
|
171
|
+
|
|
172
|
+
def _call(self, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
|
173
|
+
body = p.tool_call(name, arguments)
|
|
174
|
+
attempt = 0
|
|
175
|
+
while True:
|
|
176
|
+
status, headers, answer = self._send(
|
|
177
|
+
"POST", p.MCP_URL, body, self._mcp_headers
|
|
178
|
+
)
|
|
179
|
+
wait = p.retry_wait(status, headers, attempt)
|
|
180
|
+
if wait is None:
|
|
181
|
+
return p.read_answer(status, answer)
|
|
182
|
+
self._sleep(wait)
|
|
183
|
+
attempt += 1
|
|
184
|
+
|
|
185
|
+
def _send(
|
|
186
|
+
self, method: str, url: str, body: bytes | None, headers: dict[str, str]
|
|
187
|
+
) -> _Answer:
|
|
188
|
+
deadline = time.monotonic() + p.TIMEOUT_SECONDS
|
|
189
|
+
try:
|
|
190
|
+
with self._http.stream(
|
|
191
|
+
method,
|
|
192
|
+
url,
|
|
193
|
+
content=body,
|
|
194
|
+
headers=headers,
|
|
195
|
+
timeout=p.TIMEOUT_SECONDS,
|
|
196
|
+
follow_redirects=False,
|
|
197
|
+
) as response:
|
|
198
|
+
data = _read_capped(response.iter_bytes(), deadline)
|
|
199
|
+
return response.status_code, response.headers, data
|
|
200
|
+
except httpx.TimeoutException:
|
|
201
|
+
raise _timed_out() from None
|
|
202
|
+
except httpx.TransportError as error:
|
|
203
|
+
raise _unreachable(error) from None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class AsyncSealKeeper:
|
|
207
|
+
"""SealKeeper with every call awaited, the same methods and answers.
|
|
208
|
+
|
|
209
|
+
http_client is an httpx.AsyncClient. sleep is awaited before the one
|
|
210
|
+
retry of a 429, asyncio.sleep unless another is given.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
http_client: httpx.AsyncClient | None = None,
|
|
217
|
+
clock: Clock | None = None,
|
|
218
|
+
user_agent_suffix: str | None = None,
|
|
219
|
+
sleep: Callable[[float], Awaitable[None]] | None = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
agent = p.user_agent(user_agent_suffix)
|
|
222
|
+
self._mcp_headers = p.mcp_headers(agent)
|
|
223
|
+
self._keys_headers = p.keys_headers(agent)
|
|
224
|
+
self._owns_http = http_client is None
|
|
225
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
226
|
+
self.clock: Clock = clock if clock is not None else time.time
|
|
227
|
+
self._sleep = sleep if sleep is not None else asyncio.sleep
|
|
228
|
+
self._keys = p.KeyCache()
|
|
229
|
+
|
|
230
|
+
async def aclose(self) -> None:
|
|
231
|
+
if self._owns_http:
|
|
232
|
+
await self._http.aclose()
|
|
233
|
+
|
|
234
|
+
async def __aenter__(self) -> AsyncSealKeeper:
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
async def __aexit__(self, *_: object) -> None:
|
|
238
|
+
await self.aclose()
|
|
239
|
+
|
|
240
|
+
async def get_trust(self, target: str) -> dict[str, Any]:
|
|
241
|
+
return await self._call("get_trust", {"agent": p.trust_target(target)[0]})
|
|
242
|
+
|
|
243
|
+
async def verify_seal(
|
|
244
|
+
self, seal: str, agent: str | None = None, at: datetime | str | None = None
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
return await self._call("verify_seal", p.verify_arguments(seal, agent, at))
|
|
247
|
+
|
|
248
|
+
async def verify(self, seal: str, expected_agent_id: str) -> VerifiedSeal:
|
|
249
|
+
expected = _expected_id(expected_agent_id)
|
|
250
|
+
now = self.clock()
|
|
251
|
+
kid = seal_kid(seal)
|
|
252
|
+
keys = self._keys.usable(kid, now)
|
|
253
|
+
if keys is None:
|
|
254
|
+
status, _, body = await self._send(
|
|
255
|
+
"GET", p.KEYS_URL, None, self._keys_headers
|
|
256
|
+
)
|
|
257
|
+
keys = _keys_of(status, body)
|
|
258
|
+
self._keys.store(keys, now)
|
|
259
|
+
return verify_with_keys(seal, keys, now, expected)
|
|
260
|
+
|
|
261
|
+
async def lookup(self, target: str) -> Lookup:
|
|
262
|
+
argument, named_id = p.trust_target(target)
|
|
263
|
+
answer = await self._call("get_trust", {"agent": argument})
|
|
264
|
+
check = seal_to_check(answer, named_id)
|
|
265
|
+
if check is None:
|
|
266
|
+
return lookup_of(answer, None, None)
|
|
267
|
+
try:
|
|
268
|
+
return lookup_of(answer, await self.verify(*check), None)
|
|
269
|
+
except SealBroken as error:
|
|
270
|
+
return lookup_of(answer, None, error.reason)
|
|
271
|
+
|
|
272
|
+
async def _call(self, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
|
273
|
+
body = p.tool_call(name, arguments)
|
|
274
|
+
attempt = 0
|
|
275
|
+
while True:
|
|
276
|
+
status, headers, answer = await self._send(
|
|
277
|
+
"POST", p.MCP_URL, body, self._mcp_headers
|
|
278
|
+
)
|
|
279
|
+
wait = p.retry_wait(status, headers, attempt)
|
|
280
|
+
if wait is None:
|
|
281
|
+
return p.read_answer(status, answer)
|
|
282
|
+
await self._sleep(wait)
|
|
283
|
+
attempt += 1
|
|
284
|
+
|
|
285
|
+
async def _send(
|
|
286
|
+
self, method: str, url: str, body: bytes | None, headers: dict[str, str]
|
|
287
|
+
) -> _Answer:
|
|
288
|
+
deadline = time.monotonic() + p.TIMEOUT_SECONDS
|
|
289
|
+
try:
|
|
290
|
+
async with self._http.stream(
|
|
291
|
+
method,
|
|
292
|
+
url,
|
|
293
|
+
content=body,
|
|
294
|
+
headers=headers,
|
|
295
|
+
timeout=p.TIMEOUT_SECONDS,
|
|
296
|
+
follow_redirects=False,
|
|
297
|
+
) as response:
|
|
298
|
+
data = await _aread_capped(response.aiter_bytes(), deadline)
|
|
299
|
+
return response.status_code, response.headers, data
|
|
300
|
+
except httpx.TimeoutException:
|
|
301
|
+
raise _timed_out() from None
|
|
302
|
+
except httpx.TransportError as error:
|
|
303
|
+
raise _unreachable(error) from None
|
sealkeeper/errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The exceptions the library raises. Every one is a SealKeeperError."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SealKeeperError(Exception):
|
|
7
|
+
"""SealKeeper answered something this library cannot use."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BadTargetError(SealKeeperError):
|
|
11
|
+
"""SealKeeper refused the target or the input, with its line saying why."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnavailableError(SealKeeperError):
|
|
15
|
+
"""SealKeeper could not be reached, timed out or asked to slow down."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SealBroken(SealKeeperError):
|
|
19
|
+
"""A SEAL that did not verify. reason is one of SEAL_BROKEN_REASONS."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, reason: str) -> None:
|
|
22
|
+
super().__init__(f"broken SEAL: {reason}")
|
|
23
|
+
self.reason = reason
|