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,238 @@
1
+ """Outbound fetches to hosts that untrusted text names (SPEC 13.2.4, docs/discovery.md).
2
+
3
+ An owner domain comes from listing text, so fetching its
4
+ ``/.well-known/agentconnect.json`` must not become a way to reach this
5
+ machine's own network. :class:`OwnerFileFetcher` follows the rules the
6
+ directory follows (``directory/src/owner.rs``):
7
+
8
+ * the URL is ``agentconnect_core.owner_file_url(domain)``: HTTPS on port 443;
9
+ * the absolute name (``domain.``, so no resolver search domain applies) is
10
+ resolved once, and only addresses ``agentconnect_core.is_public_address``
11
+ accepts are kept: loopback, private, link-local, ULA and the other
12
+ special-use ranges are never dialled;
13
+ * the connection goes to that vetted address and nowhere else (no second
14
+ lookup, so no DNS rebinding), with TLS SNI and certificate checks on the
15
+ host name against the system roots;
16
+ * no proxies, no redirects (any 3xx fails), status 200 and
17
+ ``Content-Type: application/json`` only, no content encoding;
18
+ * the body is counted while it streams and capped at
19
+ ``MAX_OWNER_FILE_BYTES``, and the whole exchange gets 5 seconds.
20
+
21
+ :func:`public_addresses` and :func:`pinned_transport` are the same guard for
22
+ the directory client's HTTPS requests.
23
+
24
+ Nothing here logs the domain or an address.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import ipaddress
31
+ import socket
32
+ import ssl
33
+ from collections.abc import Awaitable, Callable, Iterable
34
+ from dataclasses import dataclass
35
+ from typing import Protocol, cast, override
36
+
37
+ import httpcore
38
+ import httpx
39
+
40
+ import agentconnect_core as ac
41
+
42
+ FETCH_TIMEOUT_SECS = 5.0
43
+ HTTPS_PORT = 443
44
+ HTTP_OK = 200
45
+ MULTIPLE_CHOICES = 300
46
+ BAD_REQUEST = 400
47
+ JSON_TYPE = "application/json"
48
+
49
+ #: ``(host, port)`` to the addresses it resolves to, as strings.
50
+ Resolver = Callable[[str, int], Awaitable[list[str]]]
51
+
52
+
53
+ async def system_resolver(host: str, port: int) -> list[str]:
54
+ infos = await asyncio.get_running_loop().getaddrinfo(host, port, type=socket.SOCK_STREAM)
55
+ return [str(info[4][0]) for info in infos]
56
+
57
+
58
+ def _is_ip(host: str) -> bool:
59
+ try:
60
+ ipaddress.ip_address(host)
61
+ except ValueError:
62
+ return False
63
+ return True
64
+
65
+
66
+ def _public(address: str) -> bool:
67
+ try:
68
+ return ac.is_public_address(address)
69
+ except ac.AgentConnectError: # e.g. an IPv6 address with a zone
70
+ return False
71
+
72
+
73
+ async def public_addresses(host: str, port: int, resolver: Resolver) -> list[str]:
74
+ """The public addresses ``host`` resolves to, resolving it once as an
75
+ absolute name. An IP literal is checked as is."""
76
+ if _is_ip(host):
77
+ candidates: Iterable[str] = [host]
78
+ else:
79
+ candidates = await resolver(host.rstrip(".") + ".", port)
80
+ return list(dict.fromkeys(a for a in candidates if _public(a)))
81
+
82
+
83
+ class PinnedBackend(httpcore.AsyncNetworkBackend):
84
+ """Dials one vetted address whatever host the request names; TLS still
85
+ checks the certificate against the request's host name."""
86
+
87
+ def __init__(self, address: str, inner: httpcore.AsyncNetworkBackend | None = None) -> None:
88
+ self.address = address
89
+ self._inner = inner or cast("httpcore.AsyncNetworkBackend", httpcore.AnyIOBackend())
90
+
91
+ # The signatures are httpcore's, `timeout` included.
92
+ @override
93
+ async def connect_tcp(
94
+ self,
95
+ host: str, # the vetted address is dialled instead
96
+ port: int,
97
+ timeout: float | None = None,
98
+ local_address: str | None = None,
99
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
100
+ ) -> httpcore.AsyncNetworkStream:
101
+ return await self._inner.connect_tcp(
102
+ self.address,
103
+ port,
104
+ timeout=timeout,
105
+ local_address=local_address,
106
+ socket_options=socket_options,
107
+ )
108
+
109
+ @override
110
+ async def connect_unix_socket(
111
+ self,
112
+ path: str,
113
+ timeout: float | None = None,
114
+ socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
115
+ ) -> httpcore.AsyncNetworkStream:
116
+ msg = "only the vetted address is dialled"
117
+ raise httpcore.ConnectError(msg)
118
+
119
+ @override
120
+ async def sleep(self, seconds: float) -> None:
121
+ await self._inner.sleep(seconds)
122
+
123
+
124
+ def pinned_transport(
125
+ address: str, inner: httpcore.AsyncNetworkBackend | None = None
126
+ ) -> httpx.AsyncBaseTransport:
127
+ """An HTTPS transport that only ever connects to ``address``: system
128
+ roots and host name checks, HTTP/1.1, no proxy."""
129
+ context = ssl.create_default_context()
130
+ transport = httpx.AsyncHTTPTransport(verify=context, trust_env=False)
131
+ # httpx takes no network backend, so its pool is replaced by one that
132
+ # dials the vetted address (tests check the wiring).
133
+ transport._pool = httpcore.AsyncConnectionPool(
134
+ ssl_context=context,
135
+ network_backend=PinnedBackend(address, inner),
136
+ max_connections=1,
137
+ http1=True,
138
+ http2=False,
139
+ )
140
+ return transport
141
+
142
+
143
+ def head_problem(resp: httpx.Response, cap: int) -> str | None:
144
+ """Why a response's status and headers are refused, or None."""
145
+ if MULTIPLE_CHOICES <= resp.status_code < BAD_REQUEST:
146
+ return "redirect"
147
+ if resp.status_code != HTTP_OK:
148
+ return "status"
149
+ media = resp.headers.get("content-type", "").split(";", 1)[0].strip().lower()
150
+ if media != JSON_TYPE:
151
+ return "content_type"
152
+ if resp.headers.get("content-encoding", "identity").strip().lower() != "identity":
153
+ return "encoding"
154
+ length = resp.headers.get("content-length")
155
+ if length is not None and (not length.isdigit() or int(length) > cap):
156
+ return "too_large"
157
+ return None
158
+
159
+
160
+ async def read_capped(resp: httpx.Response, cap: int) -> bytes | None:
161
+ """The raw body, or None once it passes ``cap`` bytes (it stops reading)."""
162
+ if resp.is_stream_consumed: # a transport that read the body already
163
+ return resp.content if len(resp.content) <= cap else None
164
+ body = bytearray()
165
+ async for chunk in resp.aiter_raw():
166
+ body += chunk
167
+ if len(body) > cap:
168
+ return None
169
+ return bytes(body)
170
+
171
+
172
+ #: What every outbound request sends: no compressed bodies to inflate.
173
+ REQUEST_HEADERS = {"accept": JSON_TYPE, "accept-encoding": "identity"}
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class OwnerFetch:
178
+ """The file's bytes, or why there are none (``blocked``, ``unreachable``,
179
+ ``timeout``, ``redirect``, ``status``, ``content_type``, ``encoding``,
180
+ ``too_large``, ``bad_domain``). Any failure counts as a failed fetch
181
+ (SPEC 13.2.4)."""
182
+
183
+ data: bytes | None
184
+ failure: str | None = None
185
+
186
+
187
+ class Fetcher(Protocol):
188
+ async def fetch(self, domain: str) -> OwnerFetch: ...
189
+
190
+
191
+ class OwnerFileFetcher:
192
+ """Fetches ``https://<domain>/.well-known/agentconnect.json`` under the
193
+ rules above. ``resolver`` and ``transport_for`` are test seams."""
194
+
195
+ def __init__(
196
+ self,
197
+ resolver: Resolver = system_resolver,
198
+ transport_for: Callable[[str], httpx.AsyncBaseTransport] = pinned_transport,
199
+ timeout: float = FETCH_TIMEOUT_SECS,
200
+ ) -> None:
201
+ self._resolver = resolver
202
+ self._transport_for = transport_for
203
+ self._timeout = timeout
204
+
205
+ async def fetch(self, domain: str) -> OwnerFetch:
206
+ try:
207
+ url = ac.owner_file_url(domain)
208
+ except ac.AgentConnectError:
209
+ return OwnerFetch(None, "bad_domain")
210
+ try:
211
+ async with asyncio.timeout(self._timeout):
212
+ return await self._fetch(domain, url)
213
+ except TimeoutError:
214
+ return OwnerFetch(None, "timeout")
215
+ except (httpx.HTTPError, OSError):
216
+ return OwnerFetch(None, "unreachable")
217
+
218
+ async def _fetch(self, domain: str, url: str) -> OwnerFetch:
219
+ addresses = await public_addresses(domain, HTTPS_PORT, self._resolver)
220
+ if not addresses:
221
+ return OwnerFetch(None, "blocked")
222
+ cap = ac.MAX_OWNER_FILE_BYTES
223
+ async with (
224
+ httpx.AsyncClient(
225
+ transport=self._transport_for(addresses[0]),
226
+ follow_redirects=False,
227
+ trust_env=False,
228
+ timeout=self._timeout,
229
+ ) as http,
230
+ http.stream("GET", url, headers=REQUEST_HEADERS) as resp,
231
+ ):
232
+ problem = head_problem(resp, cap)
233
+ if problem is not None:
234
+ return OwnerFetch(None, problem)
235
+ data = await read_capped(resp, cap)
236
+ if data is None:
237
+ return OwnerFetch(None, "too_large")
238
+ return OwnerFetch(data)
@@ -0,0 +1,109 @@
1
+ """The JSON shape the webhook and SSE adapters hand to headless agents.
2
+
3
+ One shape for both transports, so the SDK parses one thing. A message::
4
+
5
+ {"event": "message" | "task_status" | "task_artifact" | "task_cancel" |
6
+ "agent_card" | "control.<name>",
7
+ "msg_id": ..., "group": {id, name, purpose, epoch, floor, floor_holder},
8
+ "sender": {identity, instance_id, display_name, verified},
9
+ "to": [...], "addressed_to_me": bool, "scope": <group purpose>,
10
+ "untrusted": true, "trust_state": {...},
11
+ "origin": "agent" | "human", "hop": n, "reply_to": [...],
12
+ "addressee": [...], # when the sender named it (SPEC 9.3)
13
+ "frame_text": bool, "notice": <the SPEC 10.1 prefix>,
14
+ "a2a": <the inner body>}
15
+
16
+ ``frame_text`` is the group's policy (on by default): a consumer that hands
17
+ the text to a model shows ``notice`` in front of it. The SDK's ``msg.text``
18
+ does that.
19
+
20
+ A daemon event (``member_joined``, ``epoch_changed``, ``loop_guard``, ...)::
21
+
22
+ {"event": <type>, "event_id": ..., "group": {...} | null,
23
+ "untrusted": true, "data": {...}}
24
+
25
+ ``msg_id`` and ``event_id`` are idempotency keys: the transports deliver at
26
+ least once, so a receiver must treat a repeated id as already handled. Event
27
+ data can carry peer-chosen text (display names), so it is marked untrusted
28
+ like message bodies (SPEC section 10.1).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from typing import Any
35
+
36
+ from agentconnect.adapter import Delivery, Event, GroupView
37
+
38
+ MESSAGE_EVENTS = {
39
+ "a2a.message": "message",
40
+ "a2a.task_status": "task_status",
41
+ "a2a.task_artifact": "task_artifact",
42
+ "a2a.task_cancel": "task_cancel",
43
+ "a2a.agent_card": "agent_card",
44
+ }
45
+
46
+
47
+ def group_json(group: GroupView | None) -> dict[str, Any] | None:
48
+ if group is None:
49
+ return None
50
+ return {
51
+ "id": group.group_id,
52
+ "name": group.name,
53
+ "purpose": group.purpose,
54
+ "epoch": group.epoch,
55
+ "floor": group.floor,
56
+ "floor_holder": group.floor_holder,
57
+ }
58
+
59
+
60
+ def event_name(kind: str) -> str:
61
+ """The webhook ``event`` for a message kind (control kinds keep theirs)."""
62
+ return MESSAGE_EVENTS.get(kind, kind)
63
+
64
+
65
+ def message_payload(group: GroupView, delivery: Delivery) -> dict[str, Any]:
66
+ sender = delivery.sender
67
+ out: dict[str, Any] = {
68
+ "event": event_name(delivery.kind),
69
+ "msg_id": delivery.msg_id,
70
+ "group": group_json(group),
71
+ "sender": {
72
+ "identity": sender.identity,
73
+ "instance_id": sender.instance_id,
74
+ "display_name": sender.display_name,
75
+ "verified": sender.verified,
76
+ },
77
+ "to": list(delivery.to),
78
+ "addressed_to_me": delivery.addressed_to_me,
79
+ "scope": delivery.scope,
80
+ "untrusted": True,
81
+ "trust_state": delivery.trust_state,
82
+ "origin": delivery.origin,
83
+ "hop": delivery.hop,
84
+ "frame_text": delivery.frame_text,
85
+ "notice": delivery.framed_text(),
86
+ "a2a": delivery.body,
87
+ }
88
+ refs = delivery.inner.get("reply_to")
89
+ if isinstance(refs, list):
90
+ out["reply_to"] = [str(r) for r in refs]
91
+ addressee = delivery.inner.get("addressee")
92
+ if isinstance(addressee, list):
93
+ out["addressee"] = [str(a) for a in addressee]
94
+ return out
95
+
96
+
97
+ def event_payload(group: GroupView | None, event: Event) -> dict[str, Any]:
98
+ return {
99
+ "event": event.type,
100
+ "event_id": event.event_id,
101
+ "group": group_json(group),
102
+ "untrusted": True,
103
+ "data": event.data,
104
+ }
105
+
106
+
107
+ def encode(payload: dict[str, Any]) -> bytes:
108
+ """The exact bytes that are signed and sent."""
109
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()