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,444 @@
|
|
|
1
|
+
"""Shared plumbing for the `agentconnect` commands: exit codes, error mapping,
|
|
2
|
+
output helpers and daemon-API conveniences. Protocol logic stays in the
|
|
3
|
+
daemon; nothing here decides anything about a group."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import unicodedata
|
|
11
|
+
from collections.abc import Callable, Generator, Iterable, Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, override
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from typer.core import TyperCommand
|
|
19
|
+
|
|
20
|
+
from agentconnect.apiclient import ApiClient, ApiClientError
|
|
21
|
+
from agentconnect.config import Config, ConfigError, load_config
|
|
22
|
+
from agentconnect.sas_emoji import describe_emoji, render_emoji
|
|
23
|
+
|
|
24
|
+
# Exit codes (documented in docs/cli.md and `agentconnect --help`).
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_FAILED = 1 # the daemon or relay reported an error
|
|
27
|
+
EXIT_USAGE = 2 # bad arguments, bad invite URL, bad configuration
|
|
28
|
+
EXIT_REFUSED = 3 # refused: declined at a prompt, wrong token, wrong relay
|
|
29
|
+
EXIT_INTERCEPTED = 4 # invite already used or revoked: possible interception
|
|
30
|
+
EXIT_UNREACHABLE = 5 # the daemon is not running
|
|
31
|
+
EXIT_MISMATCH = 6 # the human reported different short codes; the group was left
|
|
32
|
+
EXIT_UNVERIFIED = 7 # a listing failed verification: a suspect directory, not "no answer"
|
|
33
|
+
|
|
34
|
+
# The same codes with their meaning, for the docs (scripts/gen_reference.py).
|
|
35
|
+
EXIT_CODES: tuple[tuple[int, str, str], ...] = (
|
|
36
|
+
(EXIT_OK, "EXIT_OK", "Success."),
|
|
37
|
+
(
|
|
38
|
+
EXIT_FAILED,
|
|
39
|
+
"EXIT_FAILED",
|
|
40
|
+
"Failed: the daemon or relay reported an error (not found, relay down, ...).",
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
EXIT_USAGE,
|
|
44
|
+
"EXIT_USAGE",
|
|
45
|
+
"Usage: bad arguments, malformed invite URL, bad configuration, an unreadable or "
|
|
46
|
+
"invalid card or token file.",
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
EXIT_REFUSED,
|
|
50
|
+
"EXIT_REFUSED",
|
|
51
|
+
"Refused: declined at a prompt, human-only operation, the group changed while "
|
|
52
|
+
"comparing, invite for a different relay (nothing is sent), a listing token for "
|
|
53
|
+
"another identity or key.",
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
EXIT_INTERCEPTED,
|
|
57
|
+
"EXIT_INTERCEPTED",
|
|
58
|
+
"Interception warning: the invite was already used or revoked.",
|
|
59
|
+
),
|
|
60
|
+
(EXIT_UNREACHABLE, "EXIT_UNREACHABLE", "The daemon is not running or unreachable."),
|
|
61
|
+
(
|
|
62
|
+
EXIT_MISMATCH,
|
|
63
|
+
"EXIT_MISMATCH",
|
|
64
|
+
"`verify` mismatch: you reported different codes and left the group.",
|
|
65
|
+
),
|
|
66
|
+
(
|
|
67
|
+
EXIT_UNVERIFIED,
|
|
68
|
+
"EXIT_UNVERIFIED",
|
|
69
|
+
"`knock`: the listing the directory served failed verification (signature, "
|
|
70
|
+
"canonical form, the token under the pinned keys, another name's listing, or "
|
|
71
|
+
"older than one seen before). The directory may be lying; nothing was sent.",
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
EXIT_CODES_HELP = (
|
|
76
|
+
"Exit codes: 0 ok, 1 failed, 2 usage or bad invite, 3 refused (declined, "
|
|
77
|
+
"wrong token, other relay), 4 invite already used or revoked (possible "
|
|
78
|
+
"interception), 5 daemon not running, 6 short-code mismatch reported, 7 a "
|
|
79
|
+
"listing failed verification."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
HUMAN_ONLY_HELP = (
|
|
83
|
+
"HUMAN ONLY: uses the human token. Never give an agent the human token or "
|
|
84
|
+
"run this for the human; it exists so a person can make this decision."
|
|
85
|
+
)
|
|
86
|
+
LOCAL_ONLY_HELP = (
|
|
87
|
+
"LOCAL ONLY: works on a stopped runtime's data directory, with no API and "
|
|
88
|
+
"no token. A person runs it; no agent tool offers it."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
INTERCEPTION_WARNING = (
|
|
92
|
+
"This invite was already used or revoked. If you didn't expect this, the "
|
|
93
|
+
"link may have been intercepted. Tell the sender."
|
|
94
|
+
)
|
|
95
|
+
SHARE_REMINDER = (
|
|
96
|
+
"Share this over a channel you trust. After joining, compare the short code "
|
|
97
|
+
"with the other members over a different channel (a call or in person)."
|
|
98
|
+
)
|
|
99
|
+
REPLICA_REMINDER = (
|
|
100
|
+
"Accept it on the replica's host only (`agentconnect accept <url>` there): only an "
|
|
101
|
+
"instance of your own identity can join through it, and with --instance only that one. "
|
|
102
|
+
"Without --instance, approve only the request whose instance is the one `identity "
|
|
103
|
+
"import` printed. The other members will see a new instance of yours join."
|
|
104
|
+
)
|
|
105
|
+
COMPARE_REMINDER = (
|
|
106
|
+
"Compare the short code with each other member over a different channel (a call "
|
|
107
|
+
"or in person): both of you run `agentconnect verify <group> <member>` at the same time."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# SPEC 4.3: the maximum invite lifetime.
|
|
111
|
+
MAX_TTL_SECS = 7 * 86_400
|
|
112
|
+
_TTL_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86_400}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Publish refusals that are about the files or the token the person gave:
|
|
116
|
+
# fix the input (usage) or get the right token (refused).
|
|
117
|
+
LISTING_EXITS = {
|
|
118
|
+
"unknown_directory": EXIT_USAGE,
|
|
119
|
+
"bad_card": EXIT_USAGE,
|
|
120
|
+
"bad_token": EXIT_USAGE,
|
|
121
|
+
"token_not_for_identity": EXIT_REFUSED,
|
|
122
|
+
"unknown_key": EXIT_REFUSED,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Knock refusals: a listing that fails verification is a suspect directory
|
|
127
|
+
# (unverified), not a failure to answer; the rest are about the request.
|
|
128
|
+
KNOCK_EXITS = {
|
|
129
|
+
"listing_invalid": EXIT_UNVERIFIED,
|
|
130
|
+
"listing_mismatch": EXIT_UNVERIFIED,
|
|
131
|
+
"listing_rolled_back": EXIT_UNVERIFIED,
|
|
132
|
+
"token_invalid": EXIT_UNVERIFIED,
|
|
133
|
+
"self_knock": EXIT_REFUSED,
|
|
134
|
+
"bad_listing_ref": EXIT_USAGE,
|
|
135
|
+
"unknown_directory": EXIT_USAGE,
|
|
136
|
+
}
|
|
137
|
+
NO_ANSWER = (
|
|
138
|
+
"No answer by {time}. The owner may be offline or busy, or chose not to answer; "
|
|
139
|
+
"nothing tells them apart."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CliError(Exception):
|
|
144
|
+
"""A failure to report: a machine-readable code, a message, an exit code."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, code: str, message: str, exit_code: int = EXIT_FAILED) -> None:
|
|
147
|
+
super().__init__(message)
|
|
148
|
+
self.code, self.message, self.exit_code = code, message, exit_code
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def clean(text: object) -> str:
|
|
152
|
+
"""Peer-supplied text made safe for a terminal: control, format (bidi)
|
|
153
|
+
and surrogate characters become ``?``. Names and purposes are attacker
|
|
154
|
+
controlled (SPEC 4.2)."""
|
|
155
|
+
return "".join("?" if unicodedata.category(c)[0] == "C" else c for c in str(text))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def emit_json(value: Any) -> None:
|
|
159
|
+
typer.echo(json.dumps(value, ensure_ascii=False))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def emit_error(error: CliError, as_json: bool) -> None:
|
|
163
|
+
if as_json:
|
|
164
|
+
emit_json({"error": {"code": error.code, "message": error.message}})
|
|
165
|
+
else:
|
|
166
|
+
typer.echo(error.message, err=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def map_api_error(e: ApiClientError) -> CliError: # noqa: PLR0911
|
|
170
|
+
"""A daemon error as a readable message and an exit code."""
|
|
171
|
+
if e.code == "not_running":
|
|
172
|
+
return CliError(
|
|
173
|
+
"daemon_unreachable",
|
|
174
|
+
"cannot reach the daemon; start it with `agentconnect daemon run`",
|
|
175
|
+
EXIT_UNREACHABLE,
|
|
176
|
+
)
|
|
177
|
+
if e.code == "auth_failed":
|
|
178
|
+
return CliError("auth_failed", "the daemon rejected the API token", EXIT_REFUSED)
|
|
179
|
+
if e.code == "human_only":
|
|
180
|
+
return CliError(
|
|
181
|
+
"human_only",
|
|
182
|
+
"this operation needs the human token; it is for a person, not an agent",
|
|
183
|
+
EXIT_REFUSED,
|
|
184
|
+
)
|
|
185
|
+
if e.code in ("forbidden", "not_permitted"):
|
|
186
|
+
# not_permitted: the group policy doesn't let this identity (SPEC 9.2).
|
|
187
|
+
return CliError(e.code, e.message or "not allowed", EXIT_REFUSED)
|
|
188
|
+
if e.code == "peer_refused":
|
|
189
|
+
return CliError(
|
|
190
|
+
"peer_refused",
|
|
191
|
+
"the daemon refused this OS user (separate-user mode); human commands run "
|
|
192
|
+
"as the daemon's user, see docs/isolation.md",
|
|
193
|
+
EXIT_REFUSED,
|
|
194
|
+
)
|
|
195
|
+
if e.code == "invite_not_open":
|
|
196
|
+
return CliError("invite_not_open", INTERCEPTION_WARNING, EXIT_INTERCEPTED)
|
|
197
|
+
if e.code == "wrong_relay":
|
|
198
|
+
return CliError(
|
|
199
|
+
"wrong_relay",
|
|
200
|
+
"This invite is for a different relay than this daemon uses. "
|
|
201
|
+
"AgentConnect v0 supports one relay, so nothing was sent. "
|
|
202
|
+
"Ask the sender for a link on your relay.",
|
|
203
|
+
EXIT_REFUSED,
|
|
204
|
+
)
|
|
205
|
+
if e.code == "bad_request":
|
|
206
|
+
return CliError("bad_request", e.message or "invalid request", EXIT_USAGE)
|
|
207
|
+
if e.code in LISTING_EXITS:
|
|
208
|
+
return CliError(e.code, e.message or e.code, LISTING_EXITS[e.code])
|
|
209
|
+
friendly = {
|
|
210
|
+
"not_found": "not found",
|
|
211
|
+
"expired": "expired",
|
|
212
|
+
"stale_epoch": "the group changed; look at the new short code and try again",
|
|
213
|
+
"rate_limited": "rate limited; try again shortly",
|
|
214
|
+
"relay": "the relay refused the request",
|
|
215
|
+
}
|
|
216
|
+
detail = e.message or friendly.get(e.code, e.code)
|
|
217
|
+
return CliError(e.code, detail, EXIT_FAILED)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@contextmanager
|
|
221
|
+
def guard(as_json: bool) -> Generator[None]:
|
|
222
|
+
"""Turn failures into a message (or JSON error) and the right exit code."""
|
|
223
|
+
try:
|
|
224
|
+
yield
|
|
225
|
+
except CliError as e:
|
|
226
|
+
emit_error(e, as_json)
|
|
227
|
+
raise typer.Exit(e.exit_code) from None
|
|
228
|
+
except ApiClientError as e:
|
|
229
|
+
err = map_api_error(e)
|
|
230
|
+
emit_error(err, as_json)
|
|
231
|
+
raise typer.Exit(err.exit_code) from None
|
|
232
|
+
except typer.Abort:
|
|
233
|
+
emit_error(CliError("aborted", "aborted; nothing was changed", EXIT_REFUSED), as_json)
|
|
234
|
+
raise typer.Exit(EXIT_REFUSED) from None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def options_first(params: Iterable[Any], args: list[str]) -> list[str]:
|
|
238
|
+
"""``args`` reordered as the known options (with their values), then
|
|
239
|
+
``--``, then every other token in its original order.
|
|
240
|
+
|
|
241
|
+
Group, invite and request ids are random base64url, so they can start
|
|
242
|
+
with ``-``; so can a message text. The parser would take such a token
|
|
243
|
+
for an option. Only exact names of the command's own options count as
|
|
244
|
+
options here; anything else is a positional, and ``--`` makes the
|
|
245
|
+
parser read it as one. Everything after a ``--`` the user typed stays
|
|
246
|
+
positional."""
|
|
247
|
+
arity: dict[str, int] = {}
|
|
248
|
+
for p in params:
|
|
249
|
+
if getattr(p, "param_type_name", None) != "option":
|
|
250
|
+
continue
|
|
251
|
+
n = 0 if getattr(p, "is_flag", False) or getattr(p, "count", False) else p.nargs
|
|
252
|
+
for name in (*p.opts, *p.secondary_opts):
|
|
253
|
+
arity[name] = n
|
|
254
|
+
options: list[str] = []
|
|
255
|
+
positionals: list[str] = []
|
|
256
|
+
i = 0
|
|
257
|
+
while i < len(args):
|
|
258
|
+
arg = args[i]
|
|
259
|
+
if arg == "--":
|
|
260
|
+
positionals.extend(args[i + 1 :])
|
|
261
|
+
break
|
|
262
|
+
name, attached = arg.split("=", 1)[0], "=" in arg
|
|
263
|
+
if arg.startswith("--") and name in arity:
|
|
264
|
+
take = 0 if attached else arity[name]
|
|
265
|
+
elif arg in arity:
|
|
266
|
+
take = arity[arg]
|
|
267
|
+
else:
|
|
268
|
+
positionals.append(arg)
|
|
269
|
+
i += 1
|
|
270
|
+
continue
|
|
271
|
+
if i + take >= len(args) and take:
|
|
272
|
+
raise MissingValueError(name)
|
|
273
|
+
options.extend(args[i : i + 1 + take])
|
|
274
|
+
i += 1 + take
|
|
275
|
+
return [*options, "--", *positionals] if positionals else options
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class MissingValueError(ValueError):
|
|
279
|
+
"""An option that takes a value came last, without one."""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class IdArgsCommand(TyperCommand):
|
|
283
|
+
"""A command whose positional arguments may start with ``-`` (ids are
|
|
284
|
+
random base64url). See :func:`options_first`."""
|
|
285
|
+
|
|
286
|
+
@override
|
|
287
|
+
def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
|
|
288
|
+
try:
|
|
289
|
+
ordered = options_first(self.get_params(ctx), args)
|
|
290
|
+
except MissingValueError as e:
|
|
291
|
+
msg = f"option {e.args[0]!r} requires an argument"
|
|
292
|
+
raise typer.BadParameter(msg, ctx=ctx) from None
|
|
293
|
+
return super().parse_args(ctx, ordered)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def load(path: Path | None, *, need_relay: bool = False) -> Config:
|
|
297
|
+
try:
|
|
298
|
+
cfg = load_config(path)
|
|
299
|
+
if need_relay:
|
|
300
|
+
cfg.require_relay()
|
|
301
|
+
return cfg
|
|
302
|
+
except ConfigError as e:
|
|
303
|
+
raise CliError("config", f"config error: {e}", EXIT_USAGE) from None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def connect(path: Path | None, *, human: bool = False) -> ApiClient:
|
|
307
|
+
"""A client for the daemon. ``human=True`` reads the human token, only
|
|
308
|
+
for the operations SPEC 6.2 and 11 reserve to a person."""
|
|
309
|
+
return ApiClient(load(path), human=human)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def pause(seconds: float) -> None:
|
|
313
|
+
"""Wait between polls (a seam: tests replace it)."""
|
|
314
|
+
time.sleep(seconds)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def parse_ttl(text: str) -> int:
|
|
318
|
+
"""``90``, ``30m``, ``24h`` or ``7d`` as seconds."""
|
|
319
|
+
m = re.fullmatch(r"(\d+)([smhd]?)", text.strip().lower())
|
|
320
|
+
if m is None:
|
|
321
|
+
msg = f"bad --ttl {text!r}; use e.g. 30m, 24h or 7d"
|
|
322
|
+
raise CliError("bad_ttl", msg, EXIT_USAGE)
|
|
323
|
+
secs = int(m.group(1)) * _TTL_UNITS[m.group(2) or "s"]
|
|
324
|
+
if not 0 < secs <= MAX_TTL_SECS:
|
|
325
|
+
raise CliError("bad_ttl", "--ttl must be between 1 second and 7 days", EXIT_USAGE)
|
|
326
|
+
return secs
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def resolve_group(api: ApiClient, ref: str) -> str:
|
|
330
|
+
"""A group id from a full id, a unique id prefix, or an exact name."""
|
|
331
|
+
groups = api.request("GET", "/v0/groups")["groups"]
|
|
332
|
+
for g in groups:
|
|
333
|
+
if g["group_id"] == ref:
|
|
334
|
+
return str(g["group_id"])
|
|
335
|
+
found = [g for g in groups if g["group_id"].startswith(ref)] or [
|
|
336
|
+
g for g in groups if g["name"] == ref
|
|
337
|
+
]
|
|
338
|
+
if len(found) == 1:
|
|
339
|
+
return str(found[0]["group_id"])
|
|
340
|
+
if not found:
|
|
341
|
+
raise CliError("not_found", f"no group matches {ref!r}", EXIT_FAILED)
|
|
342
|
+
raise CliError("ambiguous", f"{ref!r} matches {len(found)} groups; use more characters")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def events_after(
|
|
346
|
+
api: ApiClient, after: int = 0, on_gap: Callable[[int], None] | None = None
|
|
347
|
+
) -> Iterator[dict[str, Any]]:
|
|
348
|
+
"""Every event with ``seq > after``, in order. ``on_gap`` hears the
|
|
349
|
+
lowest ``seq`` left when retention removed some of them."""
|
|
350
|
+
while True:
|
|
351
|
+
reply = api.request("GET", f"/v0/events?after={after}")
|
|
352
|
+
if reply.get("gap") and on_gap is not None:
|
|
353
|
+
on_gap(int(reply.get("oldest_available", 0)))
|
|
354
|
+
on_gap = None
|
|
355
|
+
page = reply["events"]
|
|
356
|
+
yield from page
|
|
357
|
+
if not page:
|
|
358
|
+
return
|
|
359
|
+
after = int(page[-1]["seq"])
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True)
|
|
363
|
+
class Sas:
|
|
364
|
+
decimal: str
|
|
365
|
+
emoji: str
|
|
366
|
+
names: str
|
|
367
|
+
indices: list[int]
|
|
368
|
+
|
|
369
|
+
@classmethod
|
|
370
|
+
def of(cls, raw: dict[str, Any]) -> Sas:
|
|
371
|
+
idx = [int(i) for i in raw["emoji_indices"]]
|
|
372
|
+
return cls(str(raw["decimal"]), render_emoji(idx), describe_emoji(idx), idx)
|
|
373
|
+
|
|
374
|
+
def as_json(self) -> dict[str, Any]:
|
|
375
|
+
return {"decimal": self.decimal, "emoji": self.emoji, "emoji_names": self.names.split(", ")}
|
|
376
|
+
|
|
377
|
+
def lines(self) -> list[str]:
|
|
378
|
+
return [f" {self.emoji}", f" ({self.names})", f" {self.decimal}"]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def unverified_hint(info: dict[str, Any]) -> str | None:
|
|
382
|
+
"""The agent-facing nudge when other members exist and aren't all verified."""
|
|
383
|
+
trust = info["trust_state"]
|
|
384
|
+
if trust["members"] > 1 and trust["self"] != "verified" and info["status"] == "active":
|
|
385
|
+
return (
|
|
386
|
+
"ask your human to compare the short code with each other member: "
|
|
387
|
+
f"`agentconnect verify {info['group_id']} <member>` on both sides"
|
|
388
|
+
)
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def pending_for(api: ApiClient, group_id: str) -> list[dict[str, Any]]:
|
|
393
|
+
return [
|
|
394
|
+
a for a in api.request("GET", "/v0/approvals")["approvals"] if a["group_id"] == group_id
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def group_view(api: ApiClient, group_id: str) -> dict[str, Any]:
|
|
399
|
+
"""The stable JSON shape of one group (docs/cli.md, ``groups show``)."""
|
|
400
|
+
info = api.request("GET", f"/v0/groups/{group_id}")
|
|
401
|
+
trust = info["trust_state"]
|
|
402
|
+
others = max(trust["members"] - 1, 0)
|
|
403
|
+
return {
|
|
404
|
+
"group_id": info["group_id"],
|
|
405
|
+
"name": info["name"],
|
|
406
|
+
"purpose": info["purpose"],
|
|
407
|
+
"status": info["status"],
|
|
408
|
+
"epoch": info["epoch"],
|
|
409
|
+
"out_of_sync": info["out_of_sync"],
|
|
410
|
+
"out_of_sync_reason": info["out_of_sync_reason"],
|
|
411
|
+
"require_verified": bool(info["policy"].get("require_verified")),
|
|
412
|
+
"trust_state": trust,
|
|
413
|
+
"verified_summary": f"{trust['verified_members']} of {others} verified",
|
|
414
|
+
"leaving": info.get("leaving"),
|
|
415
|
+
"policy_summary": info["policy_summary"],
|
|
416
|
+
"floor": info["floor"],
|
|
417
|
+
"pending_leaves": info["pending_leaves"],
|
|
418
|
+
"members": [
|
|
419
|
+
{
|
|
420
|
+
"identity": m["identity"],
|
|
421
|
+
"fingerprint": m["fingerprint"],
|
|
422
|
+
"display_name": m["display_name"],
|
|
423
|
+
"display_name_verified": m["display_name_verified"],
|
|
424
|
+
"role": m["role"],
|
|
425
|
+
"verified": m["verified"],
|
|
426
|
+
"self": m["self"],
|
|
427
|
+
"instances": [
|
|
428
|
+
{
|
|
429
|
+
"instance_id": i["instance_id"],
|
|
430
|
+
"leaf_index": i["leaf_index"],
|
|
431
|
+
"joined_epoch": i["joined_epoch"],
|
|
432
|
+
"self": bool(i.get("self")),
|
|
433
|
+
"new_since_verified": None
|
|
434
|
+
if m["instances_since_verified"] is None
|
|
435
|
+
else i["instance_id"] in m["instances_since_verified"],
|
|
436
|
+
}
|
|
437
|
+
for i in m["instances"]
|
|
438
|
+
],
|
|
439
|
+
}
|
|
440
|
+
for m in info["members"]
|
|
441
|
+
],
|
|
442
|
+
"pending_approvals": len(pending_for(api, group_id)),
|
|
443
|
+
"hint": unverified_hint(info),
|
|
444
|
+
}
|
agentconnect/clock.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Injectable time source.
|
|
2
|
+
|
|
3
|
+
Daemon code must not call ``datetime.now()``, ``time.time()`` or
|
|
4
|
+
``asyncio.sleep()`` directly: it takes a :class:`Clock` so tests can drive
|
|
5
|
+
time with :class:`FakeClock` instead of sleeping. Mirrors
|
|
6
|
+
``agentconnect_core::clock`` on the Rust side.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import UTC, datetime, timedelta
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
# 2025-01-01T00:00:00Z: a fixed, recognisable default start for fake clocks.
|
|
18
|
+
DEFAULT_FAKE_START = datetime(2025, 1, 1, tzinfo=UTC)
|
|
19
|
+
|
|
20
|
+
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class Clock(Protocol):
|
|
25
|
+
"""A source of wall-clock time and timers."""
|
|
26
|
+
|
|
27
|
+
def now(self) -> datetime:
|
|
28
|
+
"""The current time as a timezone-aware UTC datetime."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def unix_millis(self) -> int:
|
|
32
|
+
"""Whole milliseconds since the Unix epoch."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
async def sleep(self, seconds: float, *, deadline: bool = False) -> None:
|
|
36
|
+
"""Wait until ``seconds`` of this clock's time have passed.
|
|
37
|
+
``deadline=True`` marks a timer that bounds other work (a handler
|
|
38
|
+
timeout) rather than a pause before the next attempt; a fake clock
|
|
39
|
+
doesn't count it as a sleeper."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
async def sleep_until(self, unix: float, *, deadline: bool = False) -> None:
|
|
43
|
+
"""Wait until this clock reads ``unix`` (Unix seconds). A timer set
|
|
44
|
+
against the clock itself, so a clock that moves before the timer
|
|
45
|
+
starts (a fake one moved by a test) can't delay it."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _millis(moment: datetime) -> int:
|
|
50
|
+
return (moment - _EPOCH) // timedelta(milliseconds=1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unix_seconds(clock: Clock) -> int:
|
|
54
|
+
"""Whole seconds since the Unix epoch on ``clock``."""
|
|
55
|
+
return clock.unix_millis() // 1000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SystemClock:
|
|
59
|
+
"""The real clock."""
|
|
60
|
+
|
|
61
|
+
def now(self) -> datetime:
|
|
62
|
+
return datetime.now(UTC)
|
|
63
|
+
|
|
64
|
+
def unix_millis(self) -> int:
|
|
65
|
+
return _millis(self.now())
|
|
66
|
+
|
|
67
|
+
async def sleep(self, seconds: float, *, deadline: bool = False) -> None:
|
|
68
|
+
await asyncio.sleep(max(seconds, 0))
|
|
69
|
+
|
|
70
|
+
async def sleep_until(self, unix: float, *, deadline: bool = False) -> None:
|
|
71
|
+
await asyncio.sleep(max(unix - self.unix_millis() / 1000, 0))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _wake(fut: asyncio.Future[Any]) -> None:
|
|
75
|
+
if not fut.done():
|
|
76
|
+
fut.set_result(None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FakeClock:
|
|
80
|
+
"""A manually driven, thread-safe clock for tests.
|
|
81
|
+
|
|
82
|
+
Time only moves through :meth:`advance` and :meth:`set`. :meth:`sleep`
|
|
83
|
+
returns once the clock has been moved past its deadline, so timers
|
|
84
|
+
(reconnect backoff, retries) run without real waiting.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, start: datetime = DEFAULT_FAKE_START) -> None:
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
self._now = self._checked(start)
|
|
90
|
+
self._sleepers: list[tuple[datetime, asyncio.AbstractEventLoop, asyncio.Future[Any]]] = []
|
|
91
|
+
self._deadlines: set[int] = set() # ids of the futures of deadline sleeps
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _checked(moment: datetime) -> datetime:
|
|
95
|
+
if moment.tzinfo is None:
|
|
96
|
+
msg = "FakeClock needs a timezone-aware datetime"
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
return moment.astimezone(UTC)
|
|
99
|
+
|
|
100
|
+
def now(self) -> datetime:
|
|
101
|
+
with self._lock:
|
|
102
|
+
return self._now
|
|
103
|
+
|
|
104
|
+
def unix_millis(self) -> int:
|
|
105
|
+
return _millis(self.now())
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def sleepers(self) -> int:
|
|
109
|
+
"""How many :meth:`sleep` calls are waiting, not counting deadlines
|
|
110
|
+
(timers that bound other work, such as a handler timeout): tests
|
|
111
|
+
read this as "the runtime is waiting to retry"."""
|
|
112
|
+
with self._lock:
|
|
113
|
+
return len([s for s in self._sleepers if id(s[2]) not in self._deadlines])
|
|
114
|
+
|
|
115
|
+
async def sleep(self, seconds: float, *, deadline: bool = False) -> None:
|
|
116
|
+
await self._sleep(lambda now: now + timedelta(seconds=max(seconds, 0)), deadline)
|
|
117
|
+
|
|
118
|
+
async def sleep_until(self, unix: float, *, deadline: bool = False) -> None:
|
|
119
|
+
await self._sleep(lambda _now: _EPOCH + timedelta(seconds=unix), deadline)
|
|
120
|
+
|
|
121
|
+
async def _sleep(self, until: Callable[[datetime], datetime], deadline: bool) -> None:
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
fut = loop.create_future()
|
|
124
|
+
with self._lock:
|
|
125
|
+
entry = (until(self._now), loop, fut)
|
|
126
|
+
if entry[0] <= self._now:
|
|
127
|
+
fut.set_result(None)
|
|
128
|
+
else:
|
|
129
|
+
self._sleepers.append(entry)
|
|
130
|
+
if deadline:
|
|
131
|
+
self._deadlines.add(id(fut))
|
|
132
|
+
try:
|
|
133
|
+
await fut
|
|
134
|
+
finally:
|
|
135
|
+
with self._lock:
|
|
136
|
+
if entry in self._sleepers:
|
|
137
|
+
self._sleepers.remove(entry)
|
|
138
|
+
self._deadlines.discard(id(fut))
|
|
139
|
+
|
|
140
|
+
def _release_due(self) -> None:
|
|
141
|
+
# Called with the lock held.
|
|
142
|
+
due = [s for s in self._sleepers if s[0] <= self._now]
|
|
143
|
+
for s in due:
|
|
144
|
+
self._sleepers.remove(s)
|
|
145
|
+
_, loop, fut = s
|
|
146
|
+
if not loop.is_closed():
|
|
147
|
+
loop.call_soon_threadsafe(_wake, fut)
|
|
148
|
+
|
|
149
|
+
def advance(self, by: timedelta | float) -> None:
|
|
150
|
+
"""Move forward by a timedelta or a number of seconds."""
|
|
151
|
+
delta = by if isinstance(by, timedelta) else timedelta(seconds=by)
|
|
152
|
+
with self._lock:
|
|
153
|
+
self._now += delta
|
|
154
|
+
self._release_due()
|
|
155
|
+
|
|
156
|
+
def set(self, to: datetime) -> None:
|
|
157
|
+
"""Jump to an absolute time (may move backwards)."""
|
|
158
|
+
moment = self._checked(to)
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._now = moment
|
|
161
|
+
self._release_due()
|