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,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
|
+
]
|