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.
Files changed (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. 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)