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,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)
|
agentconnect/payloads.py
ADDED
|
@@ -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()
|