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,2043 @@
1
+ """The local control API: small JSON over HTTP/1.1.
2
+
3
+ Transport: a Unix socket in the data directory (mode 0600) by default, or
4
+ ``127.0.0.1:<port>`` when configured. The server is a minimal asyncio
5
+ HTTP/1.1 implementation (one request per connection, ``Content-Length``
6
+ bodies only, 1 MiB cap), so the daemon needs no web framework.
7
+
8
+ Authentication: ``Authorization: Bearer <token>``. There are two tokens,
9
+ each in a 0600 file in the data directory:
10
+
11
+ * ``api-token`` (scope ``agent``): everything an agent integration may do;
12
+ * ``human-token`` (scope ``human``): additionally approving JoinRequests,
13
+ knocks and held messages, marking members verified, removing members,
14
+ creating a replica invite (ADR-005: provisioning a replica is the human's),
15
+ changing or reading the group's safety policy, audit log and transcript,
16
+ and publishing a listing (SPEC 13.2.2: an agent can't put itself into a
17
+ public directory; withdrawing is allowed to either token). Knocks this
18
+ runtime received, and the events about them, are the human's only.
19
+
20
+ Approval and verification are human decisions (SPEC §6.2, §11). An agent
21
+ that could approve joins or mark the SAS as compared would defeat both
22
+ checks, so those endpoints refuse the agent token. Hand only ``api-token``
23
+ to agent tooling. (Both files are readable by the same OS user; the split
24
+ keeps agent integrations from holding the human capability by default, it is
25
+ not a sandbox.)
26
+
27
+ Separate-OS-user mode (:mod:`agentconnect.isolation`): the socket is opened
28
+ to an agent group, every connection's peer UID is checked against an
29
+ allowlist, and the human token is accepted only from the daemon's own UID or
30
+ a configured human UID.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import contextlib
37
+ import json
38
+ import os
39
+ import re
40
+ import stat
41
+ from collections import Counter
42
+ from collections.abc import Awaitable, Callable
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ from agentconnect import log
48
+ from agentconnect.cli_json import APPROVAL, HELD, KNOCK_INSPECT, KNOCK_SUMMARY, KNOCKS, LISTING
49
+ from agentconnect.deliveries import DeliveryQueue
50
+ from agentconnect.isolation import PeerGate
51
+ from agentconnect.runtime.agent import AgentApi, a2a_text
52
+ from agentconnect.runtime.engine import Engine
53
+ from agentconnect.runtime.errors import ApiError, error_reply
54
+ from agentconnect.shapes import Opt
55
+ from agentconnect.sockets import bind_path, prepare_bind_dir
56
+ from agentconnect.sse import SseAdapter, Subscription
57
+ from agentconnect.tasks import cancel_and_wait
58
+ from agentconnect.tokens import API_TOKEN_FILE, HUMAN_TOKEN_FILE, TokenDigest
59
+
60
+ LOG = log.get("agentconnect.api")
61
+
62
+ MAX_BODY = 1 << 20
63
+ MAX_HEADER = 16 << 10
64
+ HEADER_TIMEOUT = 2.0 # seconds to send the request line and the headers
65
+ READ_TIMEOUT = 10.0 # seconds to send a whole request
66
+ # Connections whose request line and headers haven't all arrived (no token
67
+ # checked yet), in all and per peer UID where the socket reports one (the
68
+ # separate-OS-user mode). A connection whose headers arrived is never
69
+ # closed to make room. Past a limit, a new connection closes an unfinished
70
+ # one that has waited EVICT_AFTER seconds: of its own UID at that UID's
71
+ # limit, else of the UID holding the most; one that has sent nothing first,
72
+ # then the oldest (503 busy). So idle or half-sent connections can't keep
73
+ # out a request that has a token, and requests that arrive together (an
74
+ # SDK's parallel calls) don't push each other out. Past a hard limit,
75
+ # waiting doesn't count, and the oldest one that sent part of a request
76
+ # and stalled goes first. On TCP there is no UID: only the totals apply.
77
+ MAX_PENDING = 32
78
+ MAX_PENDING_PER_PEER = 8
79
+ MAX_PENDING_HARD = 128
80
+ MAX_PENDING_PER_PEER_HARD = 64
81
+ EVICT_AFTER = 0.5
82
+ # Requests in progress at most once their token is checked, per token: the
83
+ # agent token's (long polls included) can't take the human token's. Only
84
+ # the human's own tools hold the human token (in the separate-OS-user mode
85
+ # only the human's UIDs may use it), so only they can use up its 16.
86
+ MAX_AGENT_REQUESTS = 48
87
+ MAX_HUMAN_REQUESTS = 16
88
+ # Event streams (GET /v0/events as SSE) per peer UID in the
89
+ # separate-OS-user mode: one agent user can't hold every agent request.
90
+ MAX_STREAMS_PER_PEER = 16
91
+ SCOPE = "_scope" # handler parameter carrying the request's token scope
92
+ ACCEPT = "_accept" # ... the Accept header
93
+ LAST_EVENT_ID = "_last_event_id" # ... the Last-Event-ID header
94
+
95
+ STATUS_TEXT = {
96
+ 200: "OK",
97
+ 400: "Bad Request",
98
+ 401: "Unauthorized",
99
+ 408: "Request Timeout",
100
+ 403: "Forbidden",
101
+ 404: "Not Found",
102
+ 405: "Method Not Allowed",
103
+ 409: "Conflict",
104
+ 410: "Gone",
105
+ 413: "Payload Too Large",
106
+ 422: "Unprocessable Content",
107
+ 429: "Too Many Requests",
108
+ 500: "Internal Server Error",
109
+ 502: "Bad Gateway",
110
+ 503: "Service Unavailable",
111
+ }
112
+
113
+
114
+ async def _close(writer: asyncio.StreamWriter) -> None:
115
+ """Close and wait, so no transport outlives the server that made it."""
116
+ writer.close()
117
+ with contextlib.suppress(ConnectionError, OSError):
118
+ await writer.wait_closed()
119
+
120
+
121
+ class SseStream:
122
+ """A handler result that keeps the connection open and writes SSE frames
123
+ until the client goes away (``text/event-stream``, see :mod:`sse`)."""
124
+
125
+ def __init__(self, hub: SseAdapter, sub: Subscription, peer: int | None = None) -> None:
126
+ self.hub, self.sub, self.peer = hub, sub, peer
127
+ self._stop = asyncio.Event()
128
+
129
+ def stop(self) -> None:
130
+ """End the stream (the daemon is shutting down)."""
131
+ self._stop.set()
132
+
133
+ async def run(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
134
+ writer.write(
135
+ b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n"
136
+ b"cache-control: no-cache\r\nconnection: close\r\n\r\n: connected\n\n"
137
+ )
138
+ gone = asyncio.ensure_future(reader.read(1)) # EOF: the client left
139
+ stopped = asyncio.ensure_future(self._stop.wait())
140
+ try:
141
+ await writer.drain()
142
+ while not gone.done() and not stopped.done():
143
+ if self.sub.overflowed and self.sub.queue.empty():
144
+ break # too slow: the client resumes with Last-Event-ID
145
+ get = asyncio.ensure_future(self.sub.queue.get())
146
+ await asyncio.wait({get, gone, stopped}, return_when=asyncio.FIRST_COMPLETED)
147
+ if not get.done():
148
+ get.cancel()
149
+ break
150
+ writer.write(get.result().frame())
151
+ await writer.drain()
152
+ except ConnectionError:
153
+ pass
154
+ finally:
155
+ self.hub.unsubscribe(self.sub)
156
+ gone.cancel()
157
+ stopped.cancel()
158
+ await _close(writer)
159
+
160
+
161
+ MAX_POLL_SECS = 60 # the longest one GET /v0/deliveries waits
162
+ MAX_POLLS = 4 # GET /v0/deliveries waiting at once, per token
163
+
164
+
165
+ class DeliveryPoll:
166
+ """A handler result that waits for deliveries (``GET /v0/deliveries``)
167
+ while watching the connection: a caller that goes away leases nothing,
168
+ and a stopping server answers at once (empty)."""
169
+
170
+ def __init__(
171
+ self,
172
+ queue: DeliveryQueue,
173
+ stopping: asyncio.Event,
174
+ done: Callable[[], None],
175
+ **take: Any,
176
+ ) -> None:
177
+ self.queue, self.stopping, self.done, self.take = queue, stopping, done, take
178
+
179
+ async def run(self, reader: asyncio.StreamReader) -> dict[str, Any] | None:
180
+ """The answer, or ``None`` if the caller left first."""
181
+ gone = asyncio.ensure_future(reader.read(1)) # EOF: the client left
182
+ taking = asyncio.ensure_future(self.queue.take(stop=self.stopping, **self.take))
183
+ try:
184
+ await asyncio.wait({gone, taking}, return_when=asyncio.FIRST_COMPLETED)
185
+ if not taking.done():
186
+ return None
187
+ batch = taking.result()
188
+ if gone.done():
189
+ self.queue.release(batch.lease, [t.delivery_id for t in batch.items])
190
+ return None
191
+ finally:
192
+ self.done()
193
+ gone.cancel()
194
+ await cancel_and_wait(taking)
195
+ return {
196
+ "deliveries": [
197
+ {"delivery_id": str(t.delivery_id), "message": t.payload} for t in batch.items
198
+ ],
199
+ "lease": batch.lease,
200
+ "more": batch.more,
201
+ "untrusted": True,
202
+ }
203
+
204
+
205
+ @dataclass
206
+ class Connection:
207
+ """One connection's request: which token's requests it counts as."""
208
+
209
+ agent: bool = False
210
+ human: bool = False
211
+
212
+
213
+ @dataclass(eq=False)
214
+ class Pending:
215
+ """A connection whose request line and headers haven't all arrived."""
216
+
217
+ peer: int | None # its UID, where the socket reports one
218
+ task: asyncio.Task[Any] | None # the task serving it
219
+ writer: asyncio.StreamWriter
220
+ since: float # loop time it was accepted
221
+ started: bool = False # it has sent a byte
222
+
223
+
224
+ Handler = Callable[[dict[str, str], dict[str, Any]], Awaitable[Any]]
225
+
226
+
227
+ class Route:
228
+ def __init__(self, method: str, pattern: str, handler: Handler, human: bool = False) -> None:
229
+ self.method = method
230
+ self.regex = re.compile(
231
+ "^" + re.sub(r"{(\w+)}", r"(?P<\1>[A-Za-z0-9_:.-]+)", pattern) + "$"
232
+ )
233
+ self.handler = handler
234
+ self.human = human
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class RouteSpec:
239
+ """One route: where it is served, who may call it and what it takes and
240
+ returns. The table below is the only place routes are declared; the server
241
+ is built from it and ``docs/api.md`` is generated from it
242
+ (``scripts/gen_reference.py``)."""
243
+
244
+ method: str
245
+ path: str # `{name}` segments become handler parameters
246
+ handler: str # name of the ApiServer method that serves it
247
+ summary: str
248
+ request: Any = None # shape of the JSON body (None: no body); see shapes.py
249
+ response: Any = None # shape of the JSON answer
250
+ query: str = "" # query parameters, described in words
251
+ human: bool = False # True: the human token only
252
+
253
+
254
+ _GROUP_INFO: dict[str, Any] = {
255
+ "group_id": "str",
256
+ "name": "str",
257
+ "purpose": "str",
258
+ "status": "str",
259
+ "epoch": "int",
260
+ "policy": "object",
261
+ "out_of_sync": "bool",
262
+ "out_of_sync_reason": Opt("str"),
263
+ "adapters": Opt(["str"]),
264
+ "delivers_to": ["str"],
265
+ "delivery_held": "bool",
266
+ "trust_state": {"self": "str", "verified_members": "int", "members": "int"},
267
+ "leaving": Opt({"since": "int", "blocked": "bool"}),
268
+ "policy_summary": {
269
+ "invite": Opt("str"),
270
+ "remove": Opt("str"),
271
+ "max_members": Opt("int"),
272
+ "max_agent_turns": Opt("int"),
273
+ },
274
+ "floor": {
275
+ "mode": Opt("str"),
276
+ "pinned": "bool",
277
+ "moderator": Opt("str"),
278
+ "effective_moderator": Opt("str"),
279
+ "holder": Opt("str"),
280
+ },
281
+ "pending_leaves": [{"identity": "str", "instance_id": "str"}],
282
+ "members": [
283
+ {
284
+ "identity": "str",
285
+ "fingerprint": "str",
286
+ "display_name": Opt("str"),
287
+ "display_name_verified": "bool",
288
+ "role": "str",
289
+ "verified": "bool",
290
+ "instances": [
291
+ {
292
+ "instance_id": "str",
293
+ "mailbox_id": "str",
294
+ "leaf_index": "int",
295
+ "joined_epoch": "int",
296
+ "self": "bool",
297
+ }
298
+ ],
299
+ "instances_since_verified": Opt(["str"]),
300
+ "self": "bool",
301
+ "listed_as?": {
302
+ "directory": "str",
303
+ "name": "str",
304
+ "owner_domain": Opt("str"),
305
+ "owner_state": "str",
306
+ },
307
+ }
308
+ ],
309
+ "agent_cards": "object",
310
+ "origin?": {"kind": "str", "directory": "str", "name": "str"},
311
+ }
312
+
313
+ _HELD: dict[str, Any] = HELD
314
+
315
+ _SAS_RUN: dict[str, Any] = {
316
+ "run": "str",
317
+ "group_id": "str",
318
+ "peer": "str",
319
+ "peer_name": Opt("str"),
320
+ "epoch": Opt("int"),
321
+ "state": "str",
322
+ "unpaired": "bool",
323
+ "peer_started": "bool",
324
+ "reason": Opt("str"),
325
+ "warning": "bool",
326
+ "committer": Opt("str"),
327
+ "next": Opt("str"),
328
+ "warnings": "int",
329
+ "stopped": "bool",
330
+ "answerable": "bool",
331
+ "expires_at": "int",
332
+ "peer_instance?": "str",
333
+ "sas?": {"decimal": "str", "emoji_indices": ["int"]},
334
+ "labels?": {"self": "str", "peer": "str"},
335
+ "stop_text?": "str",
336
+ "unpaired_text?": "str",
337
+ "warning_text?": "str",
338
+ }
339
+
340
+ _SAFETY: dict[str, Any] = {"group_id": "str", "settings": "object", "overrides": "object"}
341
+
342
+ _WARNINGS: list[Any] = [{"code": "str", "message": "str"}]
343
+ _LISTING_REQUEST: dict[str, Any] = {
344
+ "directory": "str",
345
+ "card": "object",
346
+ "token": "object",
347
+ "owner_domain?": Opt("str"),
348
+ }
349
+
350
+ ROUTE_SPECS: tuple[RouteSpec, ...] = (
351
+ RouteSpec(
352
+ "GET",
353
+ "/v0/status",
354
+ "_status",
355
+ "Daemon status: identity, relay connection, queue sizes. With the agent token, "
356
+ "`held_messages` counts only the agent's own held sends.",
357
+ response={
358
+ "identity": "str",
359
+ "fingerprint": "str",
360
+ "instance_id": "str",
361
+ "mailbox_id": "str",
362
+ "relay": {"url": "str", "connected": "bool"},
363
+ "keystore": "str",
364
+ "groups": "int",
365
+ "out_of_sync": ["str"],
366
+ "outbox_queued": "int",
367
+ "inbox_pending": "int",
368
+ "pending_approvals": "int",
369
+ "held_messages": "int",
370
+ },
371
+ ),
372
+ RouteSpec(
373
+ "GET",
374
+ "/v0/groups",
375
+ "_list_groups",
376
+ "List groups.",
377
+ response={
378
+ "groups": [
379
+ {
380
+ "group_id": "str",
381
+ "name": "str",
382
+ "status": "str",
383
+ "epoch": "int",
384
+ "members": "int",
385
+ "out_of_sync": "bool",
386
+ }
387
+ ]
388
+ },
389
+ ),
390
+ RouteSpec(
391
+ "POST",
392
+ "/v0/groups",
393
+ "_create_group",
394
+ "Create a group you own. `policy` takes the fields of `POST /v0/groups/{gid}/policy` "
395
+ "over the default policy, set in the group's first epoch; a `floor` set here is "
396
+ "pinned. With the agent token `policy` may hold only `require_verified`; anything "
397
+ "else needs the human token (`403 human_only`).",
398
+ request={"name": "str", "purpose?": "str", "policy?": "object"},
399
+ response={"group_id": "str"},
400
+ ),
401
+ RouteSpec(
402
+ "GET",
403
+ "/v0/groups/{gid}",
404
+ "_group_info",
405
+ "One group: members, roles, verified flags, `out_of_sync` and "
406
+ "the peers' Agent Cards (each wrapped as `untrusted`). Each member lists its "
407
+ "instances, `self` marking this one and `joined_epoch` the epoch this runtime first "
408
+ "saw it in; `instances_since_verified` are those that joined after you verified the "
409
+ "member (`null`: unknown, the member was verified before the daemon recorded the "
410
+ "epoch, so verify it again). `policy_summary` and `floor` repeat the policy's "
411
+ "permissions and floor; `floor` adds the `effective_moderator` and the `holder` "
412
+ "of a moderated group's floor ([groups.md](groups.md#floor-control)), and "
413
+ "`pending_leaves` the instances whose leave waits for a commit. `leaving` is set "
414
+ "while this instance leaves the group; `blocked` says the leave waits for an "
415
+ "ownership transfer ([groups.md](groups.md#leaving)).",
416
+ response=_GROUP_INFO,
417
+ ),
418
+ RouteSpec(
419
+ "POST",
420
+ "/v0/groups/{gid}/invites",
421
+ "_create_invite",
422
+ "Create an invite. `ttl_secs` defaults to 86400 and `max_uses` to 1; "
423
+ "`inviter_name` defaults to the daemon's display name. `replica: true` (human "
424
+ "token only, else `403 human_only`) makes it an invite for another instance of "
425
+ "this identity: it is allowed whatever the group's `invite` says, and join requests "
426
+ "from any other identity are dropped. `instance` (16 lowercase hex, with `replica` "
427
+ "only) pins it to the instance `identity import` printed: requests from any other "
428
+ "instance are dropped too, and only a pinned replica invite is approved by "
429
+ "`auto_approve` ([groups.md](groups.md#replicas)).",
430
+ request={
431
+ "ttl_secs?": "int",
432
+ "max_uses?": "int",
433
+ "inviter_name?": "str",
434
+ "replica?": "bool",
435
+ "instance?": "str",
436
+ },
437
+ response={"url": "str", "invite_id": "str", "expires_at": "int"},
438
+ ),
439
+ RouteSpec(
440
+ "DELETE",
441
+ "/v0/invites/{invite_id}",
442
+ "_revoke",
443
+ "Revoke an invite you created.",
444
+ response={"revoked": "str"},
445
+ ),
446
+ RouteSpec(
447
+ "POST",
448
+ "/v0/invites/inspect",
449
+ "_inspect",
450
+ "Parse an invite URL locally and return its public metadata. Nothing is "
451
+ "sent to the relay. The metadata is claimed by the link, so it is "
452
+ "marked `untrusted`. An invite whose relay host is not this daemon's "
453
+ "relay is refused with `400 wrong_relay` before anything is fetched "
454
+ "(SPEC 4.4).",
455
+ request={"url": "str"},
456
+ response={"meta": "object", "warning": Opt("str"), "untrusted": "bool"},
457
+ ),
458
+ RouteSpec(
459
+ "POST",
460
+ "/v0/invites/accept",
461
+ "_accept",
462
+ "Send the join request for an invite URL. The inviter's human must approve it. "
463
+ "An invite for another relay is refused with `400 wrong_relay`, as by "
464
+ "`/v0/invites/inspect`.",
465
+ request={"url": "str", "display_name?": Opt("str")},
466
+ response={"invite_id": "str", "status": "str", "meta": "object"},
467
+ ),
468
+ RouteSpec(
469
+ "POST",
470
+ "/v0/groups/{gid}/policy",
471
+ "_policy",
472
+ "Change the group policy (a commit, SPEC 9.2): give only the fields that change. "
473
+ "Setting `floor` also pins it (`floor_pinned`, SPEC 9.1) unless you give "
474
+ "`floor_pinned`; `floor_moderator` takes an identity or `null`. The agent token may "
475
+ "only turn `require_verified` on (SPEC 10.4); every other change needs the human "
476
+ "token. `status` is `committing` (then `policy_changed` or `commit_failed`) or "
477
+ "`unchanged`. A change your role doesn't allow is refused at once with "
478
+ "`403 not_permitted` ([groups.md](groups.md#changing-roles-and-the-policy)).",
479
+ request={
480
+ "require_verified?": "bool",
481
+ "invite?": "str",
482
+ "remove?": "str",
483
+ "max_members?": "int",
484
+ "floor?": "str",
485
+ "floor_pinned?": "bool",
486
+ "floor_moderator?": Opt("str"),
487
+ "max_agent_turns?": "int",
488
+ },
489
+ response={"status": "str"},
490
+ ),
491
+ RouteSpec(
492
+ "POST",
493
+ "/v0/groups/{gid}/members/{identity}/role",
494
+ "_role",
495
+ "Make a member an admin or a plain member (`role`: `admin` or `member`; a commit "
496
+ "only the owner may make). `status` is `committing` (then `role_changed`) or "
497
+ "`unchanged`. The owner's role changes only with a transfer (`POST "
498
+ "/v0/groups/{gid}/owner`).",
499
+ request={"role": "str"},
500
+ response={"status": "str"},
501
+ human=True,
502
+ ),
503
+ RouteSpec(
504
+ "POST",
505
+ "/v0/groups/{gid}/owner",
506
+ "_owner",
507
+ "Hand ownership over to another member (a commit only the owner may make, SPEC "
508
+ "9.2). `keep_admin` (default `true`) keeps you an admin. `status` is `committing`, "
509
+ "then `role_changed` for both. A leave of yours that waits for a transfer goes on "
510
+ "once it lands ([groups.md](groups.md#the-owner-leaves-last)).",
511
+ request={"identity": "str", "keep_admin?": "bool"},
512
+ response={"status": "str"},
513
+ human=True,
514
+ ),
515
+ RouteSpec(
516
+ "POST",
517
+ "/v0/groups/{gid}/floor",
518
+ "_floor",
519
+ "Grant the floor of a `moderated` group to a member (`holder`: an identity), or "
520
+ "release it (`holder: null`): the daemon sends `control.floor` (SPEC 9.3), which "
521
+ "takes effect once the relay has taken it. Only the effective moderator or the "
522
+ "owner may (`403 not_permitted`); a group whose floor isn't `moderated` answers "
523
+ "`409 not_moderated`. Either token: a grant triggers nobody, and the floor mode and "
524
+ "the moderator stay the human's (`POST /v0/groups/{gid}/policy`). The agent token's "
525
+ "grants count against its send rate (`429 rate_limited`). `status` is `granted`; "
526
+ "`floor_changed` follows when the holder changed "
527
+ "([groups.md](groups.md#floor-control)).",
528
+ request={"holder": Opt("str")},
529
+ response={"status": "str", "holder": Opt("str"), "msg_id": "str"},
530
+ ),
531
+ RouteSpec(
532
+ "POST",
533
+ "/v0/groups/{gid}/messages",
534
+ "_send",
535
+ "Send a message. Give `text` (sent as an A2A v1.0 `Message` with a `messageId`, "
536
+ "`contextId`, `role` and one text part), or a full `body` object. `to` defaults to "
537
+ '`["*"]` (everyone) and `kind` to `a2a.message`. `reply_to` lists the '
538
+ "`msg_id`s answered (SPEC 10.3). `addressee` names exactly who is triggered "
539
+ '(SPEC 9.3); task replies go to `["*"]` with the requester as `addressee`. '
540
+ "For a task status or artifact update, `reply_to` names the request the task "
541
+ "answers (the message that started it, or the follow-up being answered): only "
542
+ "then does the requester take it as its task, triggered when the task asks for "
543
+ "input or auth or ends, its progress as context (SPEC 9.3). Left out, the runtime "
544
+ "names the request this task's first update named, else the last message from "
545
+ "the `addressee` that triggered this instance. "
546
+ "`instance` (an instance id, 16 lowercase hex) narrows that to one instance "
547
+ "(SPEC 9.4), such as the replica that asked: it becomes `body.metadata.instance`, "
548
+ "replacing any there, and goes on `a2a.message`, `a2a.task_status`, "
549
+ "`a2a.task_artifact` and `a2a.task_cancel` only (`400 bad_request` otherwise, "
550
+ "and for a malformed id, also one in `body.metadata.instance`). An id that isn't "
551
+ "in the group is sent anyway and triggers nobody; every instance still receives "
552
+ "the message ([groups.md](groups.md#addressing-one-instance)). "
553
+ "The agent token sends as the agent and "
554
+ "the human token as the human; kinds reserved to humans (such as "
555
+ "`control.resume`) need the human token (SPEC 7.3). `status` is `queued`, or "
556
+ "`held` when the group's `require_approval` holds the agent's message for the "
557
+ "human (see [safety.md](safety.md#approvals)).",
558
+ request={
559
+ "text?": "str",
560
+ "body?": "object",
561
+ "to?": ["str"],
562
+ "kind?": "str",
563
+ "reply_to?": ["str"],
564
+ "addressee?": ["str"],
565
+ "instance?": "str",
566
+ },
567
+ response={"msg_id": "str", "status": "str"},
568
+ ),
569
+ RouteSpec(
570
+ "POST",
571
+ "/v0/groups/{gid}/leave",
572
+ "_leave",
573
+ "Leave a group: the daemon sends `control.leave` and a leave proposal, and another "
574
+ "member commits it. `status` is `leaving`, or `left` for a sole member. The owner's "
575
+ "last instance must hand over ownership first (`403 forbidden`).",
576
+ response={"status": "str"},
577
+ ),
578
+ RouteSpec(
579
+ "GET",
580
+ "/v0/approvals",
581
+ "_list_approvals",
582
+ "Pending join requests (`kind: join`) with the joiner's fingerprint, knocks on "
583
+ "your listing waiting for approval (`kind: knock`, no `group_id` yet: the knocker's "
584
+ "name and note are claims, `known` is what this daemon already knows of its "
585
+ "identity), and the messages the safety policy holds for approval (`messages`, "
586
+ "metadata only). With the agent token, `approvals` is empty and `messages` lists "
587
+ "only the agent's own held sends: what is held of the peers' messages, and who "
588
+ "knocked, is the human's to know.",
589
+ response={"approvals": [APPROVAL], "messages": [_HELD]},
590
+ ),
591
+ RouteSpec(
592
+ "GET",
593
+ "/v0/approvals/messages/{hid}",
594
+ "_held_message",
595
+ "One held message with its content, for the human to decide on. A message "
596
+ "from a peer is `untrusted`.",
597
+ response={
598
+ **_HELD,
599
+ "untrusted": "bool",
600
+ "to": Opt(["str"]),
601
+ "addressee": Opt(["str"]),
602
+ "reply_to": Opt(["str"]),
603
+ "body": "object",
604
+ },
605
+ human=True,
606
+ ),
607
+ RouteSpec(
608
+ "POST",
609
+ "/v0/approvals/messages/{hid}/approve",
610
+ "_approve_message",
611
+ "Release a held message: to the adapter (inbound) or to the group (outbound).",
612
+ response={"hold_id": "str", "group_id": "str", "status": "str"},
613
+ human=True,
614
+ ),
615
+ RouteSpec(
616
+ "POST",
617
+ "/v0/approvals/messages/{hid}/reject",
618
+ "_reject_message",
619
+ "Drop a held message. The peer is not told; a rejected send raises `send_failed`.",
620
+ response={"hold_id": "str", "group_id": "str", "status": "str"},
621
+ human=True,
622
+ ),
623
+ RouteSpec(
624
+ "POST",
625
+ "/v0/approvals/{rid}/approve",
626
+ "_approve",
627
+ "Approve a join request or a knock. For a knock the daemon creates a new group named "
628
+ "after the listing, with `purpose` (default: the listing's description) as its "
629
+ "scope, and adds the knocker (SPEC 6.6.4); `group_id` is that group. A knock past "
630
+ "its lapse (an hour after it was sent) answers `409 expired`. Only the human token "
631
+ "approves a knock: there is no setting, policy or agent route that does.",
632
+ request={"purpose?": "str"},
633
+ response={"status": "str", "group_id?": "str"},
634
+ human=True,
635
+ ),
636
+ RouteSpec(
637
+ "POST",
638
+ "/v0/approvals/{rid}/reject",
639
+ "_reject",
640
+ "Reject a join request or a knock. Nothing is sent to the knocker: a rejection "
641
+ "looks the same as no answer.",
642
+ response={"status": "str"},
643
+ human=True,
644
+ ),
645
+ RouteSpec(
646
+ "POST",
647
+ "/v0/groups/{gid}/members/{identity}/sas",
648
+ "_sas_start",
649
+ "Start a SAS run with a member (SPEC 11.3): the daemon sends its commitment and "
650
+ "pairs with the first commitment the member's daemon sends back, from the "
651
+ "instance named in `instance` if given. Both humans start their own side. "
652
+ "`409 conflict` while a run with the member is under way, also from another "
653
+ "instance of your identity; `409 sas_stopped` after two warnings with the member, "
654
+ "until you pass `acknowledge: true`. The answer is the run, as "
655
+ "`GET /v0/groups/{gid}/sas/runs/{run}` shows it.",
656
+ request={"instance?": "str", "acknowledge?": "bool"},
657
+ response=_SAS_RUN,
658
+ human=True,
659
+ ),
660
+ RouteSpec(
661
+ "GET",
662
+ "/v0/groups/{gid}/sas/runs/{run}",
663
+ "_sas_run",
664
+ "A SAS run: `state` (`starting`, `waiting`, `exchanging`, `done`, `aborted`), the "
665
+ "code and both instance labels once `done`, `unpaired` (and `unpaired_text`) when "
666
+ "the run is still unpaired after 60 s, the abort `reason`, whether it was a "
667
+ "`warning` (and the `committer` whose commit ended it), `next` when the daemon "
668
+ "restarted the run in a new epoch, and `stopped` after two warnings with the "
669
+ "member. `answerable` is whether `same` can still be recorded for it.",
670
+ response=_SAS_RUN,
671
+ human=True,
672
+ ),
673
+ RouteSpec(
674
+ "POST",
675
+ "/v0/groups/{gid}/sas/runs/{run}/peer-started",
676
+ "_sas_peer_started",
677
+ "The other human says they started their side: if the run then times out "
678
+ "unpaired, that is a warning (SPEC 11.3).",
679
+ response=_SAS_RUN,
680
+ human=True,
681
+ ),
682
+ RouteSpec(
683
+ "POST",
684
+ "/v0/groups/{gid}/sas/runs/{run}/cancel",
685
+ "_sas_cancel",
686
+ "Give a SAS run up. Before this daemon revealed its nonce, the peer is told "
687
+ "(`control.sas_cancel`) and nothing is counted; after it, the run ends and the "
688
+ "peer's run ends as a warning (SPEC 11.3).",
689
+ response=_SAS_RUN,
690
+ human=True,
691
+ ),
692
+ RouteSpec(
693
+ "POST",
694
+ "/v0/groups/{gid}/sas/runs/{run}/labels",
695
+ "_sas_labels",
696
+ "The other human reads the same code, but instance labels that don't mirror yours "
697
+ "(a code that differs goes to `sas/mismatch`, whatever the labels): `self` is the label "
698
+ "they read as theirs, `peer` the one they read as yours. It counts as a warning. If "
699
+ "both name instances in your view (theirs one of the member's, `peer` one of your "
700
+ "identity's), two comparisons of replicas paired crosswise: `outcome` is "
701
+ "`replicas` and `instance` the member's instance to name with `--instance`. "
702
+ "Otherwise it is treated as codes that differ: the daemon leaves the group "
703
+ "(`outcome` `mismatch`, SPEC 11.5).",
704
+ request={"self": "str", "peer": "str"},
705
+ response={
706
+ "outcome": "str",
707
+ "instance?": "str",
708
+ "text?": "str",
709
+ "status?": "str",
710
+ },
711
+ human=True,
712
+ ),
713
+ RouteSpec(
714
+ "POST",
715
+ "/v0/groups/{gid}/members/{identity}/verify",
716
+ "_verify",
717
+ 'Record the human\'s answer "same code" for a SAS run (SPEC 11.5): `run` is the '
718
+ "run whose code and labels the human compared. It must have its code, the answer "
719
+ "must come within 600 s of it, and no newer run or warning with the member may "
720
+ "have come since: otherwise `409 stale_epoch` (or `409 not_done`), compare again. "
721
+ "`verified: false` clears the flag and needs no run. A body with `epoch` (the "
722
+ "group-wide code this replaced) is refused with `400 bad_request`.",
723
+ request={"verified?": "bool", "run?": "str"},
724
+ response={"verified": "bool"},
725
+ human=True,
726
+ ),
727
+ RouteSpec(
728
+ "POST",
729
+ "/v0/groups/{gid}/members/{identity}/remove",
730
+ "_remove",
731
+ "Remove a member, every instance of it (an MLS commit; the group policy decides "
732
+ "whether this instance may). For a member the safety policy reported with "
733
+ "`repeated_violations`; the daemon never removes anyone on its own. With "
734
+ "`instances` (instance ids, 16 hex characters), only those instances go, such as "
735
+ "a dead replica; this way you may also remove your own identity's other instances, "
736
+ "but never this one (leave instead). The result is a `member_removed` or "
737
+ "`instance_removed` event, or `commit_failed`.",
738
+ request={"instances?": ["str"]},
739
+ response={"status": "str"},
740
+ human=True,
741
+ ),
742
+ RouteSpec(
743
+ "GET",
744
+ "/v0/groups/{gid}/safety",
745
+ "_safety",
746
+ "The group's safety policy: the effective `settings` (with per-sender "
747
+ "`senders` overrides) and the group's own `overrides` of the defaults in "
748
+ "`[policy]`.",
749
+ response=_SAFETY,
750
+ ),
751
+ RouteSpec(
752
+ "POST",
753
+ "/v0/groups/{gid}/safety",
754
+ "_set_safety",
755
+ "Change the group's safety policy. `set` maps setting names to values; "
756
+ "`null` clears the group's override. With `sender`, the changes apply to "
757
+ "that member only (limits only). See [safety.md](safety.md#settings).",
758
+ request={"set": "object", "sender?": "str"},
759
+ response=_SAFETY,
760
+ human=True,
761
+ ),
762
+ RouteSpec(
763
+ "PUT",
764
+ "/v0/groups/{gid}/adapters",
765
+ "_set_adapters",
766
+ "Pin the adapters the group delivers its messages and events to, in order, or "
767
+ "`null` to follow `daemon.adapters` again (the default). Each must be an adapter "
768
+ "this runtime has, at most once; `400 bad_request` otherwise. Every adapter gets "
769
+ "every message and keeps its own delivery state; the first one with an Agent Card "
770
+ "sends it. This decides which endpoints receive the group's plaintext, so only "
771
+ "the human token may. See [config.md](config.md).",
772
+ request={"adapters": Opt(["str"])},
773
+ response={"group_id": "str", "adapters": Opt(["str"]), "delivers_to": ["str"]},
774
+ human=True,
775
+ ),
776
+ RouteSpec(
777
+ "GET",
778
+ "/v0/audit",
779
+ "_audit",
780
+ "The audit log, oldest first, at most 200 rows per call: who, when, kind, size "
781
+ "and the policy's decision for every message in and out. Never a body. "
782
+ "`suppressed` counts the security rows that fell between a sender's oldest and "
783
+ "newest half of its share, which are not kept ([safety.md](safety.md#audit-log)).",
784
+ query="`after`: only rows with a greater `seq` (default 0). `group`: only that group.",
785
+ response={
786
+ "audit": [
787
+ {
788
+ "seq": "int",
789
+ "group_id": "str",
790
+ "at": "int",
791
+ "direction": "str",
792
+ "sender": "str",
793
+ "msg_id": "str",
794
+ "kind": "str",
795
+ "size": "int",
796
+ "decision": "str",
797
+ "reason": "str",
798
+ }
799
+ ],
800
+ "suppressed": [
801
+ {
802
+ "group_id": "str",
803
+ "sender": "str",
804
+ "decision": "str",
805
+ "count": "int",
806
+ "first_at": "int",
807
+ "last_at": "int",
808
+ }
809
+ ],
810
+ },
811
+ human=True,
812
+ ),
813
+ RouteSpec(
814
+ "GET",
815
+ "/v0/groups/{gid}/transcript",
816
+ "_transcript",
817
+ "The group's transcript, when its `transcript` setting is on: every message "
818
+ "delivered or sent, in full, at most 200 per call. Peer messages are untrusted.",
819
+ query="`after`: only entries with a greater `seq` (default 0).",
820
+ response={
821
+ "transcript": [
822
+ {
823
+ "seq": "int",
824
+ "group_id": "str",
825
+ "at": "int",
826
+ "direction": "str",
827
+ "sender": "str",
828
+ "msg_id": "str",
829
+ "kind": "str",
830
+ "inner": "object",
831
+ }
832
+ ],
833
+ "untrusted": "bool",
834
+ },
835
+ human=True,
836
+ ),
837
+ RouteSpec(
838
+ "POST",
839
+ "/v0/groups/{gid}/sas/mismatch",
840
+ "_sas_mismatch",
841
+ "Report that the short codes differ, or take the mismatch action of a run that "
842
+ "never paired or of a stop after two warnings. Always accepted: the daemon leaves "
843
+ "the group (SPEC 11.5). `run` names the run, when there is one.",
844
+ request={"run?": Opt("str")},
845
+ response={"status": "str"},
846
+ human=True,
847
+ ),
848
+ RouteSpec(
849
+ "POST",
850
+ "/v0/listing/preview",
851
+ "_listing_preview",
852
+ "Check a listing before publishing it ([discovery.md](discovery.md)): the card "
853
+ "text (`card`, the card file's contents) and the operator's `token` are verified "
854
+ "against the pinned `[[directory]]`, and the answer shows what would be signed, "
855
+ "with `content_sha256` to pass to `publish`. With an owner domain, the daemon "
856
+ "fetches that domain's owner file and returns the entry it should list "
857
+ "(`owner_file_entry`). Nothing is registered, signed or sent to the directory.",
858
+ request=_LISTING_REQUEST,
859
+ response={
860
+ "directory": "str",
861
+ "name": "str",
862
+ "identity": "str",
863
+ "fingerprint": "str",
864
+ "content": "object",
865
+ "content_sha256": "str",
866
+ "owner_domain": Opt("str"),
867
+ "token": {"token_id": "str", "issued_at": "int", "expires_at": "int", "key_id": "str"},
868
+ "owner_file_entry": Opt("object"),
869
+ "warnings": _WARNINGS,
870
+ },
871
+ human=True,
872
+ ),
873
+ RouteSpec(
874
+ "POST",
875
+ "/v0/listing/publish",
876
+ "_listing_publish",
877
+ "Publish the listing the preview showed (`content_sha256` must match): register "
878
+ "a fresh door at the relay, sign and post. The runtime renews the door from then "
879
+ "on. A directory that doesn't answer is retried with the same signed bytes "
880
+ "(`retrying: true`, `state` `publishing`); a refusal answers with the "
881
+ "directory's code. Publishing is a human decision (SPEC 13.2.2).",
882
+ request={**_LISTING_REQUEST, "content_sha256": "str"},
883
+ response={
884
+ "directory": "str",
885
+ "name": "str",
886
+ "url": "str",
887
+ "seq": "int",
888
+ "state": "str",
889
+ "door_expires_at": Opt("int"),
890
+ "renews_at": Opt("int"),
891
+ "token_expires_at": "int",
892
+ "owner_domain": Opt("str"),
893
+ "owner_state": Opt("str"),
894
+ "warnings": _WARNINGS,
895
+ "retrying": "bool",
896
+ },
897
+ human=True,
898
+ ),
899
+ RouteSpec(
900
+ "POST",
901
+ "/v0/listing/withdraw",
902
+ "_listing_withdraw",
903
+ "Withdraw the listing: every door is closed at the relay first (their keys are "
904
+ "deleted at once), then the signed withdrawal goes to the directory. Either "
905
+ "token may: withdrawing only reduces exposure (SPEC 13.2.3). `state` is "
906
+ "`withdrawing` while the directory hasn't answered; the runtime keeps trying, "
907
+ "and gives up after 7 days (`withdraw_failed`). `force: true` (the human token "
908
+ "only, and only while a withdrawal is `withdrawing` or `withdraw_failed`; else "
909
+ "`409 not_withdrawing`) ends it here without the directory, after the doors are "
910
+ "closed: `state` is then `withdrawn_locally` and `forced` is true, and the "
911
+ "directory may go on listing it. A directory removed from the config answers "
912
+ "`400 unknown_directory`.",
913
+ request={"force?": "bool"},
914
+ response={
915
+ "directory": "str",
916
+ "name": "str",
917
+ "seq": "int",
918
+ "state": "str",
919
+ "doors_closed": "int",
920
+ "doors_close_pending": "int",
921
+ "forced": "bool",
922
+ },
923
+ ),
924
+ RouteSpec(
925
+ "GET",
926
+ "/v0/listing",
927
+ "_listing_status",
928
+ "The runtime's listing, if it has one: its state, door, token expiry and last "
929
+ "error. `owner_state` is what the directory last reported, for information only. "
930
+ "With the human token it adds `pending_knocks` and `knocks_dropped`.",
931
+ response={"listing": Opt(LISTING)},
932
+ ),
933
+ RouteSpec(
934
+ "POST",
935
+ "/v0/knocks/inspect",
936
+ "_knock_inspect",
937
+ "Fetch a listing (`listing` is `<directory host>/a/<name>`, on a pinned "
938
+ "`[[directory]]`) and check it as a knock would: signature, token under the pinned "
939
+ "keys, directory and name, relay, door, and no rollback. With `domain_check` "
940
+ "(default `knock.domain_check`) the daemon fetches the owner domain's file itself; "
941
+ "`owner.state` is that check, never the directory's. The text is the listing's "
942
+ "own claim: `untrusted`, with a `notice` to show in front of it. A listing that "
943
+ "fails a check answers `422` (`listing_invalid`, `listing_mismatch`, "
944
+ "`listing_rolled_back`) or `403 token_invalid`.",
945
+ request={"listing": "str", "domain_check?": "bool"},
946
+ response=KNOCK_INSPECT,
947
+ ),
948
+ RouteSpec(
949
+ "POST",
950
+ "/v0/knocks",
951
+ "_knock",
952
+ "Knock on a listing: ask its owner's human for a new group (SPEC 6.6.2). Pass the "
953
+ "`sha256` the inspection showed as `listing_sha256` (`409 listing_changed` if it "
954
+ "changed). `display_name` defaults to the daemon's; `note` is optional (one line, "
955
+ "1 to 500 characters). The knock waits until `gives_up_at`: a `joined` event "
956
+ "with its `knock_id` is the only answer, otherwise `knock_lapsed` (no answer, "
957
+ "never why). Either token, unless `knock.agent_may_knock` is off (then "
958
+ "`403 human_only` for the agent token).",
959
+ request={
960
+ "listing": "str",
961
+ "listing_sha256?": "str",
962
+ "display_name?": "str",
963
+ "note?": "str",
964
+ "domain_check?": "bool",
965
+ },
966
+ response=KNOCK_SUMMARY,
967
+ ),
968
+ RouteSpec(
969
+ "GET",
970
+ "/v0/knocks",
971
+ "_knocks",
972
+ "This daemon's own knocks, oldest first: `queued`, `sent`, `joined` (with "
973
+ "`group_id`), `lapsed` or `failed`, each with what it knocked on: the listing's "
974
+ "name, fingerprint and owner state, and its card's name and description (the "
975
+ "listing owner's text, so `untrusted`). A knock the agent started is readable by "
976
+ "its human here (SPEC 6.6.2).",
977
+ response={"knocks": [KNOCKS], "untrusted": "bool"},
978
+ ),
979
+ RouteSpec(
980
+ "GET",
981
+ "/v0/events",
982
+ "_events",
983
+ "Daemon events in order, at most 200 per call. With the agent token `seq` is the "
984
+ "agent's own sequence, with no gap where an event it doesn't see was recorded, and "
985
+ "`after` takes it; the human token's `seq` numbers every event. The agent token "
986
+ "doesn't see the safety policy's events (`message_held`, `held_expired`, "
987
+ "`repeated_violations`, `message_failed`): they would tell the agent, and so a "
988
+ "peer, what the policy did. Nor does it see "
989
+ "the knocks your listing received (`knock_received`, `knock_approved`, "
990
+ "`knock_rejected`, `knock_expired`, `knock_approval_failed`), "
991
+ "`invite_flooded` (one of your invites gets more join requests than it can hold), "
992
+ "`join_ledger_full` (the join ledger itself is full) and where the group's "
993
+ "deliveries stand (`adapter_unavailable`, `adapter_available`, "
994
+ "`delivery_stalled`, `delivery_resumed`). "
995
+ "With "
996
+ "`Accept: text/event-stream` it is instead a Server-Sent Events stream "
997
+ "of messages and events from a durable feed (needs "
998
+ '`"sse"` in `daemon.adapters`, else `409 no_sse_adapter`): each '
999
+ "frame has `id` (the feed sequence number), `event` and one line of "
1000
+ "JSON `data`, in the payload shape of the webhook adapter. Send "
1001
+ "`Last-Event-ID` to resume after that id, also after a daemon restart; "
1002
+ "without it the stream starts live. "
1003
+ "Events are kept for `daemon.event_retention_days` once every adapter has them, "
1004
+ "and 100000 at most; the human's own (those the agent token doesn't see) only once "
1005
+ "the human token read them without `group`. `oldest_available` is the lowest "
1006
+ "`seq` retention may have left, and `gap` says that events after `after` are "
1007
+ "gone.",
1008
+ query="`after`: only events with a greater `seq` (default 0). `group`: only "
1009
+ "that group (`404 not_found` on a stream for an unknown group).",
1010
+ response={
1011
+ "events": [
1012
+ {
1013
+ "seq": "int",
1014
+ "group_id": Opt("str"),
1015
+ "type": "str",
1016
+ "data": "object",
1017
+ "at": "int",
1018
+ "event_id": "str",
1019
+ }
1020
+ ],
1021
+ "gap": "bool",
1022
+ "oldest_available": "int",
1023
+ },
1024
+ ),
1025
+ RouteSpec(
1026
+ "GET",
1027
+ "/v0/deliveries",
1028
+ "_deliveries",
1029
+ "Messages waiting for an agent that pulls them (an MCP session attached to this "
1030
+ "runtime, [adapters/mcp.md](adapters/mcp.md)), oldest first, at most 50, in the "
1031
+ "payload shape of the webhook adapter (`untrusted`, `notice`, `addressed_to_me`). "
1032
+ "Waits up to `timeout` seconds for one; with `addressed_only`, for one that "
1033
+ "addresses this agent, however many others are queued before it (the answer holds "
1034
+ "it and up to 49 queued messages before it, as context). The messages are leased "
1035
+ "to the caller under `lease` for 120 seconds: no other caller gets them meanwhile, "
1036
+ "and they are queued again unless acknowledged with `POST /v0/deliveries/ack`. "
1037
+ "`more` is true when more are queued. At most 4 polls per token wait at once; "
1038
+ "another answers `429 busy`. Needs "
1039
+ '`"mcp"` in `daemon.adapters` (the MCP host sets it), else `409 no_deliveries`.',
1040
+ query="`group`: only that group. `addressed_only`: `1` or `0` (default 0). "
1041
+ "`timeout`: seconds to wait, 0 to 60 (default 0).",
1042
+ response={
1043
+ "deliveries": [{"delivery_id": "str", "message": "object"}],
1044
+ "lease": "str",
1045
+ "more": "bool",
1046
+ "untrusted": "bool",
1047
+ },
1048
+ ),
1049
+ RouteSpec(
1050
+ "POST",
1051
+ "/v0/deliveries/ack",
1052
+ "_deliveries_ack",
1053
+ "Acknowledge deliveries of a `lease`: they leave the queue, and a repeat of one "
1054
+ "(after a crash) is not queued again. Only the lease that took them may: also "
1055
+ "after its 120 seconds ran out, unless another caller took them since. "
1056
+ "`acked` counts those acknowledged; `refused` lists the ids the lease doesn't "
1057
+ "hold (never leased, leased again, or already acknowledged).",
1058
+ request={"lease": "str", "ids": ["str"]},
1059
+ response={"acked": "int", "refused": ["str"]},
1060
+ ),
1061
+ RouteSpec(
1062
+ "POST",
1063
+ "/v0/deliveries/release",
1064
+ "_deliveries_release",
1065
+ "Give back deliveries of a `lease` that won't be returned (the caller's tool "
1066
+ "call was cancelled): they are queued again at once. Ids the lease doesn't hold "
1067
+ "are left alone.",
1068
+ request={"lease": "str", "ids": ["str"]},
1069
+ response={"released": "bool"},
1070
+ ),
1071
+ )
1072
+
1073
+ # Every error the server can answer with: status, code, meaning.
1074
+ API_ERRORS: tuple[tuple[int, str, str], ...] = (
1075
+ (
1076
+ 400,
1077
+ "bad_request",
1078
+ "Malformed request: the body is not a JSON object, or a field is missing "
1079
+ "or has the wrong type.",
1080
+ ),
1081
+ (
1082
+ 400,
1083
+ "wrong_relay",
1084
+ "The invite is for a different relay than this daemon's (SPEC 4.4). Nothing "
1085
+ "was fetched or sent.",
1086
+ ),
1087
+ (
1088
+ 400,
1089
+ "unknown_directory",
1090
+ "Publish: the directory has no `[[directory]]` table, so no keys are pinned for it.",
1091
+ ),
1092
+ (400, "bad_token", "Publish: the token is not a listing token (malformed)."),
1093
+ (
1094
+ 400,
1095
+ "bad_listing_ref",
1096
+ "Knock: the listing is not `[https://]<host>/a/<name>[.json]` (no query, fragment, "
1097
+ "user or other path).",
1098
+ ),
1099
+ (400, "self_knock", "Knock: the listing is this runtime's own identity's."),
1100
+ (
1101
+ 400,
1102
+ "bad_card",
1103
+ "Publish: the card is not valid listing text, or holds fields of a full Agent Card.",
1104
+ ),
1105
+ (
1106
+ 400,
1107
+ "bad_listing",
1108
+ "Publish: the directory refused the listing as malformed. Also `wrong_relay`, "
1109
+ "`wrong_directory`, `bad_door` and `stale_ts`: the directory's codes "
1110
+ "(directory/README.md).",
1111
+ ),
1112
+ (401, "auth_failed", "The bearer token is missing or wrong."),
1113
+ (
1114
+ 403,
1115
+ "token_invalid",
1116
+ "Publish or knock: the listing token doesn't verify under the pinned keys: "
1117
+ "expired, not valid yet, bad signature, an unpinned key, or for another directory.",
1118
+ ),
1119
+ (403, "unknown_key", "Publish: the token is signed by a key not pinned for the directory."),
1120
+ (
1121
+ 403,
1122
+ "token_not_for_identity",
1123
+ "Publish: the token was issued for another identity (or owner domain).",
1124
+ ),
1125
+ (
1126
+ 403,
1127
+ "token_revoked",
1128
+ "Publish: the directory refused the token (also `bad_token`, `bad_signature`, "
1129
+ "`wrong_identity`, `owner_revoked`).",
1130
+ ),
1131
+ (403, "human_only", "The operation needs the human token."),
1132
+ (
1133
+ 403,
1134
+ "peer_refused",
1135
+ "Separate-OS-user mode: the caller's OS user may not use the socket, or may "
1136
+ "not use the human token (docs/isolation.md).",
1137
+ ),
1138
+ (
1139
+ 403,
1140
+ "forbidden",
1141
+ "The daemon refused: the owner must hand over first, or a browser request "
1142
+ "reached the TCP listener.",
1143
+ ),
1144
+ (
1145
+ 403,
1146
+ "not_permitted",
1147
+ "Policy, role or ownership change: the group policy doesn't let this identity make "
1148
+ "it now (the core's check, SPEC 9.2). Nothing was queued.",
1149
+ ),
1150
+ (404, "not_found", "No such endpoint, group, invite or request."),
1151
+ (405, "method_not_allowed", "The path exists but not for this method."),
1152
+ (
1153
+ 408,
1154
+ "timeout",
1155
+ "The request line and headers were not received within 2 seconds, or the whole "
1156
+ "request within 10.",
1157
+ ),
1158
+ (
1159
+ 409,
1160
+ "stale_epoch",
1161
+ "The SAS run's code can't be confirmed any more: more than 600 s passed since it "
1162
+ "was shown, or a newer run or a warning with the member came since. Compare again.",
1163
+ ),
1164
+ (
1165
+ 409,
1166
+ "not_done",
1167
+ "The SAS run has no code: it is still under way, or it ended without one.",
1168
+ ),
1169
+ (
1170
+ 409,
1171
+ "conflict",
1172
+ "A SAS run with this member is already under way, here or on another instance of "
1173
+ "your identity.",
1174
+ ),
1175
+ (
1176
+ 409,
1177
+ "sas_stopped",
1178
+ "Two SAS runs with this member ended with a warning. Start again with "
1179
+ "`acknowledge: true`, or treat it as different codes (`sas/mismatch`).",
1180
+ ),
1181
+ (
1182
+ 409,
1183
+ "no_deliveries",
1184
+ "Deliveries: this runtime has no delivery queue (`daemon.adapters` has no `mcp`).",
1185
+ ),
1186
+ (
1187
+ 409,
1188
+ "expired",
1189
+ "The invite or request has expired (a knock lapses an hour after it was sent).",
1190
+ ),
1191
+ (409, "invite_not_open", "The invite was used up, revoked or never open."),
1192
+ (409, "not_moderated", "Floor grant: the group's floor isn't `moderated`."),
1193
+ (
1194
+ 409,
1195
+ "group_full",
1196
+ "Approve: the Add would take the group past its policy's `max_members`. The "
1197
+ "request stays pending; remove a member first.",
1198
+ ),
1199
+ (
1200
+ 409,
1201
+ "too_many_invites",
1202
+ "Create invite: the open invites already reserve the whole join ledger (each "
1203
+ "its share plus its uses, SPEC 6.2); revoke the ones you no longer need.",
1204
+ ),
1205
+ (
1206
+ 409,
1207
+ "listing_exists",
1208
+ "Publish: the runtime already has another listing; withdraw it first.",
1209
+ ),
1210
+ (
1211
+ 409,
1212
+ "not_withdrawing",
1213
+ "Withdraw: `force` only ends a withdrawal the directory hasn't taken.",
1214
+ ),
1215
+ (409, "card_changed", "Publish: the card or token changed since the preview."),
1216
+ (
1217
+ 409,
1218
+ "name_held",
1219
+ "Publish: the directory holds the name for a newer token or a higher `seq`.",
1220
+ ),
1221
+ (409, "listing_changed", "Knock: the listing changed since it was inspected."),
1222
+ (409, "already_pending", "Knock: a knock on this listing already waits for an answer."),
1223
+ (409, "door_closed", "Knock: the listing has no open door."),
1224
+ (410, "listing_withdrawn", "Knock: the directory says the listing was withdrawn."),
1225
+ (413, "too_large", "The headers or the body (over 1 MiB) are too large."),
1226
+ (
1227
+ 422,
1228
+ "listing_invalid",
1229
+ "Knock: the listing the directory served doesn't verify (signature, canonical "
1230
+ "form, schema) or its door key is unusable. Also `listing_mismatch` (a listing "
1231
+ "for another name or directory) and `listing_rolled_back` (older than one this "
1232
+ "daemon has seen).",
1233
+ ),
1234
+ (429, "too_many_knocks", "Knock: 8 knocks (`knock.max_outgoing`) already wait."),
1235
+ (
1236
+ 429,
1237
+ "rate_limited",
1238
+ "Too many requests (knocks: one per door in 10 minutes, `knock.per_hour`).",
1239
+ ),
1240
+ (500, "internal", "An unexpected failure; the daemon logs it."),
1241
+ (502, "relay", "The relay reported an error."),
1242
+ (
1243
+ 502,
1244
+ "directory",
1245
+ "Publish: the directory refused the listing with a code of its own. Knock: it "
1246
+ "didn't serve the listing.",
1247
+ ),
1248
+ (
1249
+ 503,
1250
+ "busy",
1251
+ "A connection whose request line and headers hadn't arrived was closed to make "
1252
+ "room for a newer one (past 32 such connections, 8 per OS user in the "
1253
+ "separate-OS-user mode), too many requests with one token are in progress (48 "
1254
+ "with the agent token, 16 with the human token), or one OS user holds 16 event "
1255
+ "streams (separate-OS-user mode). Deliveries: `429` when 4 polls of the token "
1256
+ "wait already.",
1257
+ ),
1258
+ )
1259
+
1260
+
1261
+ def _str(body: dict[str, Any], key: str, default: str | None = None) -> str:
1262
+ value = body.get(key, default)
1263
+ if not isinstance(value, str):
1264
+ raise ApiError(400, "bad_request", f"{key} must be a string")
1265
+ return value
1266
+
1267
+
1268
+ def _int(body: dict[str, Any], key: str, default: int) -> int:
1269
+ value = body.get(key, default)
1270
+ if not isinstance(value, int) or isinstance(value, bool):
1271
+ raise ApiError(400, "bad_request", f"{key} must be an integer")
1272
+ return value
1273
+
1274
+
1275
+ def _opt_str(body: dict[str, Any], key: str) -> str | None:
1276
+ value = body.get(key)
1277
+ if value is not None and not isinstance(value, str):
1278
+ raise ApiError(400, "bad_request", f"{key} must be a string")
1279
+ return value
1280
+
1281
+
1282
+ def _opt_bool(body: dict[str, Any], key: str) -> bool | None:
1283
+ value = body.get(key)
1284
+ if value is not None and not isinstance(value, bool):
1285
+ raise ApiError(400, "bad_request", f"{key} must be a boolean")
1286
+ return value
1287
+
1288
+
1289
+ def _query_int(p: dict[str, str], key: str) -> int:
1290
+ raw = p.get(key, "0") or "0"
1291
+ if not raw.isdigit():
1292
+ raise ApiError(400, "bad_request", f"{key} must be a non-negative integer")
1293
+ return int(raw)
1294
+
1295
+
1296
+ class ApiServer:
1297
+ """Serves the control API for one :class:`Engine`."""
1298
+
1299
+ def __init__(
1300
+ self, daemon: Engine, gate: PeerGate | None = None, agent: AgentApi | None = None
1301
+ ) -> None:
1302
+ self.daemon = daemon
1303
+ self.agent = agent or AgentApi(daemon)
1304
+ # Peer-credential checks; a no-op unless isolation.enabled.
1305
+ self.gate = gate if gate is not None else PeerGate.from_config(daemon.config)
1306
+ data_dir = daemon.config.data_dir
1307
+ # Created (0600) on first start; only their hashes stay in memory.
1308
+ self._tokens = {
1309
+ "agent": TokenDigest.of_file(data_dir / API_TOKEN_FILE),
1310
+ "human": TokenDigest.of_file(data_dir / HUMAN_TOKEN_FILE),
1311
+ }
1312
+ self._server: asyncio.Server | None = None
1313
+ self._connections = 0
1314
+ self._streams: set[SseStream] = set()
1315
+ self._stopping = asyncio.Event() # long polls answer at once
1316
+ self._polls: dict[str, int] = {} # long polls waiting, per token scope
1317
+ self._agent_requests = 0 # agent-token requests in progress
1318
+ self._human_requests = 0 # human-token requests in progress
1319
+ self._pending: list[Pending] = [] # still sending, oldest first
1320
+ self._idle = asyncio.Event()
1321
+ self._idle.set()
1322
+ self._port: int | None = None
1323
+ self.address: str | Path | None = None
1324
+ self.routes = [
1325
+ Route(spec.method, spec.path, getattr(self, spec.handler), human=spec.human)
1326
+ for spec in ROUTE_SPECS
1327
+ ]
1328
+
1329
+ # -- handlers ----------------------------------------------------------
1330
+
1331
+ # Routes either token may call go to AgentApi for the agent token, so
1332
+ # the socket and the embedded SDK share one implementation. The human
1333
+ # token gets the fuller view where there is one.
1334
+
1335
+ async def _status(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1336
+ if p.get(SCOPE) != "human":
1337
+ return await self.agent.status()
1338
+ return self.daemon.status()
1339
+
1340
+ async def _list_groups(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1341
+ if p.get(SCOPE) == "human":
1342
+ return {"groups": self.daemon.list_groups()}
1343
+ return await self.agent.list_groups()
1344
+
1345
+ async def _group_info(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1346
+ if p.get(SCOPE) == "human":
1347
+ return self.daemon.group_info(p["gid"])
1348
+ return await self.agent.group_info(p["gid"])
1349
+
1350
+ async def _list_approvals(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1351
+ if p.get(SCOPE) != "human":
1352
+ return await self.agent.approvals()
1353
+ await self.daemon.expire_held()
1354
+ return {"approvals": self.daemon.approvals(), "messages": self.daemon.held_messages()}
1355
+
1356
+ async def _held_message(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1357
+ await self.daemon.expire_held()
1358
+ return self.daemon.held_message(p["hid"])
1359
+
1360
+ async def _approve_message(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1361
+ await self.daemon.expire_held()
1362
+ return self.daemon.approve_message(p["hid"])
1363
+
1364
+ async def _reject_message(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1365
+ await self.daemon.expire_held()
1366
+ return await self.daemon.reject_message(p["hid"])
1367
+
1368
+ async def _remove(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1369
+ members = {m.identity for m in self.daemon.store.members(p["gid"])}
1370
+ instances = b.get("instances")
1371
+ if instances is not None:
1372
+ if not isinstance(instances, list) or not all(isinstance(i, str) for i in instances):
1373
+ raise ApiError(400, "bad_request", "instances must be a list of instance ids")
1374
+ if p["identity"] not in members:
1375
+ raise ApiError(404, "not_found", "no such member")
1376
+ self.daemon.remove_instances(p["gid"], p["identity"], instances)
1377
+ return {"status": "removing"}
1378
+ if p["identity"] not in members or p["identity"] == self.daemon.me:
1379
+ raise ApiError(404, "not_found", "no such other member")
1380
+ self.daemon.remove_member(p["gid"], p["identity"])
1381
+ return {"status": "removing"}
1382
+
1383
+ async def _safety(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1384
+ if p.get(SCOPE) == "human":
1385
+ return self.daemon.safety_settings(p["gid"])
1386
+ return await self.agent.safety(p["gid"])
1387
+
1388
+ async def _set_adapters(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1389
+ if "adapters" not in b:
1390
+ raise ApiError(400, "bad_request", "adapters is required (a list of names, or null)")
1391
+ names = b["adapters"]
1392
+ if names is not None and not isinstance(names, list):
1393
+ raise ApiError(400, "bad_request", "adapters must be a list of names, or null")
1394
+ return self.daemon.set_adapters(p["gid"], names)
1395
+
1396
+ async def _set_safety(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1397
+ changes = b.get("set")
1398
+ if not isinstance(changes, dict):
1399
+ raise ApiError(400, "bad_request", "set must be an object")
1400
+ sender = b.get("sender")
1401
+ if sender is not None and not isinstance(sender, str):
1402
+ raise ApiError(400, "bad_request", "sender must be a string")
1403
+ return self.daemon.update_safety(p["gid"], changes, sender)
1404
+
1405
+ async def _audit(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1406
+ after = _query_int(p, "after")
1407
+ group = p.get("group") or None
1408
+ return {
1409
+ "audit": self.daemon.store.audit(group, after),
1410
+ "suppressed": self.daemon.store.audit_suppressed(group),
1411
+ }
1412
+
1413
+ async def _transcript(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1414
+ rows = self.daemon.transcript(p["gid"], _query_int(p, "after"))
1415
+ return {"transcript": rows, "untrusted": True}
1416
+
1417
+ async def _create_group(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1418
+ name, purpose = _str(b, "name"), _str(b, "purpose", "")
1419
+ raw = b.get("policy", {})
1420
+ if not isinstance(raw, dict):
1421
+ raise ApiError(400, "bad_request", "policy must be an object")
1422
+ policy: dict[str, Any] = raw
1423
+ if p.get(SCOPE) == "human" and len(policy) > 0:
1424
+ return {"group_id": self.daemon.create_group(name, purpose, policy)}
1425
+ if set(policy) - {"require_verified"}:
1426
+ raise ApiError(
1427
+ 403, "human_only", "a group policy beyond require_verified needs the human token"
1428
+ )
1429
+ verified = policy.get("require_verified", False)
1430
+ if not isinstance(verified, bool):
1431
+ raise ApiError(400, "bad_request", "require_verified must be a boolean")
1432
+ return await self.agent.create_group(name, purpose, require_verified=verified)
1433
+
1434
+ async def _create_invite(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1435
+ """Either token, except a replica invite: provisioning a replica is
1436
+ the human's (ADR-005), so ``replica: true`` needs the human token
1437
+ and goes past :class:`AgentApi`, which has no such parameter."""
1438
+ name = b.get("inviter_name")
1439
+ if name is not None and not isinstance(name, str):
1440
+ raise ApiError(400, "bad_request", "inviter_name must be a string")
1441
+ replica = b.get("replica", False)
1442
+ if not isinstance(replica, bool):
1443
+ raise ApiError(400, "bad_request", "replica must be a boolean")
1444
+ instance = b.get("instance")
1445
+ if instance is not None and (not replica or not isinstance(instance, str)):
1446
+ raise ApiError(400, "bad_request", "instance is a replica invite's pin: a string")
1447
+ ttl_secs, max_uses = _int(b, "ttl_secs", 86_400), _int(b, "max_uses", 1)
1448
+ if not replica:
1449
+ return await self.agent.create_invite(
1450
+ p["gid"], ttl_secs=ttl_secs, max_uses=max_uses, inviter_name=name
1451
+ )
1452
+ if p.get(SCOPE) != "human":
1453
+ raise ApiError(403, "human_only", "a replica invite needs the human token")
1454
+ return await self.daemon.create_invite(
1455
+ p["gid"], ttl_secs, max_uses, name, replica=True, instance=instance
1456
+ )
1457
+
1458
+ async def _policy(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1459
+ """The human token changes any field. The agent token only turns
1460
+ ``require_verified`` on, which tightens the gate (SPEC 10.4)."""
1461
+ if p.get(SCOPE) == "human":
1462
+ return {"status": self.daemon.change_policy(p["gid"], b)}
1463
+ if set(b) != {"require_verified"}:
1464
+ raise ApiError(
1465
+ 403,
1466
+ "human_only",
1467
+ "changing the group policy needs the human token, "
1468
+ "except turning require_verified on",
1469
+ )
1470
+ value = b["require_verified"]
1471
+ if not isinstance(value, bool):
1472
+ raise ApiError(400, "bad_request", "require_verified must be a boolean")
1473
+ return await self.agent.set_policy(p["gid"], require_verified=value)
1474
+
1475
+ async def _role(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1476
+ return {"status": self.daemon.set_role(p["gid"], p["identity"], _str(b, "role"))}
1477
+
1478
+ async def _owner(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1479
+ keep = b.get("keep_admin", True)
1480
+ if not isinstance(keep, bool):
1481
+ raise ApiError(400, "bad_request", "keep_admin must be a boolean")
1482
+ status = self.daemon.transfer_ownership(p["gid"], _str(b, "identity"), keep_admin=keep)
1483
+ return {"status": status}
1484
+
1485
+ async def _floor(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1486
+ if "holder" not in b:
1487
+ raise ApiError(400, "bad_request", "holder is required (an identity or null)")
1488
+ holder = b["holder"]
1489
+ if holder is not None and not isinstance(holder, str):
1490
+ raise ApiError(400, "bad_request", "holder must be an identity or null")
1491
+ if p.get(SCOPE) == "human":
1492
+ return self.daemon.grant_floor(p["gid"], holder, origin="human")
1493
+ return await self.agent.grant_floor(p["gid"], holder)
1494
+
1495
+ async def _revoke(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1496
+ return await self.agent.revoke_invite(p["invite_id"])
1497
+
1498
+ async def _inspect(self, _p: dict[str, str], b: dict[str, Any]) -> Any:
1499
+ return await self.agent.inspect_invite(_str(b, "url"))
1500
+
1501
+ async def _accept(self, _p: dict[str, str], b: dict[str, Any]) -> Any:
1502
+ name = b.get("display_name")
1503
+ if name is not None and not isinstance(name, str):
1504
+ raise ApiError(400, "bad_request", "display_name must be a string")
1505
+ return await self.agent.accept_invite(_str(b, "url"), name)
1506
+
1507
+ async def _send(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1508
+ body = b.get("body")
1509
+ human = p.get(SCOPE) == "human"
1510
+ if body is None:
1511
+ body = a2a_text(p["gid"], _str(b, "text"), human)
1512
+ if not isinstance(body, dict):
1513
+ raise ApiError(400, "bad_request", "body must be an object")
1514
+ to = b.get("to", ["*"])
1515
+ if not isinstance(to, list) or not all(isinstance(x, str) for x in to):
1516
+ raise ApiError(400, "bad_request", "to must be a list of strings")
1517
+ kind = _str(b, "kind", "a2a.message")
1518
+ reply_to = b.get("reply_to")
1519
+ if reply_to is not None and (
1520
+ not isinstance(reply_to, list) or not all(isinstance(x, str) for x in reply_to)
1521
+ ):
1522
+ raise ApiError(400, "bad_request", "reply_to must be a list of msg_ids")
1523
+ addressee = b.get("addressee")
1524
+ if addressee is not None and (
1525
+ not isinstance(addressee, list) or not all(isinstance(x, str) for x in addressee)
1526
+ ):
1527
+ raise ApiError(400, "bad_request", "addressee must be a list of identities")
1528
+ instance = _opt_str(b, "instance")
1529
+ if not human:
1530
+ # SPEC 7.2/10.3: the agent token always sends as the agent, and
1531
+ # can't send the controls SPEC 7.3 reserves to humans.
1532
+ return await self.agent.send(
1533
+ p["gid"],
1534
+ body,
1535
+ to=to,
1536
+ kind=kind,
1537
+ reply_to=reply_to,
1538
+ addressee=addressee,
1539
+ instance=instance,
1540
+ )
1541
+ msg_id, status = self.daemon.queue_message(
1542
+ p["gid"],
1543
+ body,
1544
+ to=to,
1545
+ kind=kind,
1546
+ origin="human",
1547
+ reply_to=reply_to,
1548
+ addressee=addressee,
1549
+ instance=instance,
1550
+ )
1551
+ return {"msg_id": msg_id, "status": status}
1552
+
1553
+ async def _leave(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1554
+ return await self.agent.leave(p["gid"])
1555
+
1556
+ async def _approve(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1557
+ purpose = b.get("purpose")
1558
+ if purpose is not None and not isinstance(purpose, str):
1559
+ raise ApiError(400, "bad_request", "purpose must be a string")
1560
+ return self.daemon.approve(p["rid"], purpose)
1561
+
1562
+ async def _reject(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1563
+ return self.daemon.reject(p["rid"])
1564
+
1565
+ async def _verify(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1566
+ verified = b.get("verified", True)
1567
+ if not isinstance(verified, bool):
1568
+ raise ApiError(400, "bad_request", "verified must be a boolean")
1569
+ if "epoch" in b:
1570
+ # SPEC 11.8: the old answer for a group-wide code, never mapped.
1571
+ msg = "verify takes the run compared (`run`), not an epoch: compare again"
1572
+ raise ApiError(400, "bad_request", msg)
1573
+ run = b.get("run")
1574
+ if (run is None and verified) or (run is not None and not isinstance(run, str)):
1575
+ raise ApiError(400, "bad_request", "run must name the SAS run the human compared")
1576
+ self.daemon.mark_verified(p["gid"], p["identity"], verified, run)
1577
+ return {"verified": verified}
1578
+
1579
+ async def _sas_start(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1580
+ instance, acknowledge = b.get("instance"), b.get("acknowledge", False)
1581
+ if instance is not None and not isinstance(instance, str):
1582
+ raise ApiError(400, "bad_request", "instance must be an instance id")
1583
+ if not isinstance(acknowledge, bool):
1584
+ raise ApiError(400, "bad_request", "acknowledge must be a boolean")
1585
+ return self.daemon.sas_start(p["gid"], p["identity"], instance, acknowledge)
1586
+
1587
+ async def _sas_run(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1588
+ return self.daemon.sas_run(p["gid"], p["run"])
1589
+
1590
+ async def _sas_cancel(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1591
+ return self.daemon.sas_cancel(p["gid"], p["run"])
1592
+
1593
+ async def _sas_labels(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1594
+ mine, yours = b.get("self"), b.get("peer")
1595
+ if not isinstance(mine, str) or not isinstance(yours, str):
1596
+ raise ApiError(400, "bad_request", "self and peer are the labels the other human reads")
1597
+ return await self.daemon.sas_labels(p["gid"], p["run"], mine, yours)
1598
+
1599
+ async def _sas_peer_started(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1600
+ return self.daemon.sas_peer_started(p["gid"], p["run"])
1601
+
1602
+ async def _sas_mismatch(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1603
+ # Never refused for its body (SPEC 11.5): an `epoch` is ignored.
1604
+ run = b.get("run")
1605
+ return {
1606
+ "status": await self.daemon.sas_mismatch(
1607
+ p["gid"], run if isinstance(run, str) else None
1608
+ )
1609
+ }
1610
+
1611
+ async def _listing_preview(self, _p: dict[str, str], b: dict[str, Any]) -> Any:
1612
+ return await self.daemon.listings.preview(b)
1613
+
1614
+ async def _listing_publish(self, _p: dict[str, str], b: dict[str, Any]) -> Any:
1615
+ return await self.daemon.listings.publish(b)
1616
+
1617
+ async def _listing_withdraw(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1618
+ force = b.get("force", False)
1619
+ if not isinstance(force, bool):
1620
+ raise ApiError(400, "bad_request", "force must be a boolean")
1621
+ if not force:
1622
+ return await self.agent.withdraw_listing()
1623
+ return await self.daemon.listings.withdraw(force=True, human=p.get(SCOPE) == "human")
1624
+
1625
+ async def _listing_status(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1626
+ info = await self.agent.listing_status()
1627
+ if p.get(SCOPE) == "human" and info["listing"] is not None:
1628
+ # Who knocks is the human's to know (docs/discovery.md).
1629
+ info["listing"]["pending_knocks"] = self.daemon.knocks.pending()
1630
+ info["listing"]["knocks_dropped"] = self.daemon.knocks.dropped
1631
+ return info
1632
+
1633
+ async def _knock_inspect(self, _p: dict[str, str], b: dict[str, Any]) -> Any:
1634
+ return await self.agent.knock_inspect(
1635
+ _str(b, "listing"), domain_check=_opt_bool(b, "domain_check")
1636
+ )
1637
+
1638
+ async def _knock(self, p: dict[str, str], b: dict[str, Any]) -> Any:
1639
+ args: dict[str, Any] = {
1640
+ "listing_sha256": _opt_str(b, "listing_sha256"),
1641
+ "display_name": _opt_str(b, "display_name"),
1642
+ "note": _opt_str(b, "note"),
1643
+ "domain_check": _opt_bool(b, "domain_check"),
1644
+ }
1645
+ if p.get(SCOPE) == "human":
1646
+ return await self.daemon.knocks.knock(_str(b, "listing"), human=True, **args)
1647
+ return await self.agent.knock(_str(b, "listing"), **args)
1648
+
1649
+ async def _knocks(self, _p: dict[str, str], _b: dict[str, Any]) -> Any:
1650
+ return await self.agent.knocks()
1651
+
1652
+ async def _events(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1653
+ """Events as JSON (``after``), or, with ``Accept: text/event-stream``,
1654
+ the SSE feed of messages and events (``Last-Event-ID`` resumes)."""
1655
+ group = p.get("group") or None
1656
+ if "text/event-stream" not in p.get(ACCEPT, ""):
1657
+ after = int(p.get("after", "0") or 0)
1658
+ if p.get(SCOPE) != "human":
1659
+ return await self.agent.events(after, group)
1660
+ store = self.daemon.store
1661
+ rows = store.events(after, group_id=group)
1662
+ if group is None and rows:
1663
+ # Read: retention may drop the human's own events now.
1664
+ store.events_read(int(rows[-1]["seq"]))
1665
+ purged, _ = store.events_purged()
1666
+ return {"events": rows, "gap": after < purged, "oldest_available": purged + 1}
1667
+ hub = self.daemon.adapters.get("sse")
1668
+ if not isinstance(hub, SseAdapter):
1669
+ raise ApiError(409, "no_sse_adapter", 'add "sse" to daemon.adapters')
1670
+ if group is not None and self.daemon.store.group(group) is None:
1671
+ raise ApiError(404, "not_found", "no such group")
1672
+ peer = self.gate.peer_uid()
1673
+ if peer is not None and sum(s.peer == peer for s in self._streams) >= (
1674
+ MAX_STREAMS_PER_PEER
1675
+ ):
1676
+ raise ApiError(503, "busy", "too many event streams from this user")
1677
+ return SseStream(hub, hub.subscribe(group, p.get(LAST_EVENT_ID)), peer)
1678
+
1679
+ def _queue(self) -> DeliveryQueue:
1680
+ queue = self.daemon.adapters.get("mcp")
1681
+ if not isinstance(queue, DeliveryQueue):
1682
+ raise ApiError(409, "no_deliveries", 'add "mcp" to daemon.adapters')
1683
+ return queue
1684
+
1685
+ async def _deliveries(self, p: dict[str, str], _b: dict[str, Any]) -> Any:
1686
+ addressed = p.get("addressed_only", "0") or "0"
1687
+ if addressed not in ("0", "1"):
1688
+ raise ApiError(400, "bad_request", "addressed_only must be 0 or 1")
1689
+ timeout = _query_int(p, "timeout")
1690
+ if timeout > MAX_POLL_SECS:
1691
+ raise ApiError(400, "bad_request", f"timeout must be 0..{MAX_POLL_SECS}")
1692
+ queue, scope = self._queue(), p.get(SCOPE, "agent")
1693
+ if self._polls.get(scope, 0) >= MAX_POLLS:
1694
+ msg = f"{MAX_POLLS} polls of this token wait already"
1695
+ raise ApiError(429, "busy", msg)
1696
+ self._polls[scope] = self._polls.get(scope, 0) + 1
1697
+
1698
+ def done() -> None:
1699
+ self._polls[scope] -= 1
1700
+
1701
+ return DeliveryPoll(
1702
+ queue,
1703
+ self._stopping,
1704
+ done,
1705
+ group=p.get("group") or None,
1706
+ addressed_only=addressed == "1",
1707
+ wait_secs=float(timeout),
1708
+ )
1709
+
1710
+ async def _deliveries_release(self, _p: dict[str, str], b: dict[str, Any]) -> Any:
1711
+ queue = self._queue()
1712
+ lease = _str(b, "lease")
1713
+ ids = b.get("ids")
1714
+ if not isinstance(ids, list) or not all(isinstance(x, str) and x.isdigit() for x in ids):
1715
+ raise ApiError(400, "bad_request", "ids must be a list of delivery ids")
1716
+ queue.release(lease, [int(x) for x in ids])
1717
+ return {"released": True}
1718
+
1719
+ async def _deliveries_ack(self, _p: dict[str, str], b: dict[str, Any]) -> Any:
1720
+ queue = self._queue()
1721
+ lease = _str(b, "lease")
1722
+ ids = b.get("ids")
1723
+ if not isinstance(ids, list) or not all(isinstance(x, str) and x.isdigit() for x in ids):
1724
+ raise ApiError(400, "bad_request", "ids must be a list of delivery ids")
1725
+ done = {str(d) for d in queue.ack(lease, [int(x) for x in ids])}
1726
+ return {"acked": len(done), "refused": [x for x in ids if x not in done]}
1727
+
1728
+ # -- HTTP --------------------------------------------------------------
1729
+
1730
+ def _authorize(self, headers: dict[str, str], human: bool) -> str:
1731
+ """Check the bearer token; returns the scope (``agent`` or ``human``)."""
1732
+ bearer_header = headers.get("authorization", "")
1733
+ scheme, _, token = bearer_header.partition(" ")
1734
+ if scheme.lower() != "bearer" or not token:
1735
+ raise ApiError(401, "auth_failed", "missing bearer token")
1736
+ is_human = self._tokens["human"].matches(token)
1737
+ is_agent = self._tokens["agent"].matches(token)
1738
+ if not (is_human or is_agent):
1739
+ raise ApiError(401, "auth_failed", "invalid token")
1740
+ if is_human and not self.gate.human_allowed():
1741
+ raise ApiError(403, "peer_refused", "the human token is not accepted from this user")
1742
+ if human and not is_human:
1743
+ raise ApiError(403, "human_only", "this operation needs the human token")
1744
+ return "human" if is_human else "agent"
1745
+
1746
+ async def handle(
1747
+ self,
1748
+ method: str,
1749
+ target: str,
1750
+ headers: dict[str, str],
1751
+ body: bytes,
1752
+ conn: Connection | None = None,
1753
+ ) -> tuple[int, Any]:
1754
+ route, args = self._route(method, target, headers, conn)
1755
+ return await self._run(route, args, body)
1756
+
1757
+ def _route(
1758
+ self, method: str, target: str, headers: dict[str, str], conn: Connection | None
1759
+ ) -> tuple[Route, dict[str, str]]:
1760
+ """Match the request and check its token, before its body is read:
1761
+ the route and the handler's parameters, or an ``ApiError``. Counts it
1762
+ toward its token's cap when ``conn`` is given."""
1763
+ path, _, query = target.partition("?")
1764
+ params = dict(
1765
+ (k, v) for k, _, v in (part.partition("=") for part in query.split("&") if part)
1766
+ )
1767
+ allowed = False
1768
+ for route in self.routes:
1769
+ m = route.regex.match(path)
1770
+ if m is None:
1771
+ continue
1772
+ allowed = True
1773
+ if route.method != method:
1774
+ continue
1775
+ scope = self._authorize(headers, route.human)
1776
+ if scope == "agent" and conn is not None:
1777
+ if self._agent_requests >= MAX_AGENT_REQUESTS:
1778
+ raise ApiError(503, "busy", "too many agent requests")
1779
+ self._agent_requests += 1
1780
+ conn.agent = True
1781
+ elif scope == "human" and conn is not None:
1782
+ if self._human_requests >= MAX_HUMAN_REQUESTS:
1783
+ raise ApiError(503, "busy", "too many human requests")
1784
+ self._human_requests += 1
1785
+ conn.human = True
1786
+ extra = {
1787
+ SCOPE: scope,
1788
+ ACCEPT: headers.get("accept", ""),
1789
+ LAST_EVENT_ID: headers.get("last-event-id", ""),
1790
+ }
1791
+ groups = {k: str(v) for k, v in m.groupdict().items()} # every group is required
1792
+ return route, {**params, **groups, **extra}
1793
+ if allowed:
1794
+ raise ApiError(405, "method_not_allowed", "method not allowed")
1795
+ raise ApiError(404, "not_found", "no such endpoint")
1796
+
1797
+ @staticmethod
1798
+ async def _run(route: Route, args: dict[str, str], body: bytes) -> tuple[int, Any]:
1799
+ try:
1800
+ data = json.loads(body) if body else {}
1801
+ except ValueError:
1802
+ raise ApiError(400, "bad_request", "body is not JSON") from None
1803
+ if not isinstance(data, dict):
1804
+ raise ApiError(400, "bad_request", "body must be a JSON object")
1805
+ return 200, await route.handler(args, data)
1806
+
1807
+ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
1808
+ if isinstance(self.address, Path):
1809
+ # Used, so /tmp cleaners don't age it out (sockets.py).
1810
+ with contextlib.suppress(OSError):
1811
+ os.utime(self.address)
1812
+ try:
1813
+ await self._serve_one(reader, writer)
1814
+ finally:
1815
+ writer.close() # also when the handler is cancelled mid-request
1816
+
1817
+ async def _serve_one(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
1818
+ if not self.gate.admit(writer):
1819
+ await self._reply(writer, 403, {"code": "peer_refused", "message": "user refused"})
1820
+ return
1821
+ pending = self._admit(writer)
1822
+ self._connections += 1
1823
+ self._idle.clear()
1824
+ conn = Connection()
1825
+ try:
1826
+ status, payload = await self._request(reader, conn, pending)
1827
+ if isinstance(payload, DeliveryPoll):
1828
+ answer = await payload.run(reader)
1829
+ if answer is None:
1830
+ return
1831
+ payload = answer
1832
+ if isinstance(payload, SseStream):
1833
+ self._streams.add(payload)
1834
+ try:
1835
+ await payload.run(reader, writer)
1836
+ finally:
1837
+ self._streams.discard(payload)
1838
+ return
1839
+ await self._reply(writer, status, payload)
1840
+ finally:
1841
+ if conn.agent:
1842
+ self._agent_requests -= 1
1843
+ if conn.human:
1844
+ self._human_requests -= 1
1845
+ self._unpend(pending)
1846
+ self._connections -= 1
1847
+ if self._connections == 0:
1848
+ self._idle.set()
1849
+
1850
+ def _admit(self, writer: asyncio.StreamWriter) -> Pending:
1851
+ """Count a new connection as pending until its request line and
1852
+ headers have arrived. Past a limit (``MAX_PENDING``), close one that
1853
+ is still pending first: of the same peer at its limit, else of the
1854
+ peer with the most."""
1855
+ peer = self.gate.peer_uid()
1856
+ mine = [p for p in self._pending if p.peer == peer]
1857
+ victim = None
1858
+ if peer is not None and len(mine) >= MAX_PENDING_PER_PEER:
1859
+ victim = self._victim(mine, hard=len(mine) >= MAX_PENDING_PER_PEER_HARD)
1860
+ if victim is None and len(self._pending) >= MAX_PENDING:
1861
+ counts = Counter(p.peer for p in self._pending)
1862
+ most = max(counts.values())
1863
+ heaviest = next(p.peer for p in self._pending if counts[p.peer] == most)
1864
+ victim = self._victim(
1865
+ [p for p in self._pending if p.peer == heaviest],
1866
+ hard=len(self._pending) >= MAX_PENDING_HARD,
1867
+ )
1868
+ if victim is not None:
1869
+ self._evict(victim)
1870
+ pending = Pending(peer, asyncio.current_task(), writer, asyncio.get_running_loop().time())
1871
+ self._pending.append(pending)
1872
+ return pending
1873
+
1874
+ @staticmethod
1875
+ def _victim(pool: list[Pending], hard: bool) -> Pending | None:
1876
+ """The one of ``pool`` (oldest first) to close. Below a hard limit:
1877
+ among those pending for ``EVICT_AFTER``, one that has sent nothing,
1878
+ else the oldest; ``None`` when none has waited that long, and the
1879
+ new connection is let in over the limit. Past a hard limit, waiting
1880
+ doesn't protect: the oldest that sent part of a request and stalled
1881
+ goes first, before any that sent nothing, since one of those may be
1882
+ a whole request that just arrived and isn't read yet."""
1883
+ if hard:
1884
+ stalled = [p for p in pool if p.started]
1885
+ return (stalled or pool)[0]
1886
+ now = asyncio.get_running_loop().time()
1887
+ pool = [p for p in pool if now - p.since >= EVICT_AFTER]
1888
+ quiet = [p for p in pool if not p.started]
1889
+ return (quiet or pool or [None])[0]
1890
+
1891
+ def _unpend(self, pending: Pending) -> None:
1892
+ with contextlib.suppress(ValueError):
1893
+ self._pending.remove(pending)
1894
+
1895
+ def _evict(self, pending: Pending) -> None:
1896
+ """Close a connection that hasn't sent its request (``busy``)."""
1897
+ self._unpend(pending)
1898
+ LOG.info("api_connection_evicted")
1899
+ data = json.dumps({"code": "busy", "message": "too many unfinished requests"}).encode()
1900
+ pending.writer.write(
1901
+ f"HTTP/1.1 503 {STATUS_TEXT[503]}\r\ncontent-type: application/json\r\n"
1902
+ f"content-length: {len(data)}\r\nconnection: close\r\n\r\n".encode()
1903
+ + data
1904
+ )
1905
+ if pending.task is not None:
1906
+ pending.task.cancel() # its handler closes the connection
1907
+
1908
+ def _check_origin(self, headers: dict[str, str]) -> None:
1909
+ """Loopback TCP: no browser requests (no ``Origin``), and a ``Host``
1910
+ naming this listener only (DNS rebinding). Defence in depth: the
1911
+ bearer token already stops both."""
1912
+ if self._port is None:
1913
+ return
1914
+ if "origin" in headers:
1915
+ raise ApiError(403, "forbidden", "browser requests are not accepted")
1916
+ allowed = {f"127.0.0.1:{self._port}", f"localhost:{self._port}"}
1917
+ if headers.get("host") not in allowed:
1918
+ raise ApiError(403, "forbidden", "unexpected Host header")
1919
+
1920
+ async def _request(
1921
+ self,
1922
+ reader: asyncio.StreamReader,
1923
+ conn: Connection | None = None,
1924
+ pending: Pending | None = None,
1925
+ ) -> tuple[int, Any]:
1926
+ status, payload = 500, {"code": "internal", "message": "internal error"}
1927
+ try:
1928
+ async with asyncio.timeout(READ_TIMEOUT):
1929
+ try:
1930
+ method, target, headers, length = await self._read_head(reader, pending)
1931
+ finally:
1932
+ if pending is not None:
1933
+ self._unpend(pending) # its headers arrived: it never gives way
1934
+ try:
1935
+ self._check_origin(headers)
1936
+ route, args = self._route(method, target, headers, conn)
1937
+ except ApiError:
1938
+ if length and pending is not None:
1939
+ # Refused before its body was read: read the body
1940
+ # (so the answer isn't lost to a reset) as an
1941
+ # unfinished connection again, which may give way.
1942
+ self._pending.append(pending)
1943
+ await reader.readexactly(length)
1944
+ raise
1945
+ body = await reader.readexactly(length) if length else b""
1946
+ status, payload = await self._run(route, args, body)
1947
+ except TimeoutError:
1948
+ status, payload = 408, {"code": "timeout", "message": "request not received in time"}
1949
+ except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, ValueError):
1950
+ status, payload = 400, {"code": "bad_request", "message": "malformed request"}
1951
+ except Exception as e:
1952
+ reply = error_reply(e)
1953
+ if reply is None:
1954
+ LOG.exception("api_failed")
1955
+ else:
1956
+ status, code, message = reply
1957
+ payload = {"code": code, "message": message}
1958
+ return status, payload
1959
+
1960
+ @staticmethod
1961
+ async def _read_head(
1962
+ reader: asyncio.StreamReader, pending: Pending | None
1963
+ ) -> tuple[str, str, dict[str, str], int]:
1964
+ """The request line, the headers and the body's length."""
1965
+ async with asyncio.timeout(HEADER_TIMEOUT):
1966
+ head = await reader.readexactly(1)
1967
+ if pending is not None:
1968
+ pending.started = True
1969
+ head += await reader.readuntil(b"\r\n\r\n")
1970
+ if len(head) > MAX_HEADER:
1971
+ raise ApiError(413, "too_large", "headers too large")
1972
+ lines = head.decode("latin-1").split("\r\n")
1973
+ method, target, _version = lines[0].split(" ", 2)
1974
+ headers = {}
1975
+ for line in lines[1:]:
1976
+ if ":" in line:
1977
+ k, _, v = line.partition(":")
1978
+ headers[k.strip().lower()] = v.strip()
1979
+ length = int(headers.get("content-length", "0") or 0)
1980
+ if length < 0 or length > MAX_BODY:
1981
+ raise ApiError(413, "too_large", "body too large")
1982
+ return method, target, headers, length
1983
+
1984
+ @staticmethod
1985
+ async def _reply(writer: asyncio.StreamWriter, status: int, payload: Any) -> None:
1986
+ data = json.dumps(payload).encode()
1987
+ writer.write(
1988
+ f"HTTP/1.1 {status} {STATUS_TEXT.get(status, 'Error')}\r\n"
1989
+ f"content-type: application/json\r\ncontent-length: {len(data)}\r\n"
1990
+ "connection: close\r\n\r\n".encode()
1991
+ + data
1992
+ )
1993
+ with contextlib.suppress(ConnectionError):
1994
+ await writer.drain()
1995
+ writer.close()
1996
+
1997
+ async def start(self) -> None:
1998
+ cfg = self.daemon.config
1999
+ if cfg.api_tcp_port:
2000
+ port = cfg.api_tcp_port
2001
+ self._server = await asyncio.start_server(
2002
+ self._serve, "127.0.0.1", port, limit=MAX_HEADER
2003
+ )
2004
+ sock = self._server.sockets[0].getsockname()
2005
+ self._port = int(sock[1])
2006
+ self.address = f"http://127.0.0.1:{sock[1]}"
2007
+ else:
2008
+ # A configured path over the platform's limit binds at a short
2009
+ # private path the CLI and SDK derive the same way (sockets.py).
2010
+ path = bind_path(cfg.socket_path)
2011
+ if path != cfg.socket_path:
2012
+ prepare_bind_dir(path)
2013
+ with contextlib.suppress(FileNotFoundError):
2014
+ if not stat.S_ISSOCK(path.lstat().st_mode):
2015
+ msg = f"{path} exists and is not a socket; refusing to replace it"
2016
+ raise FileExistsError(msg)
2017
+ path.unlink()
2018
+ # Bind under a 0077 umask so the socket is never connectable by
2019
+ # others, not even between bind and chmod.
2020
+ old = os.umask(0o077)
2021
+ try:
2022
+ self._server = await asyncio.start_unix_server(self._serve, path, limit=MAX_HEADER)
2023
+ finally:
2024
+ os.umask(old)
2025
+ path.chmod(0o600)
2026
+ self.gate.publish(path) # isolation: 0660 for the agent group
2027
+ self.address = path
2028
+ LOG.info("api_listening", transport="tcp" if cfg.api_tcp_port else "unix")
2029
+
2030
+ async def stop(self) -> None:
2031
+ self._stopping.set()
2032
+ for stream in list(self._streams):
2033
+ stream.stop()
2034
+ if self._server is not None:
2035
+ self._server.close()
2036
+ close_clients = getattr(self._server, "close_clients", None) # Python 3.13+
2037
+ if close_clients is not None:
2038
+ close_clients() # connections accepted but not yet served
2039
+ await self._idle.wait() # every connection handler has closed its transport
2040
+ await self._server.wait_closed()
2041
+ self._server = None
2042
+ if isinstance(self.address, Path):
2043
+ self.address.unlink(missing_ok=True)