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,1307 @@
1
+ """The owner's listing: publish, door renewal, withdraw and status
2
+ (SPEC 6.6.1, 13.2; docs/discovery.md).
3
+
4
+ One listing per runtime, for one (directory, name). Publishing is the
5
+ human's decision (the local API's human-only routes call :meth:`preview`
6
+ and :meth:`publish`); everything after it runs without anyone:
7
+
8
+ * **Doors.** A door is an HPKE key pair the core generates, registered at
9
+ the relay for this instance's mailbox. Its private key is kept in the
10
+ daemon's encrypted database (and in memory as an ``agentconnect_core.Door``,
11
+ which zeroizes it when dropped) and deleted an hour after the door
12
+ expires, once every knock it could have received has lapsed. A door the
13
+ relay may hold is never forgotten: until the relay confirms it closed
14
+ (``door_not_open`` or success), its row stays, ``closing``, without its key.
15
+ * **Renewal.** At half a door's lifetime the discovery worker registers a
16
+ fresh door and publishes the listing again, re-signed by the core with
17
+ only ``door``, ``seq`` and ``issued_at`` changed (``Listing.renew``), so
18
+ a renewal can't change what the human approved. The old door stays
19
+ registered until it expires. A renewal that keeps failing for an hour,
20
+ or a door that expires before a renewal lands, raises ``listing_stale``.
21
+ Only ``token_revoked``, ``bad_token`` and ``name_held`` stop renewing.
22
+ * **Withdraw.** Either token may withdraw (it only reduces exposure, SPEC
23
+ 13.2.3): every door is closed at the relay first, the keys are deleted at
24
+ once, then the signed withdrawal goes to the directory.
25
+
26
+ A failed publish puts the listing back exactly as it was before the attempt
27
+ (``restore_state``): a withdrawn listing stays withdrawn.
28
+
29
+ Every step is recorded before the next one starts, so a crash anywhere
30
+ resumes from the database: a door ``registering`` is registered again
31
+ (the relay's "already exists" counts as done), signed bytes in ``pending``
32
+ are posted again byte for byte (idempotent at the directory).
33
+
34
+ ``seq`` is ``max(max_signed_seq + 1, now)`` and ``max_signed_seq`` is never
35
+ lowered, so a ``seq`` is never reused, not even after a lost answer.
36
+
37
+ What a directory answers is untrusted: its codes are mapped onto the set
38
+ this daemon knows (``agentconnect.directory_client``), its owner state onto
39
+ the SPEC's states, and its ``detail`` is cleaned before a person sees it.
40
+ Logs carry event types and reason codes only: no directory, name, door id
41
+ or domain (SPEC 8.3, docs/discovery.md).
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import asyncio
47
+ import hashlib
48
+ import json
49
+ from collections.abc import Callable
50
+ from dataclasses import dataclass
51
+ from typing import Any
52
+
53
+ import agentconnect_core as ac
54
+ from agentconnect import log
55
+ from agentconnect.backoff import Backoff
56
+ from agentconnect.config import DirectoryConfig
57
+ from agentconnect.directory_client import (
58
+ DirectoryClient,
59
+ DirectoryError,
60
+ clean_detail,
61
+ owner_state,
62
+ )
63
+ from agentconnect.ownerfile import Fetcher, OwnerFileFetcher
64
+ from agentconnect.relay import RelayError, b64
65
+ from agentconnect.runtime.engine import Engine, canonical
66
+ from agentconnect.runtime.errors import ApiError
67
+ from agentconnect.store import Store
68
+
69
+ LOG = log.get("agentconnect.listing")
70
+
71
+ TOKEN_WARNING_SECS = 14 * 86_400 # SPEC 13.2.1: warn 14 days before the token expires
72
+ MIN_TOKEN_LEFT_SECS = 7200 # no renewal with less token life left
73
+ STALE_AFTER_SECS = 3600 # a renewal or withdrawal failing this long is reported
74
+ WITHDRAW_GIVE_UP_SECS = 7 * 86_400 # past every door's life: stop retrying the withdrawal
75
+ DOOR_WARNING_SECS = 86_400 # a door this close to expiry while a republish waits
76
+ RETRY_BASE_SECS = 60
77
+ RETRY_CAP_SECS = 3600
78
+ CLOSE_RETRY_SECS = 300
79
+ EXPIRY_SLACK_SECS = 3600 # a door the relay found too long is retried this much shorter
80
+ MAX_IDLE_PASSES = 3 # passes in a row with work due at once before the worker pauses
81
+
82
+ #: The card file's fields: the listing's text (``ListingContent``). An Agent
83
+ #: Card's other fields (its interfaces, signatures, extensions) are not text
84
+ #: the human writes; the core adds what a listing needs.
85
+ CONTENT_FIELDS = frozenset(
86
+ {
87
+ "name",
88
+ "description",
89
+ "version",
90
+ "provider",
91
+ "capabilities",
92
+ "defaultInputModes",
93
+ "defaultOutputModes",
94
+ "skills",
95
+ }
96
+ )
97
+ LIVE = frozenset({"publishing", "published", "stale"})
98
+ # States that hold the runtime's one listing (D2): another can't be published.
99
+ HOLDING = LIVE | {"withdrawing"}
100
+ #: ``withdrawn_locally``: a forced withdrawal; the directory may still list it.
101
+ WITHDRAWABLE = HOLDING | {"expired", "withdraw_failed", "withdrawn_locally"}
102
+ #: What ``force`` may end: a withdrawal the directory hasn't taken.
103
+ FORCEABLE = frozenset({"withdrawing", "withdraw_failed"})
104
+ PENDING_DOOR = frozenset({"registering", "registered"})
105
+ OPEN_DOOR = frozenset({"current", "retiring"})
106
+ #: SPEC 6.6.1/13.2: the refusals of a renewal that stop renewing. Any other
107
+ #: refusal is retried (with a fresh door and signature) and reported.
108
+ STOP_RENEWING = frozenset({"token_revoked", "bad_token", "name_held"})
109
+ #: Relay answers to a door registration that say the relay doesn't hold it.
110
+ NOT_REGISTERED = frozenset({"bad_request", "quota_exceeded"})
111
+ #: The directory's refusals (directory/README.md) and the status the local
112
+ #: API answers them with; any other refusal is ``502 directory``.
113
+ REFUSAL_STATUS = {
114
+ "name_held": 409,
115
+ "bad_token": 403,
116
+ "token_revoked": 403,
117
+ "unknown_key": 403,
118
+ "bad_signature": 403,
119
+ "wrong_identity": 403,
120
+ "owner_revoked": 403,
121
+ "wrong_relay": 400,
122
+ "wrong_directory": 400,
123
+ "bad_door": 400,
124
+ "stale_ts": 400,
125
+ "bad_listing": 400,
126
+ "unknown_directory": 400,
127
+ "too_large": 413,
128
+ }
129
+
130
+
131
+ def _warning(code: str, message: str) -> dict[str, str]:
132
+ return {"code": code, "message": message}
133
+
134
+
135
+ def _due(at: int | None, now: int) -> bool:
136
+ return at is None or at <= now
137
+
138
+
139
+ def _error(code: str, at: int) -> str:
140
+ return json.dumps({"code": code, "at": at})
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Draft:
145
+ """A checked publish request: what the human is shown and approves."""
146
+
147
+ entry: DirectoryConfig
148
+ name: str
149
+ token: bytes # the ListingToken, canonical
150
+ token_info: dict[str, Any]
151
+ content: bytes # the canonical ListingContent
152
+ owner_domain: str | None
153
+
154
+ @property
155
+ def sha256(self) -> str:
156
+ """What the human approves: everything that will be signed except the
157
+ door, ``seq`` and ``issued_at``, which publish chooses."""
158
+ approved = {
159
+ "content": json.loads(self.content),
160
+ "directory": self.entry.host,
161
+ "name": self.name,
162
+ "owner_domain": self.owner_domain,
163
+ "token": json.loads(self.token),
164
+ }
165
+ return hashlib.sha256(canonical(approved)).hexdigest()
166
+
167
+
168
+ class ListingManager:
169
+ """The runtime's listing and its doors (one :class:`Engine`)."""
170
+
171
+ def __init__(self, engine: Engine) -> None:
172
+ self._engine = engine
173
+ self._lock = asyncio.Lock() # one publish, withdraw or worker pass at a time
174
+ self._wake = asyncio.Event()
175
+ options = engine.options
176
+ self._fetcher: Fetcher = options.owner_fetcher or OwnerFileFetcher()
177
+ # door_id -> its key pair: what the owner opens knocks with.
178
+ self._doors: dict[str, ac.Door] = {}
179
+ # door_id -> (next close attempt, attempts) for doors the relay
180
+ # hasn't confirmed closed; after a restart they are tried at once.
181
+ self._close_retry: dict[str, tuple[int, int]] = {}
182
+ self._load_doors()
183
+
184
+ # -- plumbing -------------------------------------------------------------
185
+
186
+ @property
187
+ def _store(self) -> Store:
188
+ return self._engine.store
189
+
190
+ def _now(self) -> int:
191
+ return self._engine.now()
192
+
193
+ def _lifetime(self) -> int:
194
+ seam = self._engine.options.door_lifetime_secs
195
+ return seam if seam is not None else self._engine.config.listing_door_lifetime_secs
196
+
197
+ def _client(self, row: dict[str, Any]) -> DirectoryClient:
198
+ entry = self._engine.config.directory(str(row["directory"]))
199
+ if entry is None:
200
+ raise DirectoryError("unknown_directory", 400, "the directory is no longer configured")
201
+ return DirectoryClient(entry, transport=self._engine.options.directory_transport)
202
+
203
+ def _refresh(self, row: dict[str, Any]) -> dict[str, Any]:
204
+ fresh = self._store.listing(row["directory"], row["name"])
205
+ if fresh is None: # pragma: no cover - rows are never deleted
206
+ msg = "listing row vanished"
207
+ raise RuntimeError(msg)
208
+ return fresh
209
+
210
+ def _door_row(self, door_id: str) -> dict[str, Any] | None:
211
+ return next((d for d in self._store.doors() if d["door_id"] == door_id), None)
212
+
213
+ def _load_doors(self) -> None:
214
+ """The door keys from the database, once. A key that doesn't load
215
+ (the core checks the key pair) is dropped and its door closed by the
216
+ worker; it is logged without its id."""
217
+ for door_id, secret in self._store.door_secrets():
218
+ try:
219
+ door = ac.Door.from_secret_bytes(secret)
220
+ except (ac.AgentConnectError, ac.CoreError):
221
+ LOG.warning("door_key_unreadable")
222
+ self._store.drop_door_secret(door_id, "closing")
223
+ continue
224
+ self._doors[door_id] = door
225
+
226
+ def door(self, door_id: str) -> ac.Door | None:
227
+ """The key pair of one of this runtime's doors, while it is kept."""
228
+ return self._doors.get(door_id)
229
+
230
+ async def _commit(
231
+ self, effects: Callable[[], None], events: list[tuple[str, str, dict[str, Any]]]
232
+ ) -> None:
233
+ """``effects`` and the events they raise, in one transaction; the
234
+ adapter hears of the events once it commits. Each event's id comes
235
+ from its cause, so a repeat after a crash is the same event."""
236
+
237
+ async def body() -> str:
238
+ effects()
239
+ for kind, cause, data in events:
240
+ await self._engine.emit(None, kind, cause=cause, **data)
241
+ return ""
242
+
243
+ await self._engine._apply("listing", body)
244
+
245
+ # -- the human's publish ----------------------------------------------------
246
+
247
+ def _draft(self, body: dict[str, Any]) -> Draft:
248
+ directory, card, token = body.get("directory"), body.get("card"), body.get("token")
249
+ owner_domain = body.get("owner_domain")
250
+ if not isinstance(directory, str):
251
+ raise ApiError(400, "bad_request", "directory must be a string")
252
+ if not isinstance(card, dict) or not isinstance(token, dict):
253
+ raise ApiError(400, "bad_request", "card and token must be JSON objects")
254
+ if owner_domain is not None and not isinstance(owner_domain, str):
255
+ raise ApiError(400, "bad_request", "owner_domain must be a string")
256
+ entry = self._engine.config.directory(directory)
257
+ if entry is None:
258
+ msg = "that directory is not configured: add a [[directory]] table with its keys"
259
+ raise ApiError(400, "unknown_directory", msg)
260
+ now = self._now()
261
+ token_bytes = canonical(token)
262
+ try:
263
+ info = ac.verify_listing_token(token_bytes, entry.host, list(entry.keys), now)
264
+ except ac.AgentConnectError as e:
265
+ raise _token_error(e) from None
266
+ name = str(info["name"])
267
+ pinned = info["owner_domain"]
268
+ if owner_domain is not None and pinned is not None and owner_domain != pinned:
269
+ msg = f"the token pins the owner domain {pinned}"
270
+ raise ApiError(400, "bad_request", msg)
271
+ domain = pinned if pinned is not None else owner_domain
272
+ try:
273
+ ac.check_token_binding(token_bytes, entry.host, name, self._engine.me, domain)
274
+ except ac.AgentConnectError:
275
+ msg = (
276
+ "the token is for another identity: the operator issues it for the identity "
277
+ "`agentconnect status` shows"
278
+ )
279
+ raise ApiError(403, "token_not_for_identity", msg) from None
280
+ extra = sorted(set(card) - CONTENT_FIELDS)
281
+ if extra:
282
+ msg = f"the card file holds the listing's text only; remove: {', '.join(extra)}"
283
+ raise ApiError(400, "bad_card", msg)
284
+ try:
285
+ content = ac.canonical_listing_content(canonical(card))
286
+ # A dry run of the signature: the owner-domain and provider rules
287
+ # are checked now, before a door is registered.
288
+ ac.sign_listing_content(
289
+ content,
290
+ self._engine.identity,
291
+ directory=entry.host,
292
+ relay=self._engine.config.relay_host,
293
+ seq=1,
294
+ issued_at=now,
295
+ token_json=token_bytes,
296
+ owner_domain=domain,
297
+ )
298
+ except ac.AgentConnectError as e:
299
+ raise ApiError(400, "bad_card", str(e)) from None
300
+ return Draft(entry, name, token_bytes, info, content, domain)
301
+
302
+ def _single(self, draft: Draft) -> None:
303
+ """D2: one listing per runtime. Another one must be withdrawn first."""
304
+ for row in self._store.listings():
305
+ same = (row["directory"], row["name"]) == (draft.entry.host, draft.name)
306
+ if not same and row["state"] in HOLDING:
307
+ msg = "this runtime already has a listing; withdraw it first"
308
+ raise ApiError(409, "listing_exists", msg)
309
+
310
+ def _warnings(self, draft: Draft) -> list[dict[str, str]]:
311
+ warnings = []
312
+ groups = len([g for g in self._store.groups() if g.status == "active"])
313
+ if groups:
314
+ warnings.append(
315
+ _warning(
316
+ "identity_in_groups",
317
+ f"This identity is a member of {groups} group(s). A listed identity is "
318
+ "public: anyone can tell that a member with it, in any group, is the "
319
+ "listed agent. A dedicated identity for listing is safer.",
320
+ )
321
+ )
322
+ if int(draft.token_info["expires_at"]) - self._now() < TOKEN_WARNING_SECS:
323
+ warnings.append(
324
+ _warning(
325
+ "token_expires_soon",
326
+ "The listing token expires within 14 days; ask the operator for a new one.",
327
+ )
328
+ )
329
+ return warnings
330
+
331
+ async def _owner_file_warning(self, draft: Draft) -> dict[str, str] | None:
332
+ """Whether the owner domain already vouches for this identity in this
333
+ listing, from this daemon's own fetch of its owner file."""
334
+ domain = str(draft.owner_domain)
335
+ fetched = await self._fetcher.fetch(domain)
336
+ result = "failed"
337
+ if fetched.data is not None:
338
+ result = ac.owner_fetch_result(
339
+ fetched.data, self._engine.me, draft.entry.host, draft.name
340
+ )["result"]
341
+ if result == "vouches":
342
+ return None
343
+ if result == "failed":
344
+ return _warning(
345
+ "owner_file_unreachable",
346
+ f"{domain}/.well-known/agentconnect.json could not be fetched or read; until "
347
+ "it lists the entry below, the owner shows as not verified.",
348
+ )
349
+ return _warning(
350
+ "owner_file_missing_identity",
351
+ f"{domain}/.well-known/agentconnect.json doesn't vouch for this identity in this "
352
+ "listing; add the entry below.",
353
+ )
354
+
355
+ async def preview(self, body: dict[str, Any]) -> dict[str, Any]:
356
+ """What :meth:`publish` would sign, for the human to check: nothing
357
+ is registered, signed or sent (other than the owner-file fetch)."""
358
+ draft = self._draft(body)
359
+ self._single(draft)
360
+ warnings = self._warnings(draft)
361
+ entry = None
362
+ if draft.owner_domain is not None:
363
+ entry = {
364
+ "identity": self._engine.me,
365
+ "fingerprint": self._engine.identity.fingerprint,
366
+ "listings": [f"{draft.entry.host}/a/{draft.name}"],
367
+ }
368
+ warning = await self._owner_file_warning(draft)
369
+ if warning is not None:
370
+ warnings.append(warning)
371
+ info = draft.token_info
372
+ return {
373
+ "directory": draft.entry.host,
374
+ "name": draft.name,
375
+ "identity": self._engine.me,
376
+ "fingerprint": self._engine.identity.fingerprint,
377
+ "content": json.loads(draft.content),
378
+ "content_sha256": draft.sha256,
379
+ "owner_domain": draft.owner_domain,
380
+ "token": {
381
+ "token_id": b64(info["token_id"]),
382
+ "issued_at": int(info["issued_at"]),
383
+ "expires_at": int(info["expires_at"]),
384
+ "key_id": b64(info["key_id"]),
385
+ },
386
+ "owner_file_entry": entry,
387
+ "warnings": warnings,
388
+ }
389
+
390
+ async def publish(self, body: dict[str, Any]) -> dict[str, Any]:
391
+ """The human's publish: register a fresh door, sign, post. A
392
+ directory that doesn't answer is retried by the worker with the same
393
+ bytes (``retrying``); a refusal raises with the directory's code."""
394
+ draft = self._draft(body)
395
+ sha = body.get("content_sha256")
396
+ if not isinstance(sha, str):
397
+ raise ApiError(400, "bad_request", "content_sha256 must be a string")
398
+ if sha != draft.sha256:
399
+ msg = "the card, the token or the directory changed since it was shown; preview again"
400
+ raise ApiError(409, "card_changed", msg)
401
+ async with self._lock:
402
+ self._single(draft)
403
+ row = await self._begin(draft)
404
+ outcome = await self._round(row, interactive=True)
405
+ row = self._refresh(row)
406
+ self._wake.set()
407
+ doors = self._store.doors(row["directory"], row["name"])
408
+ door = next((d for d in reversed(doors) if d["state"] in ("current", *PENDING_DOOR)), None)
409
+ retrying = outcome == "retrying"
410
+ return {
411
+ "directory": row["directory"],
412
+ "name": row["name"],
413
+ "url": _url(row),
414
+ "seq": int(row["max_signed_seq"] if retrying else row["seq"]),
415
+ "state": row["state"],
416
+ "door_expires_at": None if door is None else int(door["expires_at"]),
417
+ "renews_at": row["next_renewal_at"],
418
+ "token_expires_at": int(row["token_expires_at"]),
419
+ "owner_domain": row["owner_domain"],
420
+ "owner_state": row["owner_state"],
421
+ "warnings": self._warnings(draft),
422
+ "retrying": retrying,
423
+ }
424
+
425
+ async def _begin(self, draft: Draft) -> dict[str, Any]:
426
+ """Record the approved text and token as ``publishing``, with the
427
+ state to go back to if this publish fails; a door still waiting from
428
+ an earlier attempt is closed."""
429
+ host, name, now = draft.entry.host, draft.name, self._now()
430
+ prev = self._store.listing(host, name)
431
+ stale_doors = [d for d in self._store.doors(host, name) if d["state"] in PENDING_DOOR]
432
+ token = draft.token.decode()
433
+ restore_state, restore_renewal_at = "refused", None
434
+ if prev is not None and prev["state"] == "publishing": # a republish over a retry
435
+ restore_state, restore_renewal_at = prev["restore_state"], prev["restore_renewal_at"]
436
+ elif prev is not None:
437
+ restore_state, restore_renewal_at = prev["state"], prev["next_renewal_at"]
438
+ row = {
439
+ **(prev or {}),
440
+ "directory": host,
441
+ "name": name,
442
+ "state": "publishing",
443
+ "content": draft.content.decode(),
444
+ "token": token,
445
+ "token_expires_at": int(draft.token_info["expires_at"]),
446
+ "owner_domain": draft.owner_domain,
447
+ "pending": None,
448
+ "pending_door": None,
449
+ "next_renewal_at": None,
450
+ "retry_at": None,
451
+ "attempts": 0,
452
+ "failing_since": None,
453
+ "last_error": None,
454
+ "token_warned": 0 if prev is None or prev["token"] != token else prev["token_warned"],
455
+ "restore_state": restore_state,
456
+ "restore_renewal_at": restore_renewal_at,
457
+ "created_at": now if prev is None else prev["created_at"],
458
+ "updated_at": now,
459
+ }
460
+ with self._store.tx():
461
+ self._store.put_listing(row)
462
+ for d in stale_doors:
463
+ await self._close_door(d)
464
+ return self._refresh(row)
465
+
466
+ # -- doors --------------------------------------------------------------------
467
+
468
+ def _pending_door(self, row: dict[str, Any]) -> dict[str, Any] | None:
469
+ if row["pending_door"] is not None:
470
+ door = self._door_row(str(row["pending_door"]))
471
+ if door is not None and str(door["door_id"]) in self._doors:
472
+ return door
473
+ # Signed for a door that is gone: sign again for a fresh one.
474
+ host, name = row["directory"], row["name"]
475
+ self._store.update_listing(host, name, pending=None, pending_door=None)
476
+ row["pending"] = row["pending_door"] = None
477
+ doors = self._store.doors(row["directory"], row["name"])
478
+ return next(
479
+ (d for d in doors if d["state"] in PENDING_DOOR and str(d["door_id"]) in self._doors),
480
+ None,
481
+ )
482
+
483
+ def _new_door(self, row: dict[str, Any], expires_at: int | None = None) -> dict[str, Any]:
484
+ """A fresh door, recorded (``registering``, with its key) before the
485
+ relay hears of it."""
486
+ now = self._now()
487
+ if expires_at is None:
488
+ expires_at = min(now + self._lifetime(), int(row["token_expires_at"]))
489
+ door = self._engine.client.generate_door(expires_at)
490
+ door_id = b64(door.id)
491
+ with self._store.tx():
492
+ self._store.add_door(
493
+ door_id=door_id,
494
+ directory=row["directory"],
495
+ name=row["name"],
496
+ secret=door.to_secret_bytes(),
497
+ expires_at=expires_at,
498
+ now=now,
499
+ )
500
+ self._doors[door_id] = door
501
+ found = self._door_row(door_id)
502
+ if found is None: # pragma: no cover - just inserted
503
+ msg = "door row vanished"
504
+ raise RuntimeError(msg)
505
+ return found
506
+
507
+ def _drop_door(self, door_id: str) -> None:
508
+ """Delete a door's key and row: the relay doesn't hold it, or it is over."""
509
+ self._doors.pop(door_id, None)
510
+ self._store.delete_door(door_id)
511
+
512
+ def _closing(self, door_id: str) -> None:
513
+ """The key goes at once; the row stays until the relay confirms."""
514
+ self._doors.pop(door_id, None)
515
+ self._store.drop_door_secret(door_id, "closing")
516
+
517
+ async def _close_door(self, door: dict[str, Any]) -> bool:
518
+ """Close a door at the relay. Its key is deleted at once, whatever the
519
+ relay says. Only success or ``door_not_open`` (unknown, expired or
520
+ closed already) counts as closed; any other answer (``auth_failed``
521
+ for a skewed clock, no answer) leaves it ``closing``, and the worker
522
+ tries again with a fresh signature."""
523
+ door_id = str(door["door_id"])
524
+ self._closing(door_id)
525
+ try:
526
+ await self._engine.rest.close_door(door_id)
527
+ closed = True
528
+ except RelayError as e:
529
+ closed = e.code == "door_not_open"
530
+ if closed:
531
+ self._store.delete_door(door_id)
532
+ self._close_retry.pop(door_id, None)
533
+ else:
534
+ # Tried again after a backoff (5 min doubling to an hour), not on
535
+ # every pass.
536
+ _, attempts = self._close_retry.get(door_id, (0, 0))
537
+ delay = min(RETRY_CAP_SECS, CLOSE_RETRY_SECS * 2 ** min(attempts, 16))
538
+ self._close_retry[door_id] = (self._now() + delay, attempts + 1)
539
+ return closed
540
+
541
+ def _close_due(self, door: dict[str, Any], now: int) -> bool:
542
+ """Whether a ``closing`` door's next close attempt is due."""
543
+ due, _ = self._close_retry.get(str(door["door_id"]), (now, 0))
544
+ return str(door["state"]) != "closing" or due <= now
545
+
546
+ async def _free_door_slot(self, keep: str, row: dict[str, Any]) -> None:
547
+ """The relay holds at most 4 open doors per mailbox: close the doors
548
+ no listing uses, or else the oldest retiring one."""
549
+ doors = [d for d in self._store.doors() if d["door_id"] != keep]
550
+ unused = [d for d in doors if d["state"] in (*PENDING_DOOR, "closing")]
551
+ if not unused:
552
+ retiring = [
553
+ d
554
+ for d in self._store.doors(row["directory"], row["name"])
555
+ if d["state"] == "retiring"
556
+ ]
557
+ unused = sorted(retiring, key=lambda d: int(d["expires_at"]))[:1]
558
+ for d in unused:
559
+ await self._close_door(d)
560
+
561
+ async def _register(self, row: dict[str, Any], door: dict[str, Any]) -> dict[str, Any]:
562
+ """Register a door at the relay. "Already exists" means an earlier
563
+ attempt got through (the id is ours and not yet public)."""
564
+ shortened = freed = False
565
+ while True:
566
+ try:
567
+ await self._engine.rest.create_door(str(door["door_id"]), int(door["expires_at"]))
568
+ except RelayError as e:
569
+ if e.code == "bad_request" and "already exists" in e.detail:
570
+ pass
571
+ elif e.code == "bad_request" and "expires_at" in e.detail and not shortened:
572
+ # The relay's clock is behind ours: ask for an hour less.
573
+ shortened = True
574
+ self._drop_door(str(door["door_id"]))
575
+ door = self._new_door(row, int(door["expires_at"]) - EXPIRY_SLACK_SECS)
576
+ continue
577
+ elif e.code == "quota_exceeded" and not freed:
578
+ freed = True
579
+ await self._free_door_slot(str(door["door_id"]), row)
580
+ continue
581
+ else:
582
+ raise
583
+ self._engine.failpoints.hit("door.after_register")
584
+ self._store.update_door(str(door["door_id"]), state="registered")
585
+ door["state"] = "registered"
586
+ return door
587
+
588
+ # -- one round: door, signature, post ---------------------------------------
589
+
590
+ def _sign(self, row: dict[str, Any], door: dict[str, Any]) -> ac.Listing:
591
+ now = self._now()
592
+ seq = max(int(row["max_signed_seq"]) + 1, now)
593
+ key = self._doors[str(door["door_id"])]
594
+ if row["state"] == "publishing":
595
+ return ac.sign_listing_content(
596
+ str(row["content"]).encode(),
597
+ self._engine.identity,
598
+ directory=str(row["directory"]),
599
+ relay=self._engine.config.relay_host,
600
+ seq=seq,
601
+ issued_at=now,
602
+ token_json=str(row["token"]).encode(),
603
+ door=key,
604
+ owner_domain=row["owner_domain"],
605
+ )
606
+ # A renewal: the human's listing, re-signed with only the door, seq
607
+ # and issued_at changed (SPEC 6.6.1, 13.2.2).
608
+ previous = ac.verify_listing(bytes(row["published"]))
609
+ return previous.renew(self._engine.identity, seq, max(now, previous.issued_at), key)
610
+
611
+ async def _round(self, row: dict[str, Any], *, interactive: bool) -> str:
612
+ """Drive one publish or renewal as far as it goes now: ``published``,
613
+ ``retrying`` (the worker goes on later), ``refused`` or ``waiting``."""
614
+ door = await self._door_for(row)
615
+ if door is None:
616
+ return "waiting"
617
+ if door["state"] == "registering":
618
+ registered = await self._registered(row, door, interactive=interactive)
619
+ if registered is None:
620
+ return "retrying"
621
+ door = registered
622
+ row = self._refresh(row)
623
+ if row["pending"] is None:
624
+ signed = self._sign(row, door)
625
+ self._store.update_listing(
626
+ row["directory"],
627
+ row["name"],
628
+ max_signed_seq=signed.seq,
629
+ pending=signed.bytes,
630
+ pending_door=door["door_id"],
631
+ updated_at=self._now(),
632
+ )
633
+ self._engine.failpoints.hit("listing.after_sign")
634
+ row = self._refresh(row)
635
+ return await self._post(row, door, interactive=interactive)
636
+
637
+ async def _door_for(self, row: dict[str, Any]) -> dict[str, Any] | None:
638
+ """The door this round publishes: the one waiting, or a fresh one;
639
+ None when the token has too little life left to renew."""
640
+ now = self._now()
641
+ door = self._pending_door(row)
642
+ if door is None:
643
+ if row["state"] != "publishing" and int(row["token_expires_at"]) - now < (
644
+ MIN_TOKEN_LEFT_SECS
645
+ ):
646
+ # The door runs out with the token; a new token is a new publish.
647
+ self._store.update_listing(
648
+ row["directory"], row["name"], next_renewal_at=None, retry_at=None
649
+ )
650
+ return None
651
+ return self._new_door(row)
652
+ made, expires = int(door["created_at"]), int(door["expires_at"])
653
+ if now - made > (expires - made) // 2:
654
+ # Half the waiting door's life went by without the relay or the
655
+ # directory: a fresh door (and signature), so a listing never
656
+ # carries a door about to expire, and at most three are open
657
+ # (current, retiring and this one).
658
+ await self._discard_pending(row, door)
659
+ return self._new_door(row)
660
+ return door
661
+
662
+ async def _registered(
663
+ self, row: dict[str, Any], door: dict[str, Any], *, interactive: bool
664
+ ) -> dict[str, Any] | None:
665
+ """The door, registered; None when the worker tries again later."""
666
+ try:
667
+ return await self._register(row, door)
668
+ except RelayError as e:
669
+ if not interactive:
670
+ await self._retry_later(row, e.code)
671
+ return None
672
+ # The relay may hold the door if its answer was lost: keep the
673
+ # row until it confirms the close.
674
+ door_id = str(door["door_id"])
675
+ if e.code in NOT_REGISTERED:
676
+ self._drop_door(door_id)
677
+ else:
678
+ self._closing(door_id)
679
+ await self._restore(row, e.code)
680
+ msg = f"the relay didn't register the door ({e.code}); nothing was published"
681
+ raise ApiError(502, "relay", msg) from None
682
+
683
+ async def _post(self, row: dict[str, Any], door: dict[str, Any], *, interactive: bool) -> str:
684
+ client: DirectoryClient | None = None
685
+ try:
686
+ client = self._client(row)
687
+ answer = await client.publish(bytes(row["pending"]))
688
+ except DirectoryError as e:
689
+ if client is not None and e.maybe_accepted and await self._landed(client, row):
690
+ answer = {} # it is listed as signed; the answer just can't be read
691
+ elif (e.transient or e.maybe_accepted) and not (interactive and e.code == "blocked"):
692
+ await self._retry_later(row, e.code)
693
+ return "retrying"
694
+ else:
695
+ return await self._refusal(row, door, e, interactive)
696
+ self._engine.failpoints.hit("listing.after_post")
697
+ await self._published(row, door, answer)
698
+ return "published"
699
+
700
+ async def _landed(self, client: DirectoryClient, row: dict[str, Any]) -> bool:
701
+ """Whether the directory lists exactly the bytes posted (it answered
702
+ with a 2xx that couldn't be read)."""
703
+ try:
704
+ return await client.fetch_listing(str(row["name"])) == bytes(row["pending"])
705
+ except DirectoryError:
706
+ return False
707
+
708
+ async def _discard_pending(self, row: dict[str, Any], door: dict[str, Any]) -> None:
709
+ await self._close_door(door)
710
+ self._store.update_listing(row["directory"], row["name"], pending=None, pending_door=None)
711
+ row["pending"] = row["pending_door"] = None
712
+
713
+ async def _refusal(
714
+ self, row: dict[str, Any], door: dict[str, Any], e: DirectoryError, interactive: bool
715
+ ) -> str:
716
+ """A refusal (of known bytes; never an unreadable 2xx). A publish is
717
+ put back as it was. A renewal stops only for the SPEC's reasons;
718
+ otherwise it is tried again with a fresh door and signature."""
719
+ LOG.warning("listing_refused", code=e.code)
720
+ if row["state"] != "publishing" and e.code not in STOP_RENEWING:
721
+ await self._discard_pending(row, door)
722
+ await self._retry_later(row, e.code, report=True)
723
+ return "retrying"
724
+ await self._close_door(door)
725
+ if row["state"] == "publishing":
726
+ await self._restore(row, e.code)
727
+ else:
728
+ await self._stop_renewing(row, e.code)
729
+ if not interactive:
730
+ return "refused"
731
+ status = REFUSAL_STATUS.get(e.code)
732
+ message = f"the directory refused the listing ({e.code})"
733
+ if e.detail:
734
+ message += f": {clean_detail(e.detail)}"
735
+ if status is None:
736
+ raise ApiError(502, "directory", message)
737
+ raise ApiError(status, e.code, message)
738
+
739
+ async def _published(
740
+ self, row: dict[str, Any], door: dict[str, Any], answer: dict[str, Any]
741
+ ) -> None:
742
+ listing = ac.verify_listing(bytes(row["pending"]))
743
+ host, name, now = str(row["directory"]), str(row["name"]), self._now()
744
+ door_id, expires_at = str(door["door_id"]), int(door["expires_at"])
745
+ renews_at = listing.issued_at + (expires_at - listing.issued_at) // 2
746
+ reported = owner_state(answer)
747
+ renewal = row["state"] != "publishing"
748
+
749
+ def effects() -> None:
750
+ for d in self._store.doors(host, name):
751
+ if d["state"] == "current" and d["door_id"] != door_id:
752
+ self._store.update_door(d["door_id"], state="retiring")
753
+ self._store.update_door(door_id, state="current")
754
+ self._store.update_listing(
755
+ host,
756
+ name,
757
+ state="published",
758
+ published=listing.bytes,
759
+ published_sha256=listing.sha256.hex(),
760
+ published_at=now,
761
+ seq=listing.seq,
762
+ pending=None,
763
+ pending_door=None,
764
+ next_renewal_at=renews_at,
765
+ owner_state=reported if reported is not None else row["owner_state"],
766
+ retry_at=None,
767
+ attempts=0,
768
+ failing_since=None,
769
+ stale_since=None,
770
+ last_error=None,
771
+ restore_state=None,
772
+ restore_renewal_at=None,
773
+ updated_at=now,
774
+ )
775
+
776
+ kind = "listing_renewed" if renewal else "listing_published"
777
+ data: dict[str, Any] = {
778
+ "directory": host,
779
+ "name": name,
780
+ "seq": listing.seq,
781
+ "door_expires_at": expires_at,
782
+ }
783
+ if not renewal:
784
+ data |= {"url": listing.url, "token_expires_at": listing.token_expires_at}
785
+ await self._commit(effects, [(kind, f"{kind}/{host}/{name}/{listing.seq}", data)])
786
+ LOG.info(kind)
787
+
788
+ def _restored(self, row: dict[str, Any]) -> dict[str, Any]:
789
+ """The listing as it was before this publish began (``restore_state``):
790
+ a refused publish never lists anything the human didn't publish
791
+ again, and a withdrawn, expired or stale listing stays so."""
792
+ state = row["restore_state"] or "refused"
793
+ fields: dict[str, Any] = {
794
+ "state": state,
795
+ "next_renewal_at": row["restore_renewal_at"] if state in LIVE else None,
796
+ "restore_state": None,
797
+ "restore_renewal_at": None,
798
+ }
799
+ if row["published"] is not None:
800
+ previous = ac.verify_listing(bytes(row["published"]))
801
+ params = json.loads(previous.bytes)["capabilities"]["extensions"][0]["params"]
802
+ fields |= {
803
+ "content": previous.content.decode(),
804
+ "token": canonical(params["token"]).decode(),
805
+ "token_expires_at": previous.token_expires_at,
806
+ "owner_domain": previous.owner_domain,
807
+ "seq": previous.seq,
808
+ }
809
+ return fields
810
+
811
+ async def _restore(self, row: dict[str, Any], code: str) -> None:
812
+ now = self._now()
813
+ fields = self._restored(self._refresh(row)) | {
814
+ "pending": None,
815
+ "pending_door": None,
816
+ "retry_at": None,
817
+ "attempts": 0,
818
+ "failing_since": None,
819
+ "last_error": _error(code, now),
820
+ "updated_at": now,
821
+ }
822
+ await self._commit(
823
+ lambda: self._store.update_listing(row["directory"], row["name"], **fields), []
824
+ )
825
+
826
+ async def _stop_renewing(self, row: dict[str, Any], code: str) -> None:
827
+ """A renewal refused for good: the current door keeps working until it
828
+ expires, and the human is told."""
829
+ host, name, now = str(row["directory"]), str(row["name"]), self._now()
830
+ since = row["stale_since"] if row["stale_since"] is not None else now
831
+ fields = {
832
+ "state": "stale",
833
+ "pending": None,
834
+ "pending_door": None,
835
+ "next_renewal_at": None,
836
+ "retry_at": None,
837
+ "attempts": 0,
838
+ "failing_since": None,
839
+ "stale_since": since,
840
+ "last_error": _error(code, now),
841
+ "updated_at": now,
842
+ }
843
+ data = {
844
+ "directory": host,
845
+ "name": name,
846
+ "reason": "refused",
847
+ "code": code,
848
+ "since": since,
849
+ "door_expires_at": self._current_expiry(host, name),
850
+ }
851
+ await self._commit(
852
+ lambda: self._store.update_listing(host, name, **fields),
853
+ [("listing_stale", f"listing_stale/{host}/{name}/refused/{since}", data)],
854
+ )
855
+
856
+ def _current_expiry(self, host: str, name: str) -> int | None:
857
+ current = [d for d in self._store.doors(host, name) if d["state"] == "current"]
858
+ return int(current[-1]["expires_at"]) if current else None
859
+
860
+ async def _retry_later(self, row: dict[str, Any], code: str, *, report: bool = False) -> None:
861
+ """No answer, or not now: the same request goes again after a backoff
862
+ (60 s doubling to an hour). A renewal failing for an hour (or at once,
863
+ with ``report``, for a refusal) makes the listing ``stale``; the old
864
+ door keeps working until it expires. A withdrawal failing for an hour
865
+ is reported, and after 7 days it is given up (``withdraw_failed``)."""
866
+ host, name, now = str(row["directory"]), str(row["name"]), self._now()
867
+ attempts = int(row["attempts"]) + 1
868
+ delay = min(RETRY_CAP_SECS, RETRY_BASE_SECS * 2 ** min(attempts - 1, 16))
869
+ failing_since = row["failing_since"] if row["failing_since"] is not None else now
870
+ fields: dict[str, Any] = {
871
+ "retry_at": now + delay,
872
+ "attempts": attempts,
873
+ "failing_since": failing_since,
874
+ "last_error": _error(code, now),
875
+ "updated_at": now,
876
+ }
877
+ events = []
878
+ failing = now - failing_since
879
+ if row["state"] in ("published", "stale") and (report or failing >= STALE_AFTER_SECS):
880
+ since = row["stale_since"] if row["stale_since"] is not None else now
881
+ fields |= {"state": "stale", "stale_since": since}
882
+ data = {
883
+ "directory": host,
884
+ "name": name,
885
+ "reason": "publish_failed",
886
+ "code": code,
887
+ "since": since,
888
+ "door_expires_at": self._current_expiry(host, name),
889
+ }
890
+ cause = f"listing_stale/{host}/{name}/publish_failed/{failing_since}"
891
+ events.append(("listing_stale", cause, data))
892
+ if row["state"] == "withdrawing" and failing >= STALE_AFTER_SECS:
893
+ data = {"directory": host, "name": name, "code": code, "since": failing_since}
894
+ if failing >= WITHDRAW_GIVE_UP_SECS:
895
+ fields |= {"state": "withdraw_failed", "retry_at": None}
896
+ cause = f"listing_withdraw_failed/{host}/{name}/{failing_since}"
897
+ events.append(("listing_withdraw_failed", cause, data))
898
+ else:
899
+ cause = f"listing_withdraw_pending/{host}/{name}/{failing_since}"
900
+ events.append(("listing_withdraw_pending", cause, data))
901
+ LOG.info("listing_retry", code=code, attempts=attempts)
902
+ await self._commit(lambda: self._store.update_listing(host, name, **fields), events)
903
+
904
+ # -- withdraw (either token; forcing it is the human's) ------------------------
905
+
906
+ def _withdrawable(self) -> dict[str, Any] | None:
907
+ for row in self._store.listings():
908
+ if row["state"] in WITHDRAWABLE:
909
+ return row
910
+ # A refused publish may still be listed: something was signed.
911
+ if row["state"] == "refused" and int(row["max_signed_seq"]) > 0:
912
+ return row
913
+ return None
914
+
915
+ async def withdraw(self, *, force: bool = False, human: bool = False) -> dict[str, Any]:
916
+ """Close every door at the relay (keys deleted at once), then send
917
+ the signed withdrawal. Allowed to the agent: it only reduces
918
+ exposure (SPEC 13.2.3). ``force`` (the human only, after a
919
+ withdrawal the directory hasn't taken) ends it here only:
920
+ ``withdrawn_locally``, and the directory may go on listing it."""
921
+ if force and not human:
922
+ raise ApiError(403, "human_only", "forcing a withdrawal needs the human token")
923
+ async with self._lock:
924
+ row = self._withdrawable()
925
+ if row is None:
926
+ raise ApiError(404, "not_found", "there is no listing to withdraw")
927
+ if force and row["state"] not in FORCEABLE:
928
+ msg = "force only ends a withdrawal the directory hasn't taken; withdraw first"
929
+ raise ApiError(409, "not_withdrawing", msg)
930
+ if row["state"] != "withdrawing":
931
+ self._store.update_listing(
932
+ row["directory"],
933
+ row["name"],
934
+ state="withdrawing",
935
+ pending=None,
936
+ pending_door=None,
937
+ next_renewal_at=None,
938
+ retry_at=None,
939
+ attempts=0,
940
+ failing_since=None,
941
+ restore_state=None,
942
+ restore_renewal_at=None,
943
+ updated_at=self._now(),
944
+ )
945
+ answer = await self._withdraw_round(self._refresh(row), force=force, now=True)
946
+ self._wake.set()
947
+ if answer.pop("unknown_directory", False):
948
+ msg = (
949
+ "the doors are closed, but the directory is no longer configured, so the "
950
+ "withdrawal can't be sent: add its [[directory]] table back, or force it"
951
+ )
952
+ raise ApiError(400, "unknown_directory", msg)
953
+ return answer
954
+
955
+ async def _withdraw_round(
956
+ self, row: dict[str, Any], *, force: bool = False, now: bool = False
957
+ ) -> dict[str, Any]:
958
+ """One attempt. ``now`` (a request, not the worker) tries every door
959
+ again, whatever its close backoff."""
960
+ host, name = str(row["directory"]), str(row["name"])
961
+ closed = 0
962
+ for door in self._store.doors(host, name):
963
+ if now or self._close_due(door, self._now()):
964
+ closed += await self._close_door(door)
965
+ pending = len(self._store.doors(host, name))
966
+ self._engine.failpoints.hit("withdraw.after_close")
967
+ seq = int(row["max_signed_seq"])
968
+ answer = {
969
+ "directory": host,
970
+ "name": name,
971
+ "seq": seq,
972
+ "state": "withdrawing",
973
+ "doors_closed": closed,
974
+ "doors_close_pending": pending,
975
+ "forced": force,
976
+ }
977
+ error: str | None = "forced" if force else None
978
+ if seq > 0 and not force: # something was signed, so something may be listed
979
+ try:
980
+ seq = await self._send_withdrawal(row)
981
+ except DirectoryError as e:
982
+ if e.transient or e.code in ("stale_ts", "unknown_directory"):
983
+ # Sent again with a fresh `ts` (300 s skew at the directory).
984
+ await self._retry_later(row, e.code)
985
+ if e.code == "unknown_directory":
986
+ answer["unknown_directory"] = True
987
+ return answer
988
+ # not_found: nothing was listed. wrong_identity and the rest:
989
+ # the directory won't take a withdrawal from this identity.
990
+ error = None if e.code == "not_found" else e.code
991
+ state = "withdrawn_locally" if force else "withdrawn"
992
+ at = self._now()
993
+ fields: dict[str, Any] = {
994
+ "state": state,
995
+ "retry_at": None,
996
+ "attempts": 0,
997
+ "failing_since": None,
998
+ "updated_at": at,
999
+ }
1000
+ if error is not None:
1001
+ fields["last_error"] = _error(error, at)
1002
+ data = {
1003
+ "directory": host,
1004
+ "name": name,
1005
+ "seq": seq,
1006
+ "doors_close_pending": pending,
1007
+ "forced": force,
1008
+ }
1009
+ await self._commit(
1010
+ lambda: self._store.update_listing(host, name, **fields),
1011
+ [("listing_withdrawn", f"listing_withdrawn/{host}/{name}/{seq}/{state}", data)],
1012
+ )
1013
+ LOG.info("listing_withdrawn", doors_close_pending=pending, forced=force)
1014
+ return answer | {"seq": seq, "state": state}
1015
+
1016
+ async def _send_withdrawal(self, row: dict[str, Any]) -> int:
1017
+ """Withdraw everything up to ``max_signed_seq`` (a publish whose answer
1018
+ was lost may have landed). ``stale_seq`` (a database restored from a
1019
+ backup) is retried once at the seq the directory lists, if that
1020
+ listing is this identity's."""
1021
+ client = self._client(row)
1022
+ host, name = str(row["directory"]), str(row["name"])
1023
+ seq = int(row["max_signed_seq"])
1024
+ identity = self._engine.identity
1025
+ try:
1026
+ await client.withdraw(ac.sign_withdrawal(identity, host, name, seq, self._now()))
1027
+ except DirectoryError as e:
1028
+ if e.code != "stale_seq":
1029
+ raise
1030
+ listed = await self._listed_seq(client, host, name)
1031
+ if listed is None or listed <= seq:
1032
+ raise
1033
+ seq = listed
1034
+ self._store.update_listing(host, name, max_signed_seq=seq)
1035
+ await client.withdraw(ac.sign_withdrawal(identity, host, name, seq, self._now()))
1036
+ return seq
1037
+
1038
+ async def _listed_seq(self, client: DirectoryClient, host: str, name: str) -> int | None:
1039
+ """The ``seq`` the directory lists under ``name``, if it is a valid
1040
+ listing of this identity for this directory."""
1041
+ try:
1042
+ listing = ac.verify_listing(await client.fetch_listing(name))
1043
+ except (DirectoryError, ac.AgentConnectError):
1044
+ return None
1045
+ same = (listing.identity, listing.directory, listing.name) == (self._engine.me, host, name)
1046
+ return listing.seq if same else None
1047
+
1048
+ # -- status (either token) ---------------------------------------------------
1049
+
1050
+ def _latest(self, states: frozenset[str]) -> dict[str, Any] | None:
1051
+ return next((r for r in self._store.listings() if r["state"] in states), None)
1052
+
1053
+ def status(self) -> dict[str, Any]:
1054
+ """The runtime's listing as it stands: the one it holds, else the
1055
+ latest one in any state. No door key is read."""
1056
+ rows = self._store.listings()
1057
+ if not rows:
1058
+ return {"listing": None}
1059
+ row, now = self._latest(HOLDING) or rows[0], self._now()
1060
+ doors = self._store.doors(row["directory"], row["name"])
1061
+ current = next((d for d in reversed(doors) if d["state"] == "current"), None)
1062
+ door = None
1063
+ if current is not None:
1064
+ expires = int(current["expires_at"])
1065
+ door = {
1066
+ "expires_at": expires,
1067
+ "renews_at": row["next_renewal_at"],
1068
+ "expiring": expires - now < DOOR_WARNING_SECS,
1069
+ }
1070
+ error = json.loads(row["last_error"]) if row["last_error"] else None
1071
+ retrying = None
1072
+ if row["state"] in ("publishing", "withdrawing") and row["failing_since"] is not None:
1073
+ retrying = {
1074
+ "since": int(row["failing_since"]),
1075
+ "code": None if error is None else error["code"],
1076
+ }
1077
+ expires_at = int(row["token_expires_at"])
1078
+ return {
1079
+ "listing": {
1080
+ "directory": row["directory"],
1081
+ "name": row["name"],
1082
+ "url": _url(row),
1083
+ "state": row["state"],
1084
+ "seq": int(row["seq"]),
1085
+ "published_at": row["published_at"],
1086
+ "identity": self._engine.me,
1087
+ "fingerprint": self._engine.identity.fingerprint,
1088
+ "door": door,
1089
+ "doors_open": len(
1090
+ [d for d in doors if d["state"] in OPEN_DOOR and int(d["expires_at"]) > now]
1091
+ ),
1092
+ "token": {"expires_at": expires_at, "warn_at": expires_at - TOKEN_WARNING_SECS},
1093
+ "owner_domain": row["owner_domain"],
1094
+ "owner_state": row["owner_state"],
1095
+ "last_error": error,
1096
+ "retrying": retrying,
1097
+ }
1098
+ }
1099
+
1100
+ # -- the discovery worker ---------------------------------------------------
1101
+
1102
+ async def run(self) -> None:
1103
+ """Renew, retry, expire and delete keys when each is due; a pass that
1104
+ fails is logged by type and retried after a backoff."""
1105
+ backoff = Backoff(base=1.0, cap=300.0, rng=self._engine.options.backoff_rng)
1106
+ busy = 0
1107
+ while True:
1108
+ self._wake.clear()
1109
+ try:
1110
+ due = await self.step()
1111
+ backoff.reset()
1112
+ except Exception as e:
1113
+ LOG.warning("discovery_step_failed", error=type(e).__name__)
1114
+ due = self._now() + backoff.next()
1115
+ # Work due at once runs at once, but a pass that makes no progress
1116
+ # can't spin: after a few in a row, the worker pauses a second.
1117
+ busy = busy + 1 if due is not None and due <= self._now() else 0
1118
+ if busy > MAX_IDLE_PASSES:
1119
+ due, busy = self._now() + 1, 0
1120
+ await self._wait(due)
1121
+
1122
+ async def _wait(self, due: float | None) -> None:
1123
+ """Until ``due`` (Unix seconds on the runtime's clock) or a wake-up.
1124
+ One timer, set against the clock itself (``Clock.sleep_until``), so
1125
+ neither a busy loop nor a test moving a fake clock re-arms it."""
1126
+ woken = asyncio.ensure_future(self._wake.wait())
1127
+ waits: set[asyncio.Future[Any]] = {woken}
1128
+ if due is not None:
1129
+ # A deadline, not a retry pause: the fake clock doesn't count it.
1130
+ waits.add(asyncio.ensure_future(self._engine.clock.sleep_until(due, deadline=True)))
1131
+ try:
1132
+ await asyncio.sleep(0) # the timer is set before anyone is told
1133
+ self._engine._pulse() # a pass ended: waiters re-check
1134
+ await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
1135
+ finally:
1136
+ for w in waits:
1137
+ w.cancel()
1138
+
1139
+ async def step(self) -> float | None:
1140
+ """One pass over the doors and listings. Returns when the next thing
1141
+ is due (Unix seconds; at or before now when it is overdue), or None
1142
+ when nothing is scheduled."""
1143
+ async with self._lock:
1144
+ await self._sweep_doors()
1145
+ for row in self._store.listings():
1146
+ await self._tend(row)
1147
+ return self._next_due()
1148
+
1149
+ async def _sweep_doors(self) -> None:
1150
+ now = self._now()
1151
+ for door in self._store.doors():
1152
+ door_id, expires_at = str(door["door_id"]), int(door["expires_at"])
1153
+ if door["state"] == "closing":
1154
+ if now >= expires_at: # the relay forgets it anyway
1155
+ self._store.delete_door(door_id)
1156
+ self._close_retry.pop(door_id, None)
1157
+ elif self._close_due(door, now):
1158
+ await self._close_door(door)
1159
+ continue
1160
+ # SPEC 6.6.1: the key goes an hour after the door expires, once
1161
+ # every knock it could have received has lapsed.
1162
+ if now >= expires_at + ac.DOOR_GRACE_SECS:
1163
+ self._drop_door(door_id)
1164
+ LOG.info("door_key_deleted")
1165
+
1166
+ async def _tend(self, row: dict[str, Any]) -> None:
1167
+ now, state = self._now(), row["state"]
1168
+ if state == "withdrawing":
1169
+ if _due(row["retry_at"], now):
1170
+ await self._withdraw_round(row)
1171
+ return
1172
+ if state not in LIVE:
1173
+ return
1174
+ host, name = str(row["directory"]), str(row["name"])
1175
+ expires_at = int(row["token_expires_at"])
1176
+ if now >= expires_at:
1177
+ await self._expire(row)
1178
+ return
1179
+ if not row["token_warned"] and now >= expires_at - TOKEN_WARNING_SECS:
1180
+ await self._commit(
1181
+ lambda: self._store.update_listing(host, name, token_warned=1),
1182
+ [
1183
+ (
1184
+ "listing_token_expiring",
1185
+ f"listing_token_expiring/{host}/{name}/{expires_at}",
1186
+ {"directory": host, "name": name, "token_expires_at": expires_at},
1187
+ )
1188
+ ],
1189
+ )
1190
+ await self._door_checks(row)
1191
+ row = self._refresh(row)
1192
+ if not _due(row["retry_at"], now):
1193
+ return
1194
+ renew_at = row["next_renewal_at"]
1195
+ if row["state"] == "publishing" or (renew_at is not None and now >= renew_at):
1196
+ await self._round(row, interactive=False)
1197
+
1198
+ async def _door_checks(self, row: dict[str, Any]) -> None:
1199
+ """Tell the human when the listed door is about to expire while a
1200
+ republish waits, and when it expired before a renewal landed."""
1201
+ host, name, now = str(row["directory"]), str(row["name"]), self._now()
1202
+ current = [d for d in self._store.doors(host, name) if d["state"] == "current"]
1203
+ if not current:
1204
+ return
1205
+ door_id, expires = current[-1]["door_id"], int(current[-1]["expires_at"])
1206
+ publishing = row["state"] == "publishing"
1207
+ since = row["stale_since"] if row["stale_since"] is not None else now
1208
+ data = {"directory": host, "name": name, "since": since, "door_expires_at": expires}
1209
+ if expires <= now:
1210
+ # A republish that waits stays `publishing`; the human is told all the same.
1211
+ fields = {} if publishing else {"state": "stale", "stale_since": since}
1212
+ await self._commit(
1213
+ lambda: self._store.update_listing(host, name, updated_at=now, **fields),
1214
+ [
1215
+ (
1216
+ "listing_stale",
1217
+ f"listing_stale/{host}/{name}/door_expired/{door_id}",
1218
+ data | {"reason": "door_expired"},
1219
+ )
1220
+ ],
1221
+ )
1222
+ elif publishing and expires - now < DOOR_WARNING_SECS:
1223
+ await self._commit(
1224
+ lambda: None,
1225
+ [
1226
+ (
1227
+ "listing_stale",
1228
+ f"listing_stale/{host}/{name}/door_expiring/{door_id}",
1229
+ data | {"reason": "door_expiring"},
1230
+ )
1231
+ ],
1232
+ )
1233
+
1234
+ async def _expire(self, row: dict[str, Any]) -> None:
1235
+ """The token expired: the listing counts as withdrawn everywhere
1236
+ (SPEC 13.2.1). Its doors are closed and their keys deleted; a new
1237
+ token is a new publish."""
1238
+ host, name, now = str(row["directory"]), str(row["name"]), self._now()
1239
+ for door in self._store.doors(host, name):
1240
+ await self._close_door(door)
1241
+ fields = {
1242
+ "state": "expired",
1243
+ "pending": None,
1244
+ "pending_door": None,
1245
+ "next_renewal_at": None,
1246
+ "retry_at": None,
1247
+ "attempts": 0,
1248
+ "failing_since": None,
1249
+ "restore_state": None,
1250
+ "restore_renewal_at": None,
1251
+ "updated_at": now,
1252
+ }
1253
+ await self._commit(
1254
+ lambda: self._store.update_listing(host, name, **fields),
1255
+ [
1256
+ (
1257
+ "listing_token_expired",
1258
+ f"listing_token_expired/{host}/{name}/{row['token_expires_at']}",
1259
+ {"directory": host, "name": name},
1260
+ )
1261
+ ],
1262
+ )
1263
+ LOG.info("listing_token_expired")
1264
+
1265
+ def _next_due(self) -> float | None:
1266
+ """When the next thing is due. Overdue work (the clock moved during
1267
+ the pass) counts, so it runs at once; what this pass already handled
1268
+ (an expired current door, a warning given) doesn't."""
1269
+ now = self._now()
1270
+ times: list[int] = []
1271
+ for door in self._store.doors():
1272
+ expires_at = int(door["expires_at"])
1273
+ if door["state"] == "closing":
1274
+ due, _ = self._close_retry.get(str(door["door_id"]), (now, 0))
1275
+ times.append(min(due, expires_at))
1276
+ else:
1277
+ times.append(expires_at + ac.DOOR_GRACE_SECS)
1278
+ if door["state"] == "current":
1279
+ times += [t for t in (expires_at - DOOR_WARNING_SECS, expires_at) if t > now]
1280
+ for row in self._store.listings():
1281
+ if row["state"] == "withdrawing":
1282
+ times.append(row["retry_at"] or now + RETRY_BASE_SECS)
1283
+ if row["state"] not in LIVE:
1284
+ continue
1285
+ expires_at = int(row["token_expires_at"])
1286
+ times.append(expires_at)
1287
+ if not row["token_warned"]:
1288
+ times.append(expires_at - TOKEN_WARNING_SECS)
1289
+ # Only when a round would run: a publish, or a renewal scheduled.
1290
+ renew_at = row["next_renewal_at"]
1291
+ if row["state"] == "publishing" or renew_at is not None:
1292
+ times.append(max(int(row["retry_at"] or 0), int(renew_at or 0)))
1293
+ return float(min(times)) if times else None
1294
+
1295
+
1296
+ def _url(row: dict[str, Any]) -> str:
1297
+ """The listing's address at its directory (``Listing.url``)."""
1298
+ return f"https://{row['directory']}/a/{row['name']}.json"
1299
+
1300
+
1301
+ def _token_error(e: ac.AgentConnectError) -> ApiError:
1302
+ if e.kind == "unknown_key":
1303
+ msg = "the token isn't signed by a key pinned for this directory"
1304
+ return ApiError(403, "unknown_key", msg)
1305
+ if e.kind in ("malformed", "unsupported_version"):
1306
+ return ApiError(400, "bad_token", f"not a listing token: {e}")
1307
+ return ApiError(403, "token_invalid", f"the listing token doesn't verify: {e}")