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,239 @@
1
+ """Provisioning a replica: the identity key from one data directory into a
2
+ fresh one, with a fresh instance (SPEC 9.4, ADR-005 Decision 5).
3
+
4
+ ``agentconnect identity export`` and ``identity import`` are the only way
5
+ in. Both are human-only and CLI-only: no local API route, SDK call or MCP
6
+ tool reaches this module, because whoever holds the identity key can add
7
+ instances that every member accepts (SPEC 12).
8
+
9
+ - **Export** wraps ``IK``'s secret under a fresh random 32-byte key in the
10
+ core (ChaCha20-Poly1305, AAD ``agentconnect/v0/identity-export``) and
11
+ writes the file 0600, never over an existing one. The key is the export
12
+ code: the CLI shows it once, and the human carries it apart from the file.
13
+ - **Import** opens the file with the code into a data directory that holds
14
+ no identity, instance, database key or MLS store, and creates a fresh
15
+ instance there: its own signing key and ``instance_id``, never the
16
+ exporting instance's. A copied data directory has those, so it is refused:
17
+ a copy is not a replica (two runtimes on one leaf break the group).
18
+
19
+ Both take the data directory's lock first, so they refuse to run while a
20
+ runtime (a daemon, an embedded agent, an MCP server) holds it, and nothing
21
+ starts on the directory meanwhile. An import cut between its two keystore
22
+ writes (the identity, then the instance) leaves a directory that holds that
23
+ identity and nothing else; importing the same export again finishes it.
24
+ File errors are :class:`ProvisionError` too, naming the path, so the CLI
25
+ reports them like any other refusal.
26
+
27
+ Nothing here logs. The secret is zeroized inside the core; in Python it
28
+ passes once as ``bytes`` into the keystore, and the code is a ``str``,
29
+ neither of which can be zeroized (the residual exposure noted in
30
+ :mod:`agentconnect.identity`).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import os
37
+ from collections.abc import Generator
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ import agentconnect_core as ac
42
+ from agentconnect.config import Config
43
+ from agentconnect.keystore import DataDirLock, DataDirLockedError, Keystore, open_keystore
44
+
45
+ # What makes a data directory an instance already: its keys and databases.
46
+ STATE_KEYS = ("identity", "instance", "db_key")
47
+ STATE_FILES = ("mls.sqlite3", "daemon.sqlite3")
48
+ ISOLATION_HINT = (
49
+ "in separate-OS-user mode, run `identity export` and `identity import` as the "
50
+ "daemon's OS user (docs/isolation.md)"
51
+ )
52
+
53
+ EXPORT_WARNING = (
54
+ "Whoever has both this file and the code holds the identity: every host you "
55
+ "import it on can speak as this agent in every group. Carry the code apart "
56
+ "from the file (not in the same message or folder), import it once, then "
57
+ "delete the file."
58
+ )
59
+
60
+
61
+ class ProvisionError(Exception):
62
+ """A refused export or import: a machine-readable code and a message."""
63
+
64
+ def __init__(self, code: str, message: str) -> None:
65
+ super().__init__(message)
66
+ self.code, self.message = code, message
67
+
68
+
69
+ @contextlib.contextmanager
70
+ def _os_errors(code: str, what: str) -> Generator[None]:
71
+ """An ``OSError`` inside as ``ProvisionError(code)``, naming ``what``."""
72
+ try:
73
+ yield
74
+ except OSError as e:
75
+ msg = f"{what}: {e.strerror or e}"
76
+ if isinstance(e, PermissionError):
77
+ msg = f"{msg}; {ISOLATION_HINT}"
78
+ raise ProvisionError(code, msg) from None
79
+
80
+
81
+ @contextlib.contextmanager
82
+ def _locked(data_dir: Path) -> Generator[None]:
83
+ try:
84
+ with _os_errors("data_dir_access", f"can't open the data directory {data_dir}"):
85
+ lock = DataDirLock(data_dir, note=False)
86
+ except DataDirLockedError as e:
87
+ msg = f"{e}; provisioning works only on a stopped runtime's data directory"
88
+ raise ProvisionError("runtime_running", msg) from None
89
+ try:
90
+ yield
91
+ finally:
92
+ lock.release()
93
+
94
+
95
+ def _write_new(path: Path, data: bytes) -> None:
96
+ """Write ``data`` to a new file created 0600; never replace or follow a
97
+ link to an existing one."""
98
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
99
+ try:
100
+ fd = os.open(path, flags, 0o600)
101
+ except FileExistsError:
102
+ msg = f"{path} exists: an export never replaces a file, choose another path"
103
+ raise ProvisionError("exists", msg) from None
104
+ except OSError as e:
105
+ raise ProvisionError("cannot_write", f"can't write {path}: {e.strerror}") from None
106
+ try:
107
+ try:
108
+ os.fchmod(fd, 0o600)
109
+ view = memoryview(data)
110
+ while view:
111
+ view = view[os.write(fd, view) :]
112
+ os.fsync(fd)
113
+ finally:
114
+ os.close(fd)
115
+ except BaseException as e:
116
+ path.unlink(missing_ok=True)
117
+ if isinstance(e, OSError):
118
+ raise ProvisionError("cannot_write", f"can't write {path}: {e.strerror}") from None
119
+ raise
120
+
121
+
122
+ def export_identity(config: Config, out: Path, keystore: Keystore | None = None) -> dict[str, Any]:
123
+ """Wrap this data directory's identity into ``out``: the file's path,
124
+ the identity, its fingerprint and the code (to show once)."""
125
+ with _locked(config.data_dir):
126
+ with _os_errors("data_dir_access", f"can't read the keys in {config.data_dir}"):
127
+ store = keystore or open_keystore(config.keystore, config.data_dir)
128
+ secret = store.get("identity")
129
+ if secret is None:
130
+ msg = f"{config.data_dir} has no identity to export"
131
+ raise ProvisionError("no_identity", msg)
132
+ identity = ac.Identity.from_secret_bytes(secret)
133
+ file, code = ac.wrap_identity_export(identity)
134
+ _write_new(out, file)
135
+ return {
136
+ "file": str(out),
137
+ "identity": identity.id,
138
+ "fingerprint": identity.fingerprint,
139
+ "code": code,
140
+ "warning": EXPORT_WARNING,
141
+ }
142
+
143
+
144
+ def read_export(path: Path) -> bytes:
145
+ """An export file's bytes (at most the core's limit)."""
146
+ try:
147
+ with path.open("rb") as f:
148
+ data = f.read(ac.MAX_IDENTITY_EXPORT_LEN + 1)
149
+ except OSError as e:
150
+ msg = f"can't read {path}: {e.strerror}"
151
+ raise ProvisionError("bad_export", msg) from None
152
+ if len(data) > ac.MAX_IDENTITY_EXPORT_LEN:
153
+ raise ProvisionError("bad_export", f"{path} is not an identity export (too large)")
154
+ return data
155
+
156
+
157
+ def export_identity_id(data: bytes) -> str:
158
+ """The identity an export file names, read without its code."""
159
+ try:
160
+ return ac.identity_export_identity(data)
161
+ except ac.AgentConnectError as e:
162
+ raise _open_error(e) from None
163
+
164
+
165
+ def _open_error(e: ac.AgentConnectError) -> ProvisionError:
166
+ kind = getattr(e, "kind", None)
167
+ if kind == "authentication":
168
+ msg = "the code does not open this file: check the code, or export again"
169
+ return ProvisionError("bad_code", msg)
170
+ if kind == "unsupported_version":
171
+ msg = (
172
+ "the file is from another identity export format version: export it "
173
+ "again with this version of agentconnect"
174
+ )
175
+ return ProvisionError("unsupported_version", msg)
176
+ if "export code" in str(e):
177
+ msg = "the code is not an export code: give the 43 characters export printed"
178
+ return ProvisionError("bad_code", msg)
179
+ msg = "the file is not an identity export, or it is damaged (cut short?)"
180
+ return ProvisionError("bad_export", msg)
181
+
182
+
183
+ def _not_empty(config: Config, found: list[str]) -> ProvisionError:
184
+ msg = (
185
+ f"{config.data_dir} already holds an instance ({', '.join(found)}): import "
186
+ "only into a new, empty data directory (a copied one is not a replica)"
187
+ )
188
+ return ProvisionError("not_empty", msg)
189
+
190
+
191
+ def _check_fresh(config: Config, store: Keystore) -> bytes | None:
192
+ """Refuse a data directory that is an instance already, or a copy of one.
193
+ One that holds an identity and nothing else is what an import cut
194
+ between its writes leaves: its secret is returned, for the caller to
195
+ resume only an import of that same identity."""
196
+ found = [name for name in STATE_KEYS if store.get(name) is not None]
197
+ found += [name for name in STATE_FILES if (config.data_dir / name).exists()]
198
+ keys = config.data_dir / "keys"
199
+ # A dot file is a temporary of a write that was cut (keystore.write_private).
200
+ other_keys = keys.is_dir() and any(
201
+ p.name != "identity" and not p.name.startswith(".") for p in keys.iterdir()
202
+ )
203
+ if other_keys and found in ([], ["identity"]):
204
+ found.append("keys/")
205
+ if found == ["identity"]:
206
+ return store.get("identity")
207
+ if found:
208
+ raise _not_empty(config, found)
209
+ return None
210
+
211
+
212
+ def import_identity(
213
+ config: Config, data: bytes, code: str, keystore: Keystore | None = None
214
+ ) -> dict[str, Any]:
215
+ """Open an export into this fresh data directory and create a new
216
+ instance for it: the identity, its fingerprint and the new
217
+ ``instance_id``. A directory that holds the same identity and nothing
218
+ else (an import cut before its second write) is finished."""
219
+ with _locked(config.data_dir):
220
+ with _os_errors("data_dir_access", f"can't read the keys in {config.data_dir}"):
221
+ store = keystore or open_keystore(config.keystore, config.data_dir)
222
+ resumed = _check_fresh(config, store)
223
+ try:
224
+ identity = ac.unwrap_identity_export(data, code)
225
+ except ac.AgentConnectError as e:
226
+ raise _open_error(e) from None
227
+ if resumed is not None and resumed != identity.secret_bytes():
228
+ raise _not_empty(config, ["identity"])
229
+ instance = identity.create_instance()
230
+ with _os_errors("cannot_write", f"can't write the keys in {config.data_dir}"):
231
+ if resumed is None:
232
+ store.put("identity", identity.secret_bytes())
233
+ store.put("instance", instance.to_secret_bytes())
234
+ return {
235
+ "identity": identity.id,
236
+ "fingerprint": identity.fingerprint,
237
+ "instance_id": instance.instance_id_hex,
238
+ "data_dir": str(config.data_dir),
239
+ }
agentconnect/py.typed ADDED
File without changes
agentconnect/relay.py ADDED
@@ -0,0 +1,405 @@
1
+ """The relay link: a persistent WebSocket (SPEC §8.2) and signed REST (§8.1.1).
2
+
3
+ The WebSocket authenticates with the instance key, reconnects with
4
+ exponential backoff and jitter (timed by the injectable clock), and hands
5
+ every server frame to the daemon. Replies (``accepted``/``error``) are
6
+ matched to requests by ``msg_id``. Nothing polls: the relay pushes queued
7
+ ``deliver`` frames on (re)connect and as they arrive.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import base64
14
+ import json
15
+ import time
16
+ from collections.abc import Awaitable, Callable, Coroutine
17
+ from typing import Any
18
+
19
+ import httpx
20
+ import websockets
21
+ from websockets.asyncio.client import ClientConnection, connect
22
+
23
+ import agentconnect_core as ac
24
+ from agentconnect import log
25
+ from agentconnect.backoff import Backoff
26
+ from agentconnect.clock import Clock, unix_seconds
27
+ from agentconnect.limits import MAX_PAYLOAD_DEPTH, loads
28
+
29
+ LOG = log.get("agentconnect.relay")
30
+
31
+ # The relay's frame limit is 2 MiB of JSON; leave headroom.
32
+ MAX_FRAME = 4 << 20
33
+ REQUEST_TIMEOUT = 30.0
34
+ NONCE_LEN = 32 # SPEC 8.2
35
+ # How far a signed request's ts may run ahead of the clock (many requests
36
+ # in one second, or the last run's); the relay allows 300 s either way.
37
+ REST_TS_LEAD_SECS = 60
38
+ # A relay that just restarted refuses signed requests for at most 300 s and
39
+ # says for how long (SPEC 8.1.1): the longest such wait a signed request sits
40
+ # out before it is signed again.
41
+ RESTART_RETRY_MAX_SECS = 310
42
+ DRAIN_KEY = "_drain" # set on every frame handed to the daemon
43
+ # Ours, never the relay's: handed to the daemon after the relay's ``drained``,
44
+ # behind every frame of the reconnect drain, with the connection's number.
45
+ CAUGHT_UP = "_caught_up"
46
+
47
+
48
+ def b64(raw: bytes) -> str:
49
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
50
+
51
+
52
+ def unb64(text: str) -> bytes:
53
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
54
+
55
+
56
+ class NotConnectedError(ConnectionError):
57
+ """The relay link is down; the request will be retried after reconnect."""
58
+
59
+
60
+ class RelayError(Exception):
61
+ """An ``error`` frame or REST error from the relay (SPEC §8.4)."""
62
+
63
+ def __init__(self, code: str, detail: str = "", current_epoch: int | None = None) -> None:
64
+ super().__init__(f"{code}: {detail}" if detail else code)
65
+ self.code = code
66
+ self.detail = detail
67
+ self.current_epoch = current_epoch
68
+ # Seconds until a retry, when the relay says (REST `retry_after`).
69
+ self.retry_after: int | None = None
70
+
71
+
72
+ FrameHandler = Callable[[dict[str, Any]], Awaitable[None]]
73
+
74
+
75
+ class RelayLink:
76
+ """One authenticated WebSocket to the relay, kept up until :meth:`stop`."""
77
+
78
+ def __init__( # noqa: PLR0913
79
+ self,
80
+ url: str,
81
+ relay_host: str,
82
+ instance: ac.Instance,
83
+ clock: Clock,
84
+ on_frame: FrameHandler,
85
+ *,
86
+ backoff: Backoff | None = None,
87
+ on_connected: Callable[[], Coroutine[Any, Any, None]] | None = None,
88
+ on_state: Callable[[], None] | None = None,
89
+ ) -> None:
90
+ self._url = url
91
+ self._host = relay_host
92
+ self._instance = instance
93
+ self._clock = clock
94
+ self._on_frame = on_frame
95
+ self._on_connected = on_connected
96
+ self._on_state = on_state or (lambda: None)
97
+ self.backoff = backoff or Backoff()
98
+ self._ws: ClientConnection | None = None
99
+ self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
100
+ self._connected = asyncio.Event()
101
+ self._stop = asyncio.Event()
102
+ self.connections = 0
103
+ self.draining = False
104
+
105
+ @property
106
+ def connected(self) -> bool:
107
+ return self._connected.is_set()
108
+
109
+ async def wait_connected(self) -> None:
110
+ await self._connected.wait()
111
+
112
+ async def run(self) -> None:
113
+ """Connect, serve, and reconnect forever (until :meth:`stop`)."""
114
+ while not self._stop.is_set():
115
+ try:
116
+ async with connect(
117
+ self._url, max_size=MAX_FRAME, open_timeout=10, compression=None
118
+ ) as ws:
119
+ await self._authenticate(ws)
120
+ self._ws = ws
121
+ # Until the relay's ``drained`` frame: the reconnect drain.
122
+ self.draining = True
123
+ self.connections += 1
124
+ self.backoff.reset()
125
+ self._connected.set()
126
+ LOG.info("relay_connected", connections=self.connections)
127
+ if self._on_connected is not None:
128
+ asyncio.get_running_loop().create_task(self._on_connected())
129
+ async for raw in ws:
130
+ await self._dispatch(raw)
131
+ except (OSError, websockets.WebSocketException, TimeoutError, ValueError) as e:
132
+ LOG.info("relay_disconnected", reason=type(e).__name__)
133
+ except Exception as e:
134
+ # Anything else a relay frame can provoke (a bug included) must
135
+ # not end the link for good: log the type only and reconnect.
136
+ LOG.warning("relay_link_error", reason=type(e).__name__)
137
+ finally:
138
+ self._down()
139
+ if self._stop.is_set():
140
+ break
141
+ delay = self.backoff.next()
142
+ LOG.info("relay_reconnect_scheduled", delay=round(delay, 3))
143
+ sleeping = asyncio.ensure_future(self._clock.sleep(delay))
144
+ await asyncio.sleep(0) # let the sleep register before announcing it
145
+ self._on_state()
146
+ await sleeping
147
+
148
+ def _down(self) -> None:
149
+ self._ws = None
150
+ self._connected.clear()
151
+ for fut in self._pending.values():
152
+ if not fut.done():
153
+ fut.set_exception(NotConnectedError("relay connection lost"))
154
+ self._pending.clear()
155
+ self._on_state()
156
+
157
+ async def _authenticate(self, ws: ClientConnection) -> None:
158
+ """SPEC 8.2. Every malformed frame raises ``ValueError`` (reconnect)."""
159
+ challenge = loads(await asyncio.wait_for(ws.recv(), REQUEST_TIMEOUT), MAX_PAYLOAD_DEPTH)
160
+ if not isinstance(challenge, dict) or challenge.get("t") != "challenge":
161
+ msg = "expected a challenge"
162
+ raise ValueError(msg)
163
+ nonce_text = challenge.get("nonce")
164
+ if not isinstance(nonce_text, str):
165
+ msg = "challenge without a nonce"
166
+ raise ValueError(msg)
167
+ try:
168
+ nonce = unb64(nonce_text)
169
+ except (ValueError, TypeError):
170
+ msg = "challenge nonce is not base64url"
171
+ raise ValueError(msg) from None
172
+ if len(nonce) != NONCE_LEN:
173
+ msg = "challenge nonce is not 32 bytes"
174
+ raise ValueError(msg)
175
+ sig = self._instance.sign_relay_challenge(nonce, self._host)
176
+ await ws.send(
177
+ json.dumps({"t": "auth", "sig_key": b64(self._instance.public_key), "sig": b64(sig)})
178
+ )
179
+ ready = loads(await asyncio.wait_for(ws.recv(), REQUEST_TIMEOUT), MAX_PAYLOAD_DEPTH)
180
+ if (
181
+ not isinstance(ready, dict)
182
+ or ready.get("t") != "ready"
183
+ or ready.get("mailbox_id") != self._instance.mailbox_id
184
+ ):
185
+ msg = "relay authentication failed"
186
+ raise ValueError(msg)
187
+
188
+ async def _dispatch(self, raw: str | bytes) -> None:
189
+ try:
190
+ frame = loads(raw, MAX_PAYLOAD_DEPTH)
191
+ except (ValueError, RecursionError): # not UTF-8 JSON, or nested too deep
192
+ LOG.warning("relay_frame_unparsable")
193
+ return
194
+ if not isinstance(frame, dict):
195
+ return
196
+ t = frame.get("t")
197
+ if t == "drained":
198
+ self.draining = False
199
+ # In order after the drain's frames: once the daemon has handled
200
+ # it, it has seen everything the relay held for this mailbox.
201
+ await self._on_frame({"t": CAUGHT_UP, "connection": self.connections})
202
+ return
203
+ if t == CAUGHT_UP:
204
+ return # never from the relay
205
+ # Ours, never the relay's: was this frame queued before we connected?
206
+ frame[DRAIN_KEY] = self.draining
207
+ if t in ("accepted", "error"):
208
+ fut = self._pending.pop(str(frame.get("msg_id")), None)
209
+ if fut is not None and not fut.done():
210
+ fut.set_result(frame)
211
+ return
212
+ await self._on_frame(frame)
213
+
214
+ async def send(self, frame: dict[str, Any]) -> None:
215
+ """Send without waiting for a reply (acks)."""
216
+ ws = self._ws
217
+ if ws is None:
218
+ raise NotConnectedError("relay link is down")
219
+ try:
220
+ await ws.send(json.dumps(frame, separators=(",", ":")))
221
+ except websockets.WebSocketException as e:
222
+ # The socket is gone, but run() may not have noticed yet. Mark
223
+ # the link down now: a caller that retries after
224
+ # NotConnectedError then waits in wait_connected() instead of
225
+ # failing again without yielding, which would starve the loop
226
+ # (run() included) for good.
227
+ if self._ws is ws:
228
+ self._down()
229
+ raise NotConnectedError(str(e)) from None
230
+
231
+ async def request(
232
+ self,
233
+ frame: dict[str, Any],
234
+ msg_id: str,
235
+ *,
236
+ on_sent: Callable[[], None] | None = None,
237
+ ) -> dict[str, Any]:
238
+ """Send a frame and wait for the ``accepted``/``error`` with ``msg_id``.
239
+ ``on_sent`` runs once the frame was written to the socket (from then
240
+ on the relay may have it, whatever happens to the answer).
241
+
242
+ Raises :class:`NotConnectedError` if the link drops first (the caller
243
+ resends the same frame after reconnecting) and :class:`RelayError`
244
+ for an ``error`` reply.
245
+ """
246
+ if self._ws is None:
247
+ raise NotConnectedError("relay link is down")
248
+ fut: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
249
+ self._pending[msg_id] = fut
250
+ try:
251
+ await self.send(frame)
252
+ if on_sent is not None:
253
+ on_sent()
254
+ reply = await asyncio.wait_for(fut, REQUEST_TIMEOUT)
255
+ except TimeoutError:
256
+ raise NotConnectedError("no reply from relay") from None
257
+ finally:
258
+ self._pending.pop(msg_id, None)
259
+ if reply.get("t") == "error":
260
+ raise RelayError(
261
+ str(reply.get("code")), str(reply.get("detail", "")), reply.get("current_epoch")
262
+ )
263
+ return reply
264
+
265
+ async def kick(self) -> None:
266
+ """Drop the current connection (it reconnects); used on shutdown."""
267
+ ws = self._ws
268
+ if ws is not None:
269
+ await ws.close()
270
+
271
+ async def stop(self) -> None:
272
+ """Go down at once (pending requests fail, nothing new is sent),
273
+ then close the socket."""
274
+ self._stop.set()
275
+ ws = self._ws
276
+ if ws is not None:
277
+ self._down()
278
+ await ws.close()
279
+
280
+
281
+ class RelayRest:
282
+ """Signed REST calls (SPEC §8.1.1): a fresh ``ts`` for every request."""
283
+
284
+ def __init__( # noqa: PLR0913
285
+ self,
286
+ base_url: str,
287
+ relay_host: str,
288
+ instance: ac.Instance,
289
+ clock: Clock,
290
+ transport: httpx.AsyncBaseTransport | None = None,
291
+ *,
292
+ last_ts: int = 0,
293
+ on_ts: Callable[[int], None] | None = None,
294
+ ) -> None:
295
+ self._base = base_url.rstrip("/")
296
+ self._host = relay_host
297
+ self._instance = instance
298
+ self._clock = clock
299
+ self._transport = transport
300
+ self._last_ts = last_ts
301
+ self._on_ts = on_ts
302
+
303
+ def _ts(self) -> int:
304
+ # Strictly increasing, so two requests never share a signature (each
305
+ # signed request is single use at the relay, and Ed25519 signs the
306
+ # same input the same way). `on_ts` stores it before the request
307
+ # goes out, and the next run starts above it (`last_ts`), so this
308
+ # holds across a restart in the same second too. A value far ahead of
309
+ # the clock (it was wrong once, then put right) is dropped: the relay
310
+ # refuses a ts more than 300 s off, and a repeat matters only within
311
+ # that window.
312
+ now = unix_seconds(self._clock)
313
+ following = self._last_ts + 1
314
+ if following > now + REST_TS_LEAD_SECS:
315
+ LOG.warning("relay_rest_ts_reset", reason="clock_behind_last_request")
316
+ following = now
317
+ ts = max(now, following)
318
+ self._last_ts = ts
319
+ if self._on_ts is not None:
320
+ self._on_ts(ts)
321
+ return ts
322
+
323
+ def authorization(self, method: str, path: str, body: bytes) -> str:
324
+ ts = self._ts()
325
+ sig = self._instance.sign_relay_request(method, path, ts, body, self._host)
326
+ return f"AgentConnect-Ed25519 {b64(self._instance.public_key)}.{ts}.{b64(sig)}"
327
+
328
+ async def _call(self, method: str, path: str, body: bytes = b"", signed: bool = True) -> Any:
329
+ try:
330
+ return await self._call_once(method, path, body, signed)
331
+ except RelayError as e:
332
+ # A relay that just restarted refuses signed requests for a
333
+ # moment and says for how long (SPEC 8.1.1): sign again, once,
334
+ # when it ends, rather than fail the human's request.
335
+ wait = e.retry_after
336
+ if not signed or e.code != "rate_limited" or wait is None:
337
+ raise
338
+ if wait > RESTART_RETRY_MAX_SECS:
339
+ raise
340
+ LOG.info("relay_restarting_retry", wait=wait)
341
+ await self._clock.sleep(wait)
342
+ return await self._call_once(method, path, body, signed)
343
+
344
+ async def _call_once(self, method: str, path: str, body: bytes, signed: bool) -> Any:
345
+ headers = {"content-type": "application/json"}
346
+ if signed:
347
+ headers["authorization"] = self.authorization(method, path, body)
348
+ started = time.monotonic()
349
+ try:
350
+ async with httpx.AsyncClient(
351
+ transport=self._transport, timeout=REQUEST_TIMEOUT
352
+ ) as http:
353
+ resp = await http.request(method, self._base + path, content=body, headers=headers)
354
+ except httpx.HTTPError as e:
355
+ # Connection refused, DNS, timeouts: all "relay unreachable".
356
+ raise RelayError("relay_unreachable", type(e).__name__) from None
357
+ LOG.debug(
358
+ "relay_rest",
359
+ method=method,
360
+ status=resp.status_code,
361
+ ms=round((time.monotonic() - started) * 1000),
362
+ )
363
+ try:
364
+ data = resp.json() if resp.content else {}
365
+ except ValueError:
366
+ data = None
367
+ if not isinstance(data, dict):
368
+ # A proxy's HTML error page, or a non-object body.
369
+ code = "internal" if resp.status_code >= 400 else "bad_response" # noqa: PLR2004
370
+ raise RelayError(code, f"HTTP {resp.status_code} without a JSON object")
371
+ if resp.status_code >= 400: # noqa: PLR2004
372
+ err = RelayError(str(data.get("code", "internal")), str(data.get("detail", "")))
373
+ retry_after = data.get("retry_after")
374
+ if isinstance(retry_after, int) and not isinstance(retry_after, bool):
375
+ err.retry_after = max(1, retry_after)
376
+ raise err
377
+ return data
378
+
379
+ async def create_invite(self, meta: dict[str, Any]) -> dict[str, Any]:
380
+ body = json.dumps(
381
+ {**meta, "inviter_mailbox": self._instance.mailbox_id}, separators=(",", ":")
382
+ ).encode()
383
+ return await self._call("POST", "/v0/invites", body)
384
+
385
+ async def revoke_invite(self, invite_id: str) -> None:
386
+ await self._call("DELETE", f"/v0/invites/{invite_id}")
387
+
388
+ async def invite_meta(self, invite_id: str) -> dict[str, Any]:
389
+ return await self._call("GET", f"/v0/invites/{invite_id}", signed=False)
390
+
391
+ async def create_door(self, door_id: str, expires_at: int) -> dict[str, Any]:
392
+ """Register a door for this instance's mailbox (SPEC 6.6.1, 8.1)."""
393
+ body = json.dumps(
394
+ {
395
+ "door_id": door_id,
396
+ "expires_at": expires_at,
397
+ "owner_mailbox": self._instance.mailbox_id,
398
+ },
399
+ separators=(",", ":"),
400
+ ).encode()
401
+ return await self._call("POST", "/v0/doors", body)
402
+
403
+ async def close_door(self, door_id: str) -> None:
404
+ """Close a door at once: from then on every knock gets ``door_not_open``."""
405
+ await self._call("DELETE", f"/v0/doors/{door_id}")
@@ -0,0 +1,46 @@
1
+ """The AgentConnect runtime: everything one identity and instance needs.
2
+
3
+ Embeddable: no terminal, no signals, no stdout. Hosts (``agentconnectd`` in
4
+ :mod:`agentconnect.daemon`, the embedded SDK in ``agentconnect_sdk``, the
5
+ MCP server in :mod:`agentconnect.mcp`) build on :class:`Runtime` and import
6
+ from this package only, never from its submodules (ADR-005, Decision 1;
7
+ ruff's ``TID251`` enforces it).
8
+ """
9
+
10
+ from agentconnect.adapter import Adapter
11
+ from agentconnect.callback import CallbackAdapter, PayloadCallback
12
+ from agentconnect.clock import Clock, SystemClock
13
+ from agentconnect.config import Config, ConfigError, load_config
14
+ from agentconnect.deliveries import MAX_WAIT_SECS, DeliveryQueue, Taken
15
+ from agentconnect.isolation import IsolationError
16
+ from agentconnect.keystore import DataDirLockedError, Keystore
17
+ from agentconnect.runtime.agent import AgentApi
18
+ from agentconnect.runtime.engine import Options
19
+ from agentconnect.runtime.errors import ApiError, error_reply
20
+ from agentconnect.runtime.registry import AdapterRegistry
21
+ from agentconnect.runtime.runtime import STOP_FLUSH_SECS, HandledIds, Runtime
22
+
23
+ __all__ = [
24
+ "MAX_WAIT_SECS",
25
+ "STOP_FLUSH_SECS",
26
+ "Adapter",
27
+ "AdapterRegistry",
28
+ "AgentApi",
29
+ "ApiError",
30
+ "CallbackAdapter",
31
+ "Clock",
32
+ "Config",
33
+ "ConfigError",
34
+ "DataDirLockedError",
35
+ "DeliveryQueue",
36
+ "HandledIds",
37
+ "IsolationError",
38
+ "Keystore",
39
+ "Options",
40
+ "PayloadCallback",
41
+ "Runtime",
42
+ "SystemClock",
43
+ "Taken",
44
+ "error_reply",
45
+ "load_config",
46
+ ]