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
agentconnect/keystore.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Secrets at rest: the OS keyring when available, else 0600 key files.
|
|
2
|
+
|
|
3
|
+
The keystore holds the identity key, the instance key, the DB key and
|
|
4
|
+
invite secrets. Values never appear in logs, ``repr`` or error messages.
|
|
5
|
+
|
|
6
|
+
Files and directories the daemon creates are private: directories 0700,
|
|
7
|
+
files 0600 (see :func:`private_dir` and :func:`private_file`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import contextlib
|
|
14
|
+
import fcntl
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Protocol, override
|
|
21
|
+
|
|
22
|
+
import keyring
|
|
23
|
+
import keyring.backend
|
|
24
|
+
import keyring.errors
|
|
25
|
+
|
|
26
|
+
SERVICE = "agentconnect"
|
|
27
|
+
_NAME = re.compile(r"^[a-z][a-z0-9_.-]{0,40}(/[A-Za-z0-9_-]{1,64})?$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KeystoreError(RuntimeError):
|
|
31
|
+
"""The keystore could not read or write a secret."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class KeystoreNameError(KeystoreError):
|
|
35
|
+
"""A secret name the keystore refuses (not the store's state, the name's)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def private_dir(path: Path) -> Path:
|
|
39
|
+
"""Create ``path`` (and parents) and make it 0700."""
|
|
40
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
41
|
+
path.chmod(0o700)
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def private_file(path: Path) -> Path:
|
|
46
|
+
"""Create ``path`` empty if missing and make it 0600."""
|
|
47
|
+
private_dir(path.parent)
|
|
48
|
+
fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600)
|
|
49
|
+
os.close(fd)
|
|
50
|
+
path.chmod(0o600)
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_private(path: Path, data: bytes) -> None:
|
|
55
|
+
"""Atomically replace ``path`` with ``data``, mode 0600: write a fresh
|
|
56
|
+
temporary file (unique name, created 0600) in the same directory, fsync
|
|
57
|
+
it, then ``os.replace`` it into place."""
|
|
58
|
+
private_dir(path.parent)
|
|
59
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
60
|
+
try:
|
|
61
|
+
try:
|
|
62
|
+
os.fchmod(fd, 0o600)
|
|
63
|
+
view = memoryview(data)
|
|
64
|
+
while view:
|
|
65
|
+
view = view[os.write(fd, view) :]
|
|
66
|
+
os.fsync(fd)
|
|
67
|
+
finally:
|
|
68
|
+
os.close(fd)
|
|
69
|
+
os.replace(tmp, path)
|
|
70
|
+
except BaseException:
|
|
71
|
+
Path(tmp).unlink(missing_ok=True)
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DataDirLockedError(RuntimeError):
|
|
76
|
+
"""Another runtime (a daemon, an embedded agent or an MCP server) holds
|
|
77
|
+
the data directory. ``holder`` is the kind its note names (``daemon``,
|
|
78
|
+
``embedded``, ``mcp``), or ``None`` when the note is missing."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: str, holder: str | None = None) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
self.holder = holder
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DataDirAccessError(OSError):
|
|
86
|
+
"""This OS user can't open the data directory's lock (another user's,
|
|
87
|
+
such as a daemon in separate-OS-user mode)."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
LOCK_HOLDERS = {"daemon": "a daemon", "embedded": "an embedded agent", "mcp": "an MCP server"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DataDirLock:
|
|
94
|
+
"""An exclusive ``flock`` on ``<data_dir>/daemon.lock``, held for the
|
|
95
|
+
runtime's lifetime, so two runtimes (a daemon, an embedded agent, or a
|
|
96
|
+
tool that opens the same state) never share keys, databases or a mailbox.
|
|
97
|
+
|
|
98
|
+
The holder writes ``<owner> <pid>`` into the file, so a second runtime
|
|
99
|
+
can say who has the directory. That note is informational only: the
|
|
100
|
+
``flock`` is the lock. ``note=False`` (the CLI's identity commands,
|
|
101
|
+
which are no runtime) leaves the last runtime's note as it is."""
|
|
102
|
+
|
|
103
|
+
FILE = "daemon.lock"
|
|
104
|
+
|
|
105
|
+
def __init__(self, data_dir: Path, owner: str = "daemon", *, note: bool = True) -> None:
|
|
106
|
+
private_dir(data_dir)
|
|
107
|
+
path = data_dir / self.FILE
|
|
108
|
+
self._fd: int | None = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
109
|
+
try:
|
|
110
|
+
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
111
|
+
except OSError:
|
|
112
|
+
held = self._note(self._fd)
|
|
113
|
+
os.close(self._fd)
|
|
114
|
+
self._fd = None
|
|
115
|
+
holder = "unknown" if held is None else f"{LOCK_HOLDERS[held[0]]}, pid {held[1]}"
|
|
116
|
+
msg = (
|
|
117
|
+
f"another agentconnect runtime ({holder}) is using {data_dir}: "
|
|
118
|
+
"stop it, or use another data directory"
|
|
119
|
+
)
|
|
120
|
+
raise DataDirLockedError(msg, None if held is None else held[0]) from None
|
|
121
|
+
if note:
|
|
122
|
+
os.ftruncate(self._fd, 0)
|
|
123
|
+
os.pwrite(self._fd, f"{owner} {os.getpid()}\n".encode("ascii"), 0)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def probe(cls, data_dir: Path) -> tuple[bool, str | None]:
|
|
127
|
+
"""Whether a runtime holds ``data_dir`` now, and the kind its note
|
|
128
|
+
names (the holder's, or the last holder's when nobody holds it;
|
|
129
|
+
``None`` without a note). Takes the lock for an instant when it is
|
|
130
|
+
free, so a runtime that starts at that instant is refused once."""
|
|
131
|
+
try:
|
|
132
|
+
fd = os.open(data_dir / cls.FILE, os.O_RDWR)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
return False, None
|
|
135
|
+
except PermissionError:
|
|
136
|
+
msg = f"{data_dir} is not accessible to this user"
|
|
137
|
+
raise DataDirAccessError(msg) from None
|
|
138
|
+
try:
|
|
139
|
+
note = cls._note(fd)
|
|
140
|
+
try:
|
|
141
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
142
|
+
except OSError:
|
|
143
|
+
return True, None if note is None else note[0]
|
|
144
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
145
|
+
return False, None if note is None else note[0]
|
|
146
|
+
finally:
|
|
147
|
+
os.close(fd)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _note(fd: int) -> tuple[str, str] | None:
|
|
151
|
+
"""``(kind, pid)`` from the holder's note, or ``None`` when it's
|
|
152
|
+
missing or not ours."""
|
|
153
|
+
words = os.pread(fd, 64, 0).decode("ascii", "replace").split()
|
|
154
|
+
if len(words) != 2 or words[0] not in LOCK_HOLDERS or not words[1].isdigit(): # noqa: PLR2004
|
|
155
|
+
return None
|
|
156
|
+
return words[0], words[1]
|
|
157
|
+
|
|
158
|
+
def release(self) -> None:
|
|
159
|
+
if self._fd is not None:
|
|
160
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
161
|
+
os.close(self._fd)
|
|
162
|
+
self._fd = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check_name(name: str) -> str:
|
|
166
|
+
if not _NAME.match(name):
|
|
167
|
+
msg = "invalid secret name"
|
|
168
|
+
raise KeystoreNameError(msg)
|
|
169
|
+
return name
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Keystore(Protocol):
|
|
173
|
+
"""Named secrets (bytes)."""
|
|
174
|
+
|
|
175
|
+
kind: str
|
|
176
|
+
|
|
177
|
+
def get(self, name: str) -> bytes | None: ...
|
|
178
|
+
def put(self, name: str, value: bytes) -> None: ...
|
|
179
|
+
def delete(self, name: str) -> None: ...
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class FileKeystore:
|
|
183
|
+
"""One 0600 file per secret in a 0700 directory."""
|
|
184
|
+
|
|
185
|
+
kind = "file"
|
|
186
|
+
|
|
187
|
+
def __init__(self, directory: Path) -> None:
|
|
188
|
+
self._dir = private_dir(directory)
|
|
189
|
+
|
|
190
|
+
@override
|
|
191
|
+
@override
|
|
192
|
+
def __repr__(self) -> str:
|
|
193
|
+
return "FileKeystore(...)"
|
|
194
|
+
|
|
195
|
+
def _path(self, name: str) -> Path:
|
|
196
|
+
return self._dir / _check_name(name).replace("/", "__")
|
|
197
|
+
|
|
198
|
+
def get(self, name: str) -> bytes | None:
|
|
199
|
+
p = self._path(name)
|
|
200
|
+
try:
|
|
201
|
+
return p.read_bytes()
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def put(self, name: str, value: bytes) -> None:
|
|
206
|
+
write_private(self._path(name), value)
|
|
207
|
+
|
|
208
|
+
def delete(self, name: str) -> None:
|
|
209
|
+
self._path(name).unlink(missing_ok=True)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class KeyringKeystore:
|
|
213
|
+
"""The OS keyring (Secret Service, macOS Keychain, Windows Credential Locker).
|
|
214
|
+
|
|
215
|
+
Entries live under service ``agentconnect``, with a user name scoped to
|
|
216
|
+
the data directory so several daemons on one host don't collide.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
kind = "keyring"
|
|
220
|
+
|
|
221
|
+
def __init__(self, scope: str, backend: keyring.backend.KeyringBackend | None = None) -> None:
|
|
222
|
+
self._scope = hashlib.sha256(scope.encode()).hexdigest()[:16]
|
|
223
|
+
self._backend = backend or keyring.get_keyring()
|
|
224
|
+
|
|
225
|
+
@override
|
|
226
|
+
@override
|
|
227
|
+
def __repr__(self) -> str:
|
|
228
|
+
return "KeyringKeystore(...)"
|
|
229
|
+
|
|
230
|
+
def _user(self, name: str) -> str:
|
|
231
|
+
return f"{self._scope}/{_check_name(name)}"
|
|
232
|
+
|
|
233
|
+
def get(self, name: str) -> bytes | None:
|
|
234
|
+
try:
|
|
235
|
+
raw = self._backend.get_password(SERVICE, self._user(name))
|
|
236
|
+
except keyring.errors.KeyringError:
|
|
237
|
+
msg = "keyring read failed"
|
|
238
|
+
raise KeystoreError(msg) from None
|
|
239
|
+
return None if raw is None else base64.b64decode(raw)
|
|
240
|
+
|
|
241
|
+
def put(self, name: str, value: bytes) -> None:
|
|
242
|
+
try:
|
|
243
|
+
self._backend.set_password(SERVICE, self._user(name), base64.b64encode(value).decode())
|
|
244
|
+
except keyring.errors.KeyringError:
|
|
245
|
+
msg = "keyring write failed"
|
|
246
|
+
raise KeystoreError(msg) from None
|
|
247
|
+
|
|
248
|
+
def delete(self, name: str) -> None:
|
|
249
|
+
with contextlib.suppress(keyring.errors.PasswordDeleteError):
|
|
250
|
+
self._backend.delete_password(SERVICE, self._user(name))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def keyring_usable(backend: keyring.backend.KeyringBackend | None = None) -> bool:
|
|
254
|
+
"""Whether a real keyring backend is configured (not the fail/null ones)."""
|
|
255
|
+
backend = backend or keyring.get_keyring()
|
|
256
|
+
module = type(backend).__module__
|
|
257
|
+
if module.startswith(("keyring.backends.fail", "keyring.backends.null")):
|
|
258
|
+
return False
|
|
259
|
+
try:
|
|
260
|
+
return float(backend.priority) > 0
|
|
261
|
+
except Exception:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def open_keystore(
|
|
266
|
+
kind: str,
|
|
267
|
+
data_dir: Path,
|
|
268
|
+
backend: keyring.backend.KeyringBackend | None = None,
|
|
269
|
+
) -> Keystore:
|
|
270
|
+
"""``auto`` picks the keyring when usable, otherwise key files."""
|
|
271
|
+
if kind == "file" or (kind == "auto" and not keyring_usable(backend)):
|
|
272
|
+
return FileKeystore(data_dir / "keys")
|
|
273
|
+
if kind in ("keyring", "auto"):
|
|
274
|
+
if not keyring_usable(backend):
|
|
275
|
+
msg = "no usable OS keyring"
|
|
276
|
+
raise KeystoreError(msg)
|
|
277
|
+
return KeyringKeystore(str(data_dir.resolve()), backend)
|
|
278
|
+
msg = f"unknown keystore {kind!r}"
|
|
279
|
+
raise KeystoreError(msg)
|