agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""``agentconnectd``: the separate-daemon host of the runtime (ADR-005).
|
|
2
|
+
|
|
3
|
+
It reads the configuration, registers the adapters the configuration names
|
|
4
|
+
(:func:`agentconnect.adapters.build_adapters`), runs one
|
|
5
|
+
:class:`~agentconnect.runtime.Runtime` until SIGINT or SIGTERM, and stops
|
|
6
|
+
it. Everything else, every check included, is the runtime's.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import signal
|
|
14
|
+
|
|
15
|
+
from agentconnect.adapters import build_adapters
|
|
16
|
+
from agentconnect.runtime import Config, Runtime, SystemClock
|
|
17
|
+
|
|
18
|
+
SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def serve(config: Config, ready: asyncio.Event | None = None) -> None:
|
|
22
|
+
"""Run the daemon until a signal (or cancellation); ``ready`` is set
|
|
23
|
+
once the runtime and its local API are up. Logging is the caller's
|
|
24
|
+
(``agentconnect.log.configure``)."""
|
|
25
|
+
clock = SystemClock()
|
|
26
|
+
runtime = Runtime(config, adapters=build_adapters(config, clock), clock=clock)
|
|
27
|
+
stop = asyncio.Event()
|
|
28
|
+
loop = asyncio.get_running_loop()
|
|
29
|
+
await runtime.start()
|
|
30
|
+
try:
|
|
31
|
+
for sig in SIGNALS:
|
|
32
|
+
with contextlib.suppress(NotImplementedError):
|
|
33
|
+
loop.add_signal_handler(sig, stop.set)
|
|
34
|
+
if ready is not None:
|
|
35
|
+
ready.set()
|
|
36
|
+
await stop.wait()
|
|
37
|
+
finally:
|
|
38
|
+
for sig in SIGNALS:
|
|
39
|
+
with contextlib.suppress(NotImplementedError):
|
|
40
|
+
loop.remove_signal_handler(sig)
|
|
41
|
+
await runtime.stop()
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""The delivery queue: messages kept until an agent tool takes them.
|
|
2
|
+
|
|
3
|
+
The MCP host (``agentconnect mcp``, ADR-005) registers it as the ``mcp``
|
|
4
|
+
adapter. An MCP client pulls: the model calls a tool that waits for
|
|
5
|
+
messages. So the adapter doesn't push anything. It keeps each delivery in
|
|
6
|
+
the runtime's encrypted database until a tool call has returned it, and
|
|
7
|
+
answers the runtime ``ACK`` at once, so the relay can drop its copy.
|
|
8
|
+
|
|
9
|
+
* **Durable.** A queued message survives a restart; one that no tool call
|
|
10
|
+
returned before the session ended is returned by the next one. The queue
|
|
11
|
+
keeps the newest 10 000.
|
|
12
|
+
* **At least once, deduplicated.** A delivery the runtime hands over again
|
|
13
|
+
after a crash has the same key (``["msg", group, sender, msg_id]``, the
|
|
14
|
+
embedded SDK's key): it isn't queued twice, and once a tool call returned
|
|
15
|
+
it, the key is in the runtime's handled ids (:attr:`Runtime.handled`'s
|
|
16
|
+
table), so a later repeat is dropped.
|
|
17
|
+
* **Leases.** :meth:`DeliveryQueue.take` leases what it returns for
|
|
18
|
+
:data:`LEASE_SECS` under a lease token; :meth:`DeliveryQueue.ack` with
|
|
19
|
+
that token takes it off the queue, and no other token can. Another waiter
|
|
20
|
+
(a second session attached through the local API) doesn't see a leased
|
|
21
|
+
message. A lease that runs out without an ack (the caller went away) puts
|
|
22
|
+
the message back; its holder may still ack it until another take leases it.
|
|
23
|
+
* **Addressed first.** Each row records whether it addresses this agent, so
|
|
24
|
+
a take with ``addressed_only`` finds the oldest such row directly, however
|
|
25
|
+
many others are queued before it.
|
|
26
|
+
* **Content only.** Text and task messages are queued. Agent Cards and
|
|
27
|
+
``control.*`` messages carry no text for the model: they are acknowledged
|
|
28
|
+
and not queued (the runtime keeps the card). Events are not queued
|
|
29
|
+
either: they are in the agent's event log (``GET /v0/events``).
|
|
30
|
+
|
|
31
|
+
Every queued message keeps its SPEC 10.1 framing (``untrusted``,
|
|
32
|
+
``notice``, ``scope``, ``addressed_to_me``) in the payload shape of the
|
|
33
|
+
webhook adapter (:mod:`agentconnect.payloads`).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import json
|
|
40
|
+
import secrets
|
|
41
|
+
from collections.abc import Iterable, Sequence
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from typing import Any, Protocol
|
|
44
|
+
|
|
45
|
+
from agentconnect.adapter import (
|
|
46
|
+
Capabilities,
|
|
47
|
+
Delivery,
|
|
48
|
+
DeliveryResult,
|
|
49
|
+
Event,
|
|
50
|
+
GroupView,
|
|
51
|
+
OutboundSend,
|
|
52
|
+
Sender,
|
|
53
|
+
)
|
|
54
|
+
from agentconnect.clock import Clock
|
|
55
|
+
from agentconnect.payloads import message_payload
|
|
56
|
+
|
|
57
|
+
ADAPTER_NAME = "mcp"
|
|
58
|
+
CONTENT_KINDS = frozenset(
|
|
59
|
+
{"a2a.message", "a2a.task_status", "a2a.task_artifact", "a2a.task_cancel"}
|
|
60
|
+
)
|
|
61
|
+
QUEUE_KEEP = 10_000 # queued messages kept per runtime, the newest
|
|
62
|
+
MAX_BATCH = 50 # messages one take returns at most
|
|
63
|
+
LEASE_SECS = 120.0 # how long a taken, unacknowledged message stays hidden
|
|
64
|
+
MAX_WAIT_SECS = 300.0 # the longest one take waits
|
|
65
|
+
HOLDER_KEEP_SECS = 3600.0 # how long a lease's holder may still ack after it ran out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class QueueStore(Protocol):
|
|
69
|
+
"""The part of the runtime's store the queue uses."""
|
|
70
|
+
|
|
71
|
+
def adapter_event_add(
|
|
72
|
+
self,
|
|
73
|
+
adapter: str,
|
|
74
|
+
event_id: str,
|
|
75
|
+
payload: dict[str, Any],
|
|
76
|
+
keep: int = ...,
|
|
77
|
+
*,
|
|
78
|
+
addressed: bool = ...,
|
|
79
|
+
) -> bool: ...
|
|
80
|
+
def queue_rows( # noqa: PLR0913
|
|
81
|
+
self,
|
|
82
|
+
adapter: str,
|
|
83
|
+
*,
|
|
84
|
+
group_id: str | None,
|
|
85
|
+
skip: Sequence[int],
|
|
86
|
+
limit: int,
|
|
87
|
+
addressed: bool = ...,
|
|
88
|
+
before: int | None = ...,
|
|
89
|
+
) -> list[tuple[int, dict[str, Any]]]: ...
|
|
90
|
+
def adapter_event_finish(self, adapter: str, seq: int) -> bool: ...
|
|
91
|
+
def handled_has(self, key: str) -> bool: ...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def delivery_key(payload: dict[str, Any]) -> str:
|
|
95
|
+
"""``["msg", group, sender, msg_id]``: each sender picks its own
|
|
96
|
+
``msg_id`` (SPEC 8.3), so the key names the sender too, and a peer can't
|
|
97
|
+
pick an id that hides another sender's message."""
|
|
98
|
+
group, sender = payload.get("group"), payload.get("sender")
|
|
99
|
+
group_id = group.get("id") if isinstance(group, dict) else None
|
|
100
|
+
identity = sender.get("identity") if isinstance(sender, dict) else None
|
|
101
|
+
return json.dumps(["msg", group_id, identity, payload.get("msg_id")], separators=(",", ":"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class _Holder:
|
|
106
|
+
token: str
|
|
107
|
+
ends: float # Unix seconds on the runtime's clock
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Batch:
|
|
112
|
+
"""What one take leased: ``lease`` acknowledges or releases it."""
|
|
113
|
+
|
|
114
|
+
lease: str
|
|
115
|
+
items: list[Taken]
|
|
116
|
+
more: bool
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Taken:
|
|
121
|
+
"""One leased message: ``delivery_id`` acknowledges it."""
|
|
122
|
+
|
|
123
|
+
delivery_id: int
|
|
124
|
+
payload: dict[str, Any]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DeliveryQueue:
|
|
128
|
+
"""Implements :class:`agentconnect.adapter.Adapter`; the agent pulls with
|
|
129
|
+
:meth:`take` and confirms with :meth:`ack`."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, clock: Clock, *, name: str = ADAPTER_NAME) -> None:
|
|
132
|
+
self.name = name
|
|
133
|
+
self._clock = clock
|
|
134
|
+
self._store: QueueStore | None = None
|
|
135
|
+
self._holders: dict[int, _Holder] = {} # delivery_id -> its lease
|
|
136
|
+
self._changed = asyncio.Event()
|
|
137
|
+
self._closed = False
|
|
138
|
+
self.waiting = 0 # takes waiting now (status and tests)
|
|
139
|
+
|
|
140
|
+
def capabilities(self) -> Capabilities:
|
|
141
|
+
# The agent sends through the runtime's AgentApi, not on_outbound.
|
|
142
|
+
return Capabilities(accepts=["text", "data"], originates=False)
|
|
143
|
+
|
|
144
|
+
def bind_store(self, store: QueueStore) -> None:
|
|
145
|
+
"""Called by the runtime at start with its store."""
|
|
146
|
+
self._store = store
|
|
147
|
+
self._closed = False
|
|
148
|
+
|
|
149
|
+
async def on_outbound(self, send: OutboundSend) -> None:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _queue(self) -> QueueStore:
|
|
153
|
+
if self._store is None:
|
|
154
|
+
msg = "the delivery queue has no store yet"
|
|
155
|
+
raise RuntimeError(msg)
|
|
156
|
+
return self._store
|
|
157
|
+
|
|
158
|
+
async def deliver(
|
|
159
|
+
self, group: GroupView, sender: Sender, to: list[str], inner: Delivery
|
|
160
|
+
) -> DeliveryResult:
|
|
161
|
+
if inner.kind not in CONTENT_KINDS:
|
|
162
|
+
return DeliveryResult.ACK
|
|
163
|
+
store = self._queue() # raises without a store: the runtime retries
|
|
164
|
+
payload = message_payload(group, inner)
|
|
165
|
+
key = delivery_key(payload)
|
|
166
|
+
if not store.handled_has(key) and store.adapter_event_add(
|
|
167
|
+
self.name, key, payload, QUEUE_KEEP, addressed=inner.addressed_to_me
|
|
168
|
+
):
|
|
169
|
+
self._changed.set()
|
|
170
|
+
return DeliveryResult.ACK
|
|
171
|
+
|
|
172
|
+
async def on_event(self, group: GroupView | None, event: Event) -> None:
|
|
173
|
+
"""Events are read from the agent's event log, not queued here."""
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
async def close(self) -> None:
|
|
177
|
+
"""Called by the runtime when it stops: waiting takes return."""
|
|
178
|
+
self._closed = True
|
|
179
|
+
self._changed.set()
|
|
180
|
+
|
|
181
|
+
def _now(self) -> float:
|
|
182
|
+
return self._clock.now().timestamp()
|
|
183
|
+
|
|
184
|
+
def _leased(self) -> list[int]:
|
|
185
|
+
"""The seqs under a lease that hasn't run out. A record outlives its
|
|
186
|
+
lease (it names the last holder, who may still ack) until another
|
|
187
|
+
take leases the message again; old ones are pruned."""
|
|
188
|
+
now = self._now()
|
|
189
|
+
self._holders = {d: h for d, h in self._holders.items() if h.ends > now - HOLDER_KEEP_SECS}
|
|
190
|
+
return [d for d, h in self._holders.items() if h.ends > now]
|
|
191
|
+
|
|
192
|
+
def _pick(
|
|
193
|
+
self, group: str | None, limit: int, addressed_only: bool
|
|
194
|
+
) -> tuple[list[tuple[int, dict[str, Any]]], bool] | None:
|
|
195
|
+
"""What a take returns now (the rows and whether more are queued),
|
|
196
|
+
or ``None`` when it has to wait.
|
|
197
|
+
|
|
198
|
+
Without ``addressed_only``: the oldest ``limit`` unleased rows.
|
|
199
|
+
With it, the oldest unleased row that addresses this agent is found
|
|
200
|
+
directly, however many others are queued before it: the batch is the
|
|
201
|
+
oldest ``limit`` rows if it is among them, else that row and the
|
|
202
|
+
``limit - 1`` rows right before it."""
|
|
203
|
+
store, skip = self._queue(), self._leased()
|
|
204
|
+
head = store.queue_rows(self.name, group_id=group, skip=skip, limit=limit + 1)
|
|
205
|
+
if not head:
|
|
206
|
+
return None
|
|
207
|
+
if not addressed_only:
|
|
208
|
+
return head[:limit], len(head) > limit
|
|
209
|
+
first = store.queue_rows(self.name, group_id=group, skip=skip, limit=1, addressed=True)
|
|
210
|
+
if not first:
|
|
211
|
+
return None
|
|
212
|
+
if any(seq == first[0][0] for seq, _ in head[:limit]):
|
|
213
|
+
return head[:limit], len(head) > limit
|
|
214
|
+
before = store.queue_rows(
|
|
215
|
+
self.name, group_id=group, skip=skip, limit=limit - 1, before=first[0][0]
|
|
216
|
+
)
|
|
217
|
+
return [*before, first[0]], True
|
|
218
|
+
|
|
219
|
+
async def take(
|
|
220
|
+
self,
|
|
221
|
+
group: str | None = None,
|
|
222
|
+
*,
|
|
223
|
+
addressed_only: bool = False,
|
|
224
|
+
wait_secs: float = 0.0,
|
|
225
|
+
limit: int = MAX_BATCH,
|
|
226
|
+
stop: asyncio.Event | None = None,
|
|
227
|
+
) -> Batch:
|
|
228
|
+
"""Wait (on the runtime's clock, at most ``wait_secs`` seconds, capped
|
|
229
|
+
at :data:`MAX_WAIT_SECS`) until a message of ``group`` (any group:
|
|
230
|
+
``None``) is queued, or with ``addressed_only`` one that addresses
|
|
231
|
+
this agent; then lease what :meth:`_pick` chose under a new lease
|
|
232
|
+
token and return it. Nothing is leased if the caller is cancelled,
|
|
233
|
+
the runtime stops or ``stop`` is set: the batch is then empty."""
|
|
234
|
+
limit = max(1, min(limit, MAX_BATCH))
|
|
235
|
+
deadline = self._now() + max(0.0, min(wait_secs, MAX_WAIT_SECS))
|
|
236
|
+
while True:
|
|
237
|
+
self._changed.clear() # before checking: no change is missed
|
|
238
|
+
if self._closed:
|
|
239
|
+
return Batch("", [], more=False)
|
|
240
|
+
picked = self._pick(group, limit, addressed_only)
|
|
241
|
+
if picked is not None:
|
|
242
|
+
rows, more = picked
|
|
243
|
+
holder = _Holder(secrets.token_urlsafe(16), self._now() + LEASE_SECS)
|
|
244
|
+
for seq, _ in rows:
|
|
245
|
+
self._holders[seq] = holder
|
|
246
|
+
return Batch(holder.token, [Taken(d, p) for d, p in rows], more=more)
|
|
247
|
+
now = self._now()
|
|
248
|
+
if now >= deadline or (stop is not None and stop.is_set()):
|
|
249
|
+
return Batch("", [], more=False)
|
|
250
|
+
ends = [h.ends for h in self._holders.values() if h.ends > now]
|
|
251
|
+
await self._nap(min([deadline, *ends]), stop)
|
|
252
|
+
|
|
253
|
+
async def _nap(self, until: float, stop: asyncio.Event | None) -> None:
|
|
254
|
+
"""Until something changes, ``until`` (Unix seconds on the runtime's
|
|
255
|
+
clock: the deadline or the end of a lease) or ``stop``."""
|
|
256
|
+
waits = [
|
|
257
|
+
asyncio.ensure_future(self._changed.wait()),
|
|
258
|
+
asyncio.ensure_future(self._clock.sleep_until(until, deadline=True)),
|
|
259
|
+
]
|
|
260
|
+
if stop is not None:
|
|
261
|
+
waits.append(asyncio.ensure_future(stop.wait()))
|
|
262
|
+
self.waiting += 1
|
|
263
|
+
try:
|
|
264
|
+
await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
|
|
265
|
+
finally:
|
|
266
|
+
self.waiting -= 1
|
|
267
|
+
for w in waits:
|
|
268
|
+
w.cancel()
|
|
269
|
+
|
|
270
|
+
def _held(self, lease: str, delivery_ids: Iterable[int]) -> list[int]:
|
|
271
|
+
return [
|
|
272
|
+
d
|
|
273
|
+
for d in delivery_ids
|
|
274
|
+
if lease and (h := self._holders.get(d)) is not None and h.token == lease
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
def ack(self, lease: str, delivery_ids: Iterable[int]) -> list[int]:
|
|
278
|
+
"""Take messages off the queue (a tool call returned them) and
|
|
279
|
+
remember their keys as handled. Only the ids ``lease`` holds, or
|
|
280
|
+
held last if nobody leased them since: an id never leased, or leased
|
|
281
|
+
again by another caller, is refused. The ids acknowledged."""
|
|
282
|
+
store = self._queue()
|
|
283
|
+
done = []
|
|
284
|
+
for delivery_id in self._held(lease, delivery_ids):
|
|
285
|
+
del self._holders[delivery_id]
|
|
286
|
+
if store.adapter_event_finish(self.name, delivery_id):
|
|
287
|
+
done.append(delivery_id)
|
|
288
|
+
return done
|
|
289
|
+
|
|
290
|
+
def release(self, lease: str, delivery_ids: Iterable[int]) -> None:
|
|
291
|
+
"""End ``lease``'s hold early: the messages are queued again for any
|
|
292
|
+
waiter. Ids it doesn't hold are left alone."""
|
|
293
|
+
for delivery_id in self._held(lease, delivery_ids):
|
|
294
|
+
del self._holders[delivery_id]
|
|
295
|
+
self._changed.set()
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Requests to a pinned directory (SPEC 13.2, docs/discovery.md).
|
|
2
|
+
|
|
3
|
+
A :class:`DirectoryClient` talks only to the ``url`` of one configured
|
|
4
|
+
``[[directory]]``, never to a URL a listing names. It follows no redirects,
|
|
5
|
+
sends no compressed bodies home, caps every answer while it streams, and
|
|
6
|
+
for an ``https`` directory dials only a public address its host resolved
|
|
7
|
+
to, once (the guard of :mod:`agentconnect.ownerfile`). A loopback
|
|
8
|
+
``http://`` directory (tests) is dialled directly.
|
|
9
|
+
|
|
10
|
+
Every answer is data from a party this daemon doesn't trust: the caller
|
|
11
|
+
verifies listings through the core and treats the directory's own fields
|
|
12
|
+
(such as ``owner_state``) as information only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import unicodedata
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import urlsplit
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
import agentconnect_core as ac
|
|
28
|
+
from agentconnect.config import DirectoryConfig
|
|
29
|
+
from agentconnect.ownerfile import (
|
|
30
|
+
REQUEST_HEADERS,
|
|
31
|
+
Resolver,
|
|
32
|
+
head_problem,
|
|
33
|
+
pinned_transport,
|
|
34
|
+
public_addresses,
|
|
35
|
+
read_capped,
|
|
36
|
+
system_resolver,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
REQUEST_TIMEOUT_SECS = 10.0
|
|
40
|
+
MAX_ANSWER_BYTES = 64 << 10 # publish and withdraw answers, and error bodies
|
|
41
|
+
SUCCESS = 200
|
|
42
|
+
MULTIPLE_CHOICES = 300
|
|
43
|
+
TOO_MANY_REQUESTS = 429
|
|
44
|
+
SERVER_ERROR = 500
|
|
45
|
+
NOT_FOUND = 404
|
|
46
|
+
GONE = 410
|
|
47
|
+
MAX_DETAIL_CHARS = 200
|
|
48
|
+
#: The codes a directory answers with (directory/README.md). Any other code,
|
|
49
|
+
#: or anything that isn't a short code, becomes ``directory``: what a
|
|
50
|
+
#: directory says is untrusted, and only known codes reach events, logs and
|
|
51
|
+
#: the terminal.
|
|
52
|
+
DIRECTORY_CODES = frozenset(
|
|
53
|
+
{
|
|
54
|
+
"bad_door",
|
|
55
|
+
"bad_listing",
|
|
56
|
+
"bad_request",
|
|
57
|
+
"bad_signature",
|
|
58
|
+
"bad_token",
|
|
59
|
+
"conflict",
|
|
60
|
+
"internal",
|
|
61
|
+
"name_held",
|
|
62
|
+
"not_found",
|
|
63
|
+
"not_vouched",
|
|
64
|
+
"owner_revoked",
|
|
65
|
+
"rate_limited",
|
|
66
|
+
"stale_seq",
|
|
67
|
+
"stale_ts",
|
|
68
|
+
"token_revoked",
|
|
69
|
+
"too_large",
|
|
70
|
+
"unavailable",
|
|
71
|
+
"unknown_key",
|
|
72
|
+
"withdrawn",
|
|
73
|
+
"wrong_directory",
|
|
74
|
+
"wrong_identity",
|
|
75
|
+
"wrong_relay",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
#: The owner states of SPEC 13.2.4; any other value a directory reports is
|
|
79
|
+
#: ``unknown``.
|
|
80
|
+
OWNER_STATES = frozenset(
|
|
81
|
+
{"verified", "unclaimed", "stale", "failed", "revoked", "checking", "unchecked"}
|
|
82
|
+
)
|
|
83
|
+
_CODE = re.compile(r"^[a-z_]{1,32}$")
|
|
84
|
+
# Answers that say "not now" rather than "no", and ours for no usable answer.
|
|
85
|
+
TRANSIENT_CODES = frozenset(
|
|
86
|
+
{"conflict", "rate_limited", "unreachable", "timeout", "blocked", "unreadable", "redirect"}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clean_detail(text: str) -> str:
|
|
91
|
+
"""A directory's ``detail`` made safe to show: no control or format
|
|
92
|
+
characters, at most 200 characters."""
|
|
93
|
+
out = "".join("?" if unicodedata.category(c)[0] == "C" else c for c in text)
|
|
94
|
+
return out[:MAX_DETAIL_CHARS]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def owner_state(answer: dict[str, Any]) -> str | None:
|
|
98
|
+
"""The owner state a publish answer reports (information only): a known
|
|
99
|
+
state, ``unknown`` for anything else, None when there is none."""
|
|
100
|
+
value = answer.get("owner_state")
|
|
101
|
+
if value is None:
|
|
102
|
+
return None
|
|
103
|
+
return value if isinstance(value, str) and value in OWNER_STATES else "unknown"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DirectoryError(Exception):
|
|
107
|
+
"""A refused or failed directory request: the directory's ``code`` (one of
|
|
108
|
+
:data:`DIRECTORY_CODES`, else ``directory``) or ours (``unreachable``,
|
|
109
|
+
``timeout``, ``blocked``, ``redirect``, ``unreadable``), its HTTP
|
|
110
|
+
``status`` (0 when there was no answer), and its cleaned ``detail`` and
|
|
111
|
+
``kind`` when it gave them."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, code: str, status: int = 0, detail: str = "", kind: str | None = None):
|
|
114
|
+
super().__init__(f"{code}: {detail}" if detail else code)
|
|
115
|
+
self.code, self.status, self.detail, self.kind = code, status, detail, kind
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def transient(self) -> bool:
|
|
119
|
+
"""Whether the same request may succeed later (5xx, 429, a conflict,
|
|
120
|
+
no answer, or one that couldn't be read)."""
|
|
121
|
+
return (
|
|
122
|
+
self.status == 0
|
|
123
|
+
or self.status >= SERVER_ERROR
|
|
124
|
+
or self.status == TOO_MANY_REQUESTS
|
|
125
|
+
or self.code in TRANSIENT_CODES
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def maybe_accepted(self) -> bool:
|
|
130
|
+
"""A 2xx whose body couldn't be read: the directory may have taken
|
|
131
|
+
the request, so the outcome is unknown, never a refusal."""
|
|
132
|
+
return self.code == "unreadable" and SUCCESS <= self.status < MULTIPLE_CHOICES
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class DirectoryClient:
|
|
136
|
+
"""One pinned directory. ``transport`` (tests) replaces the network,
|
|
137
|
+
resolution included; ``resolver`` and ``transport_for`` replace the
|
|
138
|
+
lookup and the pinned dial."""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
entry: DirectoryConfig,
|
|
143
|
+
*,
|
|
144
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
145
|
+
resolver: Resolver = system_resolver,
|
|
146
|
+
transport_for: Callable[[str], httpx.AsyncBaseTransport] = pinned_transport,
|
|
147
|
+
) -> None:
|
|
148
|
+
self.entry = entry
|
|
149
|
+
self._transport = transport
|
|
150
|
+
self._resolver = resolver
|
|
151
|
+
self._transport_for = transport_for
|
|
152
|
+
|
|
153
|
+
async def publish(self, listing: bytes) -> dict[str, Any]:
|
|
154
|
+
"""``POST /v0/listings``. Re-posting the same bytes is idempotent."""
|
|
155
|
+
return await self._write("/v0/listings", listing)
|
|
156
|
+
|
|
157
|
+
async def withdraw(self, withdrawal: bytes) -> dict[str, Any]:
|
|
158
|
+
"""``POST /v0/withdrawals``."""
|
|
159
|
+
return await self._write("/v0/withdrawals", withdrawal)
|
|
160
|
+
|
|
161
|
+
async def fetch_listing(self, name: str) -> bytes:
|
|
162
|
+
"""The exact signed bytes at ``/a/<name>.json``: ``not_found`` (404),
|
|
163
|
+
``listing_withdrawn`` (410), else what the directory said."""
|
|
164
|
+
if not ac.is_listing_name(name):
|
|
165
|
+
raise DirectoryError("bad_request", 0, "not a listing name")
|
|
166
|
+
status, headers, body = await self._send("GET", f"/a/{name}.json", None)
|
|
167
|
+
if status == NOT_FOUND:
|
|
168
|
+
raise DirectoryError("not_found", status)
|
|
169
|
+
if status == GONE:
|
|
170
|
+
raise DirectoryError("listing_withdrawn", status)
|
|
171
|
+
if status != httpx.codes.OK:
|
|
172
|
+
raise _refusal(status, body)
|
|
173
|
+
if headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json":
|
|
174
|
+
raise DirectoryError("unreadable", status, "not application/json")
|
|
175
|
+
return body
|
|
176
|
+
|
|
177
|
+
async def _write(self, path: str, body: bytes) -> dict[str, Any]:
|
|
178
|
+
status, _headers, answer = await self._send("POST", path, body)
|
|
179
|
+
if status not in (httpx.codes.OK, httpx.codes.CREATED):
|
|
180
|
+
raise _refusal(status, answer)
|
|
181
|
+
data = _json_object(answer)
|
|
182
|
+
if data is None:
|
|
183
|
+
raise DirectoryError("unreadable", status, "not a JSON object")
|
|
184
|
+
return data
|
|
185
|
+
|
|
186
|
+
async def _send(
|
|
187
|
+
self, method: str, path: str, body: bytes | None
|
|
188
|
+
) -> tuple[int, httpx.Headers, bytes]:
|
|
189
|
+
cap = ac.MAX_LISTING_BYTES if method == "GET" else MAX_ANSWER_BYTES
|
|
190
|
+
try:
|
|
191
|
+
async with asyncio.timeout(REQUEST_TIMEOUT_SECS):
|
|
192
|
+
transport = await self._transport_for_request()
|
|
193
|
+
headers = dict(REQUEST_HEADERS)
|
|
194
|
+
if body is not None:
|
|
195
|
+
headers["content-type"] = "application/json"
|
|
196
|
+
async with (
|
|
197
|
+
httpx.AsyncClient(
|
|
198
|
+
transport=transport,
|
|
199
|
+
follow_redirects=False,
|
|
200
|
+
trust_env=False,
|
|
201
|
+
timeout=REQUEST_TIMEOUT_SECS,
|
|
202
|
+
) as http,
|
|
203
|
+
http.stream(
|
|
204
|
+
method, self.entry.url + path, content=body, headers=headers
|
|
205
|
+
) as resp,
|
|
206
|
+
):
|
|
207
|
+
problem = head_problem(resp, cap)
|
|
208
|
+
if problem == "redirect":
|
|
209
|
+
raise DirectoryError("redirect", resp.status_code)
|
|
210
|
+
if problem in ("encoding", "too_large"):
|
|
211
|
+
raise DirectoryError("unreadable", resp.status_code, problem)
|
|
212
|
+
data = await read_capped(resp, cap)
|
|
213
|
+
if data is None:
|
|
214
|
+
raise DirectoryError("unreadable", resp.status_code, "too_large")
|
|
215
|
+
return resp.status_code, resp.headers, data
|
|
216
|
+
except TimeoutError:
|
|
217
|
+
raise DirectoryError("timeout") from None
|
|
218
|
+
except (httpx.HTTPError, OSError) as e:
|
|
219
|
+
raise DirectoryError("unreachable", 0, type(e).__name__) from None
|
|
220
|
+
|
|
221
|
+
async def _transport_for_request(self) -> httpx.AsyncBaseTransport:
|
|
222
|
+
if self._transport is not None:
|
|
223
|
+
return self._transport
|
|
224
|
+
parts = urlsplit(self.entry.url)
|
|
225
|
+
host = parts.hostname or ""
|
|
226
|
+
if parts.scheme == "http": # a loopback directory (config.py allows no other)
|
|
227
|
+
return httpx.AsyncHTTPTransport(trust_env=False)
|
|
228
|
+
addresses = await public_addresses(host, parts.port or 443, self._resolver)
|
|
229
|
+
if not addresses:
|
|
230
|
+
raise DirectoryError("blocked", 0, "the directory host has no public address")
|
|
231
|
+
return self._transport_for(addresses[0])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _json_object(body: bytes) -> dict[str, Any] | None:
|
|
235
|
+
try:
|
|
236
|
+
data = json.loads(body)
|
|
237
|
+
except ValueError:
|
|
238
|
+
return None
|
|
239
|
+
return data if isinstance(data, dict) else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _refusal(status: int, body: bytes) -> DirectoryError:
|
|
243
|
+
"""The directory's ``{"code", "detail", "kind"?}`` as an error. A body
|
|
244
|
+
without a code is ``unreadable`` (retried); a code this daemon doesn't
|
|
245
|
+
know is ``directory``."""
|
|
246
|
+
data = _json_object(body)
|
|
247
|
+
if data is None or not isinstance(data.get("code"), str):
|
|
248
|
+
return DirectoryError("unreadable", status, "no error code")
|
|
249
|
+
code, detail, kind = data["code"], data.get("detail"), data.get("kind")
|
|
250
|
+
return DirectoryError(
|
|
251
|
+
code if _CODE.match(code) and code in DIRECTORY_CODES else "directory",
|
|
252
|
+
status,
|
|
253
|
+
clean_detail(detail) if isinstance(detail, str) else "",
|
|
254
|
+
kind if isinstance(kind, str) and _CODE.match(kind) else None,
|
|
255
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Test-only fault injection at the daemon's step boundaries (ADR-004).
|
|
2
|
+
|
|
3
|
+
Each :class:`~agentconnect.runtime.engine.Engine` has a :class:`Failpoints` set. It
|
|
4
|
+
is empty unless a test arms a point, and an empty set is one falsy check per
|
|
5
|
+
point, so the points are inert in production. An armed point either
|
|
6
|
+
|
|
7
|
+
* **raises** :class:`FailpointError`, a transient failure the daemon
|
|
8
|
+
retries (the frame is neither recorded nor acked), or
|
|
9
|
+
* **crashes**: raises :class:`SimulatedCrash`, a ``BaseException`` that no
|
|
10
|
+
handler in the daemon catches, so the worker dies on the spot as if the
|
|
11
|
+
process had been killed there; the test then restarts the daemon.
|
|
12
|
+
|
|
13
|
+
The core has its own points (``Client._arm_failpoint_for_testing``). The
|
|
14
|
+
names are listed in :data:`POINTS` and in TESTING.md.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
Action = Literal["raise", "crash"]
|
|
22
|
+
|
|
23
|
+
#: Every daemon failpoint. ``<path>.before_core`` and ``<path>.after_core``
|
|
24
|
+
#: surround the core call; ``in_tx`` is inside the daemon transaction that
|
|
25
|
+
#: records the result, ``after_tx`` right after it commits, and
|
|
26
|
+
#: ``after_consume`` after the core journal entry is consumed (before the
|
|
27
|
+
#: relay ack). ``event.after_record`` and ``event.after_notify`` surround
|
|
28
|
+
#: telling the adapter about a recorded event; ``delivery.after_adapter`` is
|
|
29
|
+
#: after the adapter accepted a message, before the inbox records that.
|
|
30
|
+
#: Publishing a listing (docs/discovery.md): ``door.after_register`` (the
|
|
31
|
+
#: relay has the door, the daemon doesn't record it yet), ``listing.after_sign``
|
|
32
|
+
#: (signed and stored, not posted) and ``listing.after_post`` (the directory
|
|
33
|
+
#: accepted it, not recorded yet); ``withdraw.after_close`` is after closing
|
|
34
|
+
#: the doors, before the withdrawal is sent.
|
|
35
|
+
#: Knocks: ``knock.in_tx`` and ``knock.after_tx`` around the owner's record
|
|
36
|
+
#: of a received knock (ledger, approval, inbox); ``knock_welcome.*`` are
|
|
37
|
+
#: the knocker's join steps (as ``welcome.*``); ``knock_send.after_core``
|
|
38
|
+
#: is after the core built the knock, before it is recorded and queued;
|
|
39
|
+
#: ``knock_approve.after_group`` after the core created the approval's
|
|
40
|
+
#: group, before it is recorded; ``knock_add.after_welcome`` after the
|
|
41
|
+
#: approved knock's Welcome went out, before the approval is recorded done.
|
|
42
|
+
#: Joins: ``join_request.in_tx`` and ``join_request.after_tx`` around the
|
|
43
|
+
#: inviter's record of a verified JoinRequest (replay ledger, approval, inbox).
|
|
44
|
+
#: Leaves: ``leave_timeout.in_tx`` and ``leave_timeout.after_tx`` around a
|
|
45
|
+
#: timed-out leave's deletion of the group and its ``left`` event.
|
|
46
|
+
#: A message that fails alone: ``poison.in_tx`` and ``poison.after_tx``
|
|
47
|
+
#: around recording it as failed (inbox, audit row, ``message_failed``).
|
|
48
|
+
PATHS = ("receive", "confirm", "welcome", "knock_welcome")
|
|
49
|
+
STEPS = ("before_core", "after_core", "in_tx", "after_tx", "after_consume")
|
|
50
|
+
POINTS = frozenset(
|
|
51
|
+
{f"{path}.{step}" for path in PATHS for step in STEPS}
|
|
52
|
+
| {"invite.after_register", "event.after_record", "event.after_notify"}
|
|
53
|
+
| {"held_timer.expire", "delivery.after_adapter"}
|
|
54
|
+
| {"door.after_register", "listing.after_sign", "listing.after_post"}
|
|
55
|
+
| {"withdraw.after_close"}
|
|
56
|
+
| {"knock.in_tx", "knock.after_tx", "knock_send.after_core"}
|
|
57
|
+
| {"knock_approve.after_group", "knock_add.after_welcome"}
|
|
58
|
+
| {"join_request.in_tx", "join_request.after_tx"}
|
|
59
|
+
| {"leave_timeout.in_tx", "leave_timeout.after_tx"}
|
|
60
|
+
| {"poison.in_tx", "poison.after_tx"}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FailpointError(Exception):
|
|
65
|
+
"""An injected transient failure."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SimulatedCrash(BaseException):
|
|
69
|
+
"""An injected crash: nothing in the daemon catches it."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Failpoints:
|
|
73
|
+
"""A daemon's armed failpoints."""
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._armed: dict[str, tuple[Action, int]] = {}
|
|
77
|
+
self.fired: list[str] = []
|
|
78
|
+
|
|
79
|
+
def arm(self, name: str, action: Action = "raise", times: int = 1) -> None:
|
|
80
|
+
if name not in POINTS:
|
|
81
|
+
msg = f"unknown failpoint {name}"
|
|
82
|
+
raise ValueError(msg)
|
|
83
|
+
self._armed[name] = (action, times)
|
|
84
|
+
|
|
85
|
+
def clear(self) -> None:
|
|
86
|
+
self._armed.clear()
|
|
87
|
+
|
|
88
|
+
def hit(self, name: str) -> None:
|
|
89
|
+
if not self._armed or name not in self._armed:
|
|
90
|
+
return
|
|
91
|
+
action, left = self._armed[name]
|
|
92
|
+
if left <= 1:
|
|
93
|
+
del self._armed[name]
|
|
94
|
+
else:
|
|
95
|
+
self._armed[name] = (action, left - 1)
|
|
96
|
+
self.fired.append(name)
|
|
97
|
+
if action == "crash":
|
|
98
|
+
raise SimulatedCrash(name)
|
|
99
|
+
raise FailpointError(name)
|