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
agentconnect/config.py ADDED
@@ -0,0 +1,1213 @@
1
+ """Daemon configuration: TOML at ``~/.config/agentconnect/config.toml``.
2
+
3
+ Every value can be overridden for tests, either by passing keyword
4
+ arguments to :func:`load_config` or through the environment::
5
+
6
+ AC_CONFIG path of the TOML file
7
+ AC_DATA_DIR data directory (default ~/.local/share/agentconnect)
8
+ AC_RELAY_URL relay base URL (ws:// or wss://)
9
+ AC_RELAY_HOST host string signed into relay authentication
10
+
11
+ Example file::
12
+
13
+ [relay]
14
+ url = "wss://relay.example.org"
15
+ host = "relay.example.org" # optional; defaults to the URL's host[:port]
16
+
17
+ [daemon]
18
+ data_dir = "~/.local/share/agentconnect"
19
+ display_name = "build-bot"
20
+ adapters = ["webhook"] # where groups deliver: webhook, sse, mcp, a2a
21
+ keystore = "auto" # auto | keyring | file
22
+ event_retention_days = 30 # events are kept this long (and 100000 at most)
23
+
24
+ [policy]
25
+ auto_approve = false # approve JoinRequests without a human
26
+ require_approval = "off" # off | inbound | outbound | both
27
+ frame_text = true # the SPEC 10.1 notice for text consumers
28
+ sender_per_minute = 30 # and the other limits: docs/safety.md
29
+ transcript = false # full transcript (encrypted), per group
30
+
31
+ [api]
32
+ socket = "~/.local/share/agentconnect/agentconnectd.sock"
33
+ tcp_port = 0 # > 0 serves on 127.0.0.1:<port> instead
34
+
35
+ [sse]
36
+ retention_secs = 3600 # how long the SSE feed keeps items for resume
37
+
38
+ [webhook]
39
+ callback_url = "http://127.0.0.1:8808/" # where the webhook adapter POSTs
40
+ allow_remote = false # opt in to a callback that isn't loopback
41
+ timeout_secs = 120 # per request; an agent turn may be slow
42
+ [sdk] # the embedded SDK (docs/sdk.md)
43
+ handler_timeout_secs = 120 # per handler call; then cancelled and retried
44
+ [a2a] # optional: the A2A adapter (docs/adapters/a2a.md)
45
+ local_agent_url = "http://127.0.0.1:9999/"
46
+ local_agent_token_file = "~/.config/agentconnect/agent-token"
47
+ card_keys_file = "~/.config/agentconnect/card-keys.json"
48
+ facade_port = 8765 # 0: no facade
49
+ context_messages = 20
50
+ frame_text = true
51
+ reply_timeout = 600
52
+ [isolation] # optional: separate-OS-user mode (docs/isolation.md)
53
+ enabled = false
54
+ agent_dir = "/run/agentconnect"
55
+ group = "agentconnect"
56
+ agent_users = ["bot"] # names or UIDs allowed on the socket
57
+ human_users = [] # also allowed to use the human token
58
+ [[directory]] # a directory you may list in (docs/discovery.md)
59
+ host = "directory.example.org"
60
+ keys = ["<the operator's DK.pub, base64url>"]
61
+ [listing]
62
+ door_lifetime_secs = 601200 # 7 days less 1 hour
63
+ [knock] # knocking on listings (docs/discovery.md)
64
+ agent_may_knock = true # false: only the human token may knock
65
+ domain_check = true # fetch the owner's domain file before a knock
66
+ [groups] # membership (docs/groups.md)
67
+ leave_timeout_secs = 604800 # a leave nobody commits ends locally after this
68
+ cleanup_departed_roles = true # the owner drops departed admins and moderators
69
+ """
70
+
71
+ from __future__ import annotations
72
+
73
+ import base64
74
+ import binascii
75
+ import ipaddress
76
+ import os
77
+ import tomllib
78
+ from collections.abc import Mapping
79
+ from dataclasses import dataclass, replace
80
+ from pathlib import Path
81
+ from typing import Any
82
+ from urllib.parse import urlsplit
83
+
84
+ import agentconnect_core as ac
85
+ from agentconnect import log
86
+ from agentconnect.policy import (
87
+ DEFAULT_AUDIT_GROUP_ROWS,
88
+ DEFAULT_AUDIT_RETENTION_DAYS,
89
+ DEFAULT_AUDIT_SENDER_ROWS,
90
+ DEFAULT_TRANSCRIPT_MAX_BYTES,
91
+ KNOB,
92
+ KNOBS,
93
+ MAX_PENDING_KNOCKS,
94
+ PolicyError,
95
+ Settings,
96
+ )
97
+
98
+ LOG = log.get("agentconnect.config")
99
+ KEYSTORES = ("auto", "keyring", "file")
100
+ # The adapters the separate daemon builds (agentconnect.adapters).
101
+ ADAPTER_NAMES = ("webhook", "sse", "mcp", "a2a")
102
+ # SPEC 6.6.1: a door lives at most 7 days, measured on the relay's clock, so
103
+ # the default asks for an hour less.
104
+ MIN_DOOR_LIFETIME_SECS = 7200
105
+ DEFAULT_DOOR_LIFETIME_SECS = ac.MAX_DOOR_LIFETIME_SECS - 3600
106
+ MAX_DIRECTORY_KEYS = 2
107
+ # SPEC 6.6.2, 6.6.3 and 8.3: the knock limits a daemon may only lower.
108
+ MAX_OUTGOING_KNOCKS = 8
109
+ MAX_KNOCKS_PER_HOUR = 20
110
+ MAX_PENDING_KNOCKS_PER_DOOR = MAX_PENDING_KNOCKS
111
+ MAX_KNOCKS_OPENED_PER_MINUTE = 10
112
+ DEFAULT_KNOCK_FAILURES_PER_HOUR = 10
113
+ MAX_KNOCK_FAILURES_PER_HOUR = 100
114
+ MIN_LEDGER_CAPACITY = 1024
115
+ # An invite's share of the join replay ledger (SPEC 6.2 "Replays"):
116
+ # max(4 x max_uses, 64), at most policy.join_ledger_per_invite.
117
+ MIN_JOIN_LEDGER_PER_INVITE = 64
118
+ MAX_JOIN_LEDGER_PER_INVITE = 1024
119
+ # JoinRequests of one invite that may wait for the human at once (as knocks
120
+ # per door, SPEC 6.6.3 step 7).
121
+ MAX_PENDING_JOINS_PER_INVITE = 32
122
+ # How long a leave waits for a member to commit it (docs/groups.md): at most
123
+ # the relay's 7-day message lifetime, after which the proposal can't arrive.
124
+ MIN_LEAVE_TIMEOUT_SECS = 3600
125
+ MAX_LEAVE_TIMEOUT_SECS = 7 * 86_400
126
+ # How long the events for the local API and the CLI are kept.
127
+ DEFAULT_EVENT_RETENTION_DAYS = 30
128
+ MAX_EVENT_RETENTION_DAYS = 365
129
+
130
+
131
+ class ConfigError(ValueError):
132
+ """An invalid configuration value."""
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class DirectoryConfig:
137
+ """One pinned directory (a ``[[directory]]`` table): its canonical host,
138
+ the operator token keys (``DK.pub``) pinned for it, never fetched (SPEC
139
+ 13.2.1), and the base URL requests go to."""
140
+
141
+ host: str
142
+ keys: tuple[bytes, ...]
143
+ url: str
144
+
145
+
146
+ def _xdg(environ: Mapping[str, str], var: str, fallback: str) -> Path:
147
+ raw = environ.get(var, "").strip()
148
+ base = Path(raw) if raw else Path(environ.get("HOME", str(Path.home()))) / fallback
149
+ return base / "agentconnect"
150
+
151
+
152
+ def default_config_path(environ: Mapping[str, str] = os.environ) -> Path:
153
+ """``$XDG_CONFIG_HOME/agentconnect/config.toml``."""
154
+ return _xdg(environ, "XDG_CONFIG_HOME", ".config") / "config.toml"
155
+
156
+
157
+ def default_data_dir(environ: Mapping[str, str] = os.environ) -> Path:
158
+ """``$XDG_DATA_HOME/agentconnect``."""
159
+ return _xdg(environ, "XDG_DATA_HOME", ".local/share")
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class Config:
164
+ """Resolved daemon configuration."""
165
+
166
+ relay_url: str
167
+ relay_host: str
168
+ data_dir: Path
169
+ display_name: str = "agent"
170
+ default_adapter: str = "none"
171
+ # The adapters every group delivers to unless the human pinned its own
172
+ # list; empty: `default_adapter` alone (`none`: none).
173
+ adapters: tuple[str, ...] = ()
174
+ keystore: str = "auto"
175
+ event_retention_days: int = DEFAULT_EVENT_RETENTION_DAYS
176
+ auto_approve: bool = False
177
+ api_socket: Path | None = None
178
+ api_tcp_port: int = 0
179
+ relay_rest_url: str | None = None
180
+ webhook_url: str | None = None
181
+ webhook_allow_remote: bool = False
182
+ webhook_timeout: int = 120
183
+ sse_retention: int = 3600
184
+ sdk_handler_timeout: int = 120
185
+ a2a_local_agent_url: str | None = None
186
+ a2a_local_agent_token_file: Path | None = None
187
+ a2a_card_keys_file: Path | None = None
188
+ a2a_facade_port: int = 0
189
+ a2a_context_messages: int = 20
190
+ a2a_frame_text: bool = True
191
+ a2a_reply_timeout: int = 600
192
+ isolation: bool = False
193
+ isolation_agent_dir: Path = Path("/run/agentconnect")
194
+ isolation_group: str = "agentconnect"
195
+ isolation_agent_users: tuple[str, ...] = ()
196
+ isolation_human_users: tuple[str, ...] = ()
197
+ directories: tuple[DirectoryConfig, ...] = ()
198
+ listing_card_file: Path | None = None
199
+ listing_token_file: Path | None = None
200
+ listing_door_lifetime_secs: int = DEFAULT_DOOR_LIFETIME_SECS
201
+ knock_agent_may_knock: bool = True
202
+ knock_domain_check: bool = True
203
+ knock_max_outgoing: int = MAX_OUTGOING_KNOCKS
204
+ knock_per_hour: int = MAX_KNOCKS_PER_HOUR
205
+ knock_max_pending_per_door: int = MAX_PENDING_KNOCKS_PER_DOOR
206
+ knock_opened_per_door_per_minute: int = MAX_KNOCKS_OPENED_PER_MINUTE
207
+ knock_failures_per_sender_per_hour: int = DEFAULT_KNOCK_FAILURES_PER_HOUR
208
+ knock_ledger_capacity: int = ac.DEFAULT_LEDGER_CAPACITY
209
+ groups_leave_timeout_secs: int = MAX_LEAVE_TIMEOUT_SECS
210
+ groups_cleanup_departed_roles: bool = True
211
+
212
+ def directory(self, host: str) -> DirectoryConfig | None:
213
+ """The pinned directory ``host`` names (in any host form), if any."""
214
+ try:
215
+ canonical = ac.canonical_relay_host(host)
216
+ except ac.AgentConnectError:
217
+ return None
218
+ return next((d for d in self.directories if d.host == canonical), None)
219
+
220
+ @property
221
+ def adapter_names(self) -> tuple[str, ...]:
222
+ """The adapters a group delivers to unless pinned: ``adapters``,
223
+ else ``default_adapter`` as a one-element list (none for `none`)."""
224
+ if self.adapters:
225
+ return self.adapters
226
+ return () if self.default_adapter == "none" else (self.default_adapter,)
227
+
228
+ @property
229
+ def a2a_enabled(self) -> bool:
230
+ """Whether the daemon runs the A2A adapter: a local agent, a facade or both."""
231
+ return self.a2a_local_agent_url is not None or self.a2a_facade_port > 0
232
+
233
+ # [policy]: the safety policy's defaults (agentconnect.policy.KNOBS).
234
+ policy_max_message_bytes: int = KNOB["max_message_bytes"].default
235
+ policy_sender_per_minute: int = KNOB["sender_per_minute"].default
236
+ policy_group_per_minute: int = KNOB["group_per_minute"].default
237
+ policy_identity_rate_multiple: int = KNOB["identity_rate_multiple"].default
238
+ policy_task_per_minute: int = KNOB["task_per_minute"].default
239
+ policy_group_task_per_minute: int = KNOB["group_task_per_minute"].default
240
+ policy_allow_files: bool = KNOB["allow_files"].default
241
+ policy_max_file_bytes: int = KNOB["max_file_bytes"].default
242
+ policy_frame_text: bool = KNOB["frame_text"].default
243
+ policy_require_approval: str = KNOB["require_approval"].default
244
+ policy_approval_ttl_secs: int = KNOB["approval_ttl_secs"].default
245
+ policy_max_held_per_group: int = KNOB["max_held_per_group"].default
246
+ policy_max_held_per_sender: int = KNOB["max_held_per_sender"].default
247
+ policy_max_held_bytes: int = KNOB["max_held_bytes"].default
248
+ policy_max_held_bytes_per_sender: int = KNOB["max_held_bytes_per_sender"].default
249
+ policy_task_approval_window_secs: int = KNOB["task_approval_window_secs"].default
250
+ policy_violation_threshold: int = KNOB["violation_threshold"].default
251
+ policy_violation_window_secs: int = KNOB["violation_window_secs"].default
252
+ policy_transcript: bool = KNOB["transcript"].default
253
+ policy_transcript_retention_days: int = KNOB["transcript_retention_days"].default
254
+ policy_transcript_max_rows: int = KNOB["transcript_max_rows"].default
255
+ policy_transcript_max_bytes: int = DEFAULT_TRANSCRIPT_MAX_BYTES
256
+ policy_audit_max_rows_per_group: int = DEFAULT_AUDIT_GROUP_ROWS
257
+ policy_audit_max_rows_per_sender: int = DEFAULT_AUDIT_SENDER_ROWS
258
+ policy_max_pending_joins_per_invite: int = MAX_PENDING_JOINS_PER_INVITE
259
+ policy_join_ledger_per_invite: int = MAX_JOIN_LEDGER_PER_INVITE
260
+ policy_transcript_max_rows_per_sender: int = KNOB["transcript_max_rows_per_sender"].default
261
+ policy_transcript_max_group_bytes: int = KNOB["transcript_max_group_bytes"].default
262
+ policy_audit_retention_days: int = DEFAULT_AUDIT_RETENTION_DAYS
263
+
264
+ def safety_defaults(self) -> Settings:
265
+ """The safety policy every group starts from (docs/safety.md)."""
266
+ return Settings.of({k.name: getattr(self, f"policy_{k.name}") for k in KNOBS})
267
+
268
+ def require_relay(self) -> None:
269
+ """Raise :class:`ConfigError` unless a relay is configured: there is no
270
+ built-in default."""
271
+ if not self.relay_url:
272
+ msg = "relay.url is required (the relay this agent uses)"
273
+ raise ConfigError(msg)
274
+
275
+ @property
276
+ def relay_http(self) -> str:
277
+ """The relay's REST base URL (``http(s)://host[:port]``), derived from
278
+ the WebSocket URL unless ``relay_rest_url`` overrides it."""
279
+ if self.relay_rest_url is not None:
280
+ return self.relay_rest_url.rstrip("/")
281
+ self.require_relay()
282
+ parts = urlsplit(self.relay_url)
283
+ scheme = "https" if parts.scheme == "wss" else "http"
284
+ return f"{scheme}://{parts.netloc}"
285
+
286
+ @property
287
+ def relay_ws(self) -> str:
288
+ """The relay's WebSocket endpoint."""
289
+ self.require_relay()
290
+ return self.relay_url.rstrip("/") + "/v0/ws"
291
+
292
+ @property
293
+ def socket_path(self) -> Path:
294
+ # Separate-OS-user mode: agents can't enter the data directory, so the
295
+ # socket lives in the agent directory (docs/isolation.md).
296
+ home = self.isolation_agent_dir if self.isolation else self.data_dir
297
+ return self.api_socket or home / "agentconnectd.sock"
298
+
299
+ def with_overrides(self, **kw: Any) -> Config:
300
+ return replace(self, **kw)
301
+
302
+
303
+ @dataclass(frozen=True)
304
+ class ConfigKey:
305
+ """A key of ``config.toml`` and the :class:`Config` field it fills.
306
+
307
+ :data:`CONFIG_KEYS` is what ``docs/config.md`` is generated from
308
+ (``scripts/gen_reference.py``): the type comes from the dataclass field,
309
+ the rest is written here, next to the loader that gives it meaning.
310
+ """
311
+
312
+ key: str # `section.name` in the TOML file
313
+ field: str # attribute of Config
314
+ default: str # as the docs show it
315
+ doc: str
316
+ env: str = "" # environment variable that overrides it
317
+ # The TOML type, for a key of an array of tables (`[[directory]]`):
318
+ # its tables together fill one field, so the field's type isn't the key's.
319
+ kind: str = ""
320
+
321
+
322
+ def shown_default(value: object) -> str:
323
+ """A default as the docs show it: TOML syntax in backticks."""
324
+ if isinstance(value, bool):
325
+ return "`true`" if value else "`false`"
326
+ if isinstance(value, str):
327
+ return f'`"{value}"`'
328
+ return f"`{value}`"
329
+
330
+
331
+ _POLICY_KEYS: tuple[ConfigKey, ...] = (
332
+ *(
333
+ ConfigKey(f"policy.{k.name}", f"policy_{k.name}", shown_default(k.default), k.doc)
334
+ for k in KNOBS
335
+ ),
336
+ ConfigKey(
337
+ "policy.audit_retention_days",
338
+ "policy_audit_retention_days",
339
+ shown_default(DEFAULT_AUDIT_RETENTION_DAYS),
340
+ "Days the metadata-only audit log keeps a row (1 to 365). Drops, routine rows and "
341
+ "decisions also keep at most their newest 100000 rows each, 300000 in all; "
342
+ "security rows have no total cap and are bounded per admitted sender instead "
343
+ "(`policy.audit_max_rows_per_sender` per group and decision).",
344
+ ),
345
+ ConfigKey(
346
+ "policy.transcript_max_bytes",
347
+ "policy_transcript_max_bytes",
348
+ shown_default(DEFAULT_TRANSCRIPT_MAX_BYTES),
349
+ "Bytes all transcripts together keep at most (1048576 to 67108864, 64 MiB); the "
350
+ "oldest entries go first.",
351
+ ),
352
+ ConfigKey(
353
+ "policy.audit_max_rows_per_group",
354
+ "policy_audit_max_rows_per_group",
355
+ shown_default(DEFAULT_AUDIT_GROUP_ROWS),
356
+ "Drops, routine rows and decisions one group keeps at most, each (1000 to 20000). "
357
+ "Past that, the senders holding the most rows of the class give way, oldest "
358
+ "first. Security rows don't count toward it.",
359
+ ),
360
+ ConfigKey(
361
+ "policy.audit_max_rows_per_sender",
362
+ "policy_audit_max_rows_per_sender",
363
+ shown_default(DEFAULT_AUDIT_SENDER_ROWS),
364
+ "Drops and routine rows one sender keeps in a group at most, each, the newest "
365
+ "(100 to 2000), so a sender flooding the log with them pushes out only its own. "
366
+ "Of its security rows (content that reached the agent or left it, holds and "
367
+ "their outcomes, refusals of what it may not do) it keeps this many per "
368
+ "decision, the oldest half and a rolling newest half, and counts the rows "
369
+ "between. Decisions have no per-sender cap.",
370
+ ),
371
+ ConfigKey(
372
+ "policy.max_pending_joins_per_invite",
373
+ "policy_max_pending_joins_per_invite",
374
+ shown_default(MAX_PENDING_JOINS_PER_INVITE),
375
+ f"Join requests for one invite that may wait for your approval at once (1 to "
376
+ f"{MAX_PENDING_JOINS_PER_INVITE}). The rest are dropped silently, and you get an "
377
+ "`invite_flooded` event.",
378
+ ),
379
+ ConfigKey(
380
+ "policy.join_ledger_per_invite",
381
+ "policy_join_ledger_per_invite",
382
+ shown_default(MAX_JOIN_LEDGER_PER_INVITE),
383
+ f"The most join requests the replay ledger remembers for one invite "
384
+ f"({MIN_JOIN_LEDGER_PER_INVITE} to {MAX_JOIN_LEDGER_PER_INVITE}). An invite's "
385
+ f"share is 4 times its `max_uses`, at least {MIN_JOIN_LEDGER_PER_INVITE} and at "
386
+ "most this. Past it, new requests for that invite are dropped and you get an "
387
+ "`invite_flooded` event; other invites are unaffected.",
388
+ ),
389
+ )
390
+
391
+ CONFIG_KEYS: tuple[ConfigKey, ...] = (
392
+ ConfigKey(
393
+ "relay.url",
394
+ "relay_url",
395
+ "required",
396
+ "Relay base URL. `wss://`, or `ws://` for a loopback relay only.",
397
+ "AC_RELAY_URL",
398
+ ),
399
+ ConfigKey(
400
+ "relay.host",
401
+ "relay_host",
402
+ "the URL's `host[:port]`",
403
+ "The host string signed into relay authentication. It must match the "
404
+ "relay's `AC_RELAY_HOST`.",
405
+ "AC_RELAY_HOST",
406
+ ),
407
+ ConfigKey(
408
+ "daemon.data_dir",
409
+ "data_dir",
410
+ "`$XDG_DATA_HOME/agentconnect`",
411
+ "Keys, databases, API tokens and the API socket. Created 0700.",
412
+ "AC_DATA_DIR",
413
+ ),
414
+ ConfigKey("daemon.display_name", "display_name", "`agent`", "The name other members see."),
415
+ ConfigKey(
416
+ "daemon.adapters",
417
+ "adapters",
418
+ "`[]`",
419
+ "The adapters every group delivers its messages and events to, unless the human "
420
+ "pinned the group's own list (`agentconnect group adapters`): any of `webhook` "
421
+ "([adapters/webhook.md](adapters/webhook.md)), `sse` (the event stream of "
422
+ "`GET /v0/events`, see [api.md](api.md#event-stream)), `mcp` (the queue MCP "
423
+ "sessions attached to this daemon pull from, [adapters/mcp.md](adapters/mcp.md)) "
424
+ "and `a2a` ([adapters/a2a.md](adapters/a2a.md), needs `a2a.local_agent_url` or "
425
+ "`a2a.facade_port`), each at most once. Each adapter gets every message, in order, "
426
+ "and keeps its own delivery state. The first one with an Agent Card sends it. "
427
+ "Empty: `daemon.default_adapter`.",
428
+ ),
429
+ ConfigKey(
430
+ "daemon.default_adapter",
431
+ "default_adapter",
432
+ "`none`",
433
+ "One adapter, the same as `daemon.adapters` with one name; set one of the two, "
434
+ "not both. `none`: no adapter is named, so groups deliver to the A2A adapter when "
435
+ "`a2a.local_agent_url` or `a2a.facade_port` is set, and otherwise received "
436
+ "messages wait in the inbound queue.",
437
+ ),
438
+ ConfigKey(
439
+ "daemon.keystore",
440
+ "keystore",
441
+ "`auto`",
442
+ "`keyring` (the OS keyring), `file` (0600 files in the data directory), or "
443
+ "`auto` (the keyring when one is usable, files otherwise).",
444
+ ),
445
+ ConfigKey(
446
+ "daemon.event_retention_days",
447
+ "event_retention_days",
448
+ shown_default(DEFAULT_EVENT_RETENTION_DAYS),
449
+ f"Days an event (`agentconnect events`, `GET /v0/events`) is kept once every "
450
+ f"adapter has it (1 to {MAX_EVENT_RETENTION_DAYS}). Past 100000 events the "
451
+ "oldest go first, whether adapters have them or not.",
452
+ ),
453
+ ConfigKey(
454
+ "policy.auto_approve",
455
+ "auto_approve",
456
+ "`false`",
457
+ "Approve JoinRequests without asking a human. Leave it off unless every "
458
+ "holder of your invite links is trusted. It never applies to knocks: every "
459
+ "knock needs a person (SPEC 6.6.4), and no setting changes that.",
460
+ ),
461
+ *_POLICY_KEYS,
462
+ ConfigKey(
463
+ "api.socket",
464
+ "api_socket",
465
+ "`<data_dir>/agentconnectd.sock`",
466
+ "Unix socket for the local API.",
467
+ ),
468
+ ConfigKey(
469
+ "api.tcp_port",
470
+ "api_tcp_port",
471
+ "`0`",
472
+ "When greater than 0, serve the local API on `127.0.0.1:<port>` instead of "
473
+ "the socket (0 to 65535).",
474
+ ),
475
+ ConfigKey(
476
+ "webhook.callback_url",
477
+ "webhook_url",
478
+ "none",
479
+ "Where the webhook adapter POSTs. Required when `daemon.adapters` lists "
480
+ '`webhook` (or `default_adapter = "webhook"`). Loopback unless '
481
+ "`webhook.allow_remote` is set.",
482
+ ),
483
+ ConfigKey(
484
+ "webhook.allow_remote",
485
+ "webhook_allow_remote",
486
+ "`false`",
487
+ "Allow a `callback_url` that is not loopback. It must then use `https`.",
488
+ ),
489
+ ConfigKey(
490
+ "webhook.timeout_secs",
491
+ "webhook_timeout",
492
+ "`120`",
493
+ "How long the daemon waits for each webhook response (1 to 3600). The SDK "
494
+ "answers after your handler returns, so allow for a slow model call.",
495
+ ),
496
+ ConfigKey(
497
+ "sdk.handler_timeout_secs",
498
+ "sdk_handler_timeout",
499
+ "`120`",
500
+ "Embedded SDK only: how long a message or event handler may run (1 to 3600). "
501
+ "A handler still running then is cancelled and the delivery retried with "
502
+ "backoff, as for a webhook that timed out. Allow for a slow model call.",
503
+ ),
504
+ ConfigKey(
505
+ "sse.retention_secs",
506
+ "sse_retention",
507
+ "`3600`",
508
+ "How long the SSE adapter keeps messages and events for `Last-Event-ID` "
509
+ "resume (60 to 604800). After that the payloads are deleted and a consumer "
510
+ "resuming from before the window gets an `sse_gap` event. Used only when "
511
+ "`daemon.adapters` lists `sse`.",
512
+ ),
513
+ ConfigKey(
514
+ "a2a.local_agent_url",
515
+ "a2a_local_agent_url",
516
+ "none",
517
+ "Base URL of the local A2A agent (`http://` or `https://`) that the A2A adapter "
518
+ "hands group messages to ([adapters/a2a.md](adapters/a2a.md)).",
519
+ ),
520
+ ConfigKey(
521
+ "a2a.local_agent_token_file",
522
+ "a2a_local_agent_token_file",
523
+ "none",
524
+ "A file holding a bearer token the adapter sends to the local agent.",
525
+ ),
526
+ ConfigKey(
527
+ "a2a.card_keys_file",
528
+ "a2a_card_keys_file",
529
+ "none",
530
+ "A JWKS file with the public keys you trust to sign Agent Cards.",
531
+ ),
532
+ ConfigKey(
533
+ "a2a.facade_port",
534
+ "a2a_facade_port",
535
+ "`0`",
536
+ "The A2A facade's port on `127.0.0.1` (0 to 65535); `0` turns it off.",
537
+ ),
538
+ ConfigKey(
539
+ "a2a.context_messages",
540
+ "a2a_context_messages",
541
+ "`20`",
542
+ "Messages that didn't trigger the agent kept for its next call (0 to 100).",
543
+ ),
544
+ ConfigKey(
545
+ "a2a.frame_text",
546
+ "a2a_frame_text",
547
+ "`true`",
548
+ "Put the SPEC section 10.1 notice in front of each message as its first text part.",
549
+ ),
550
+ ConfigKey(
551
+ "a2a.reply_timeout",
552
+ "a2a_reply_timeout",
553
+ "`600`",
554
+ "Seconds a facade request waits for the first reply from the group.",
555
+ ),
556
+ ConfigKey(
557
+ "isolation.enabled",
558
+ "isolation",
559
+ "`false`",
560
+ "Separate-OS-user mode ([isolation.md](isolation.md)): the daemon runs as its "
561
+ "own user, checks the peer's UID on every connection, and serves the socket "
562
+ "and a copy of `api-token` from `isolation.agent_dir`. Needs the Unix socket "
563
+ "(no `api.tcp_port`) and refuses to start as root or with a data directory, "
564
+ "token or agent directory that others can read.",
565
+ ),
566
+ ConfigKey(
567
+ "isolation.agent_dir",
568
+ "isolation_agent_dir",
569
+ "`/run/agentconnect`",
570
+ "The directory agents use as their data directory (`AC_DATA_DIR`): the socket, "
571
+ "`api-token` and `webhook-secret`, group-readable by `isolation.group`. It must "
572
+ "be owned by the daemon's user, belong to that group and give others no access.",
573
+ ),
574
+ ConfigKey(
575
+ "isolation.group",
576
+ "isolation_group",
577
+ "`agentconnect`",
578
+ "The group (name or GID) that owns the socket and the agent directory. Agent "
579
+ "users join it.",
580
+ ),
581
+ ConfigKey(
582
+ "isolation.agent_users",
583
+ "isolation_agent_users",
584
+ "`[]`",
585
+ "Users (names or UIDs) allowed to connect with the agent token, besides the "
586
+ "daemon's own user. Any other UID is logged and refused.",
587
+ ),
588
+ ConfigKey(
589
+ "isolation.human_users",
590
+ "isolation_human_users",
591
+ "`[]`",
592
+ "Users (names or UIDs), besides the daemon's own user, from whom the human "
593
+ "token is accepted, such as `0` for `sudo agentconnect ...`. The human token "
594
+ "is still required. A user can't be in both lists.",
595
+ ),
596
+ ConfigKey(
597
+ "directory.host",
598
+ "directories",
599
+ "required",
600
+ "One `[[directory]]` table per directory you may publish a listing in "
601
+ "([discovery.md](discovery.md)). Its host, e.g. `directory.example.org`, as "
602
+ "listings and tokens name it. There is no default directory.",
603
+ kind="string",
604
+ ),
605
+ ConfigKey(
606
+ "directory.keys",
607
+ "directories",
608
+ "required",
609
+ "The directory operator's token keys (`DK.pub`, base64url): one, or two while "
610
+ "the operator rotates. They are pinned here and never fetched, not even from "
611
+ "the directory's `/.well-known/agentconnect-directory.json`.",
612
+ kind="array",
613
+ ),
614
+ ConfigKey(
615
+ "directory.url",
616
+ "directories",
617
+ "`https://<host>`",
618
+ "Where the daemon sends the directory's requests. `https://`, or `http://` "
619
+ "for a loopback directory only. It is never taken from a listing.",
620
+ kind="string",
621
+ ),
622
+ ConfigKey(
623
+ "listing.card_file",
624
+ "listing_card_file",
625
+ "`$XDG_CONFIG_HOME/agentconnect/listing-card.json`",
626
+ "The listing's card text, read by `agentconnect listing publish` as the "
627
+ "calling user ([discovery.md](discovery.md#the-card-file)).",
628
+ ),
629
+ ConfigKey(
630
+ "listing.token_file",
631
+ "listing_token_file",
632
+ "`$XDG_CONFIG_HOME/agentconnect/listing-token.json`",
633
+ "The listing token from the directory's operator, read by "
634
+ "`agentconnect listing publish`. It is not a secret.",
635
+ ),
636
+ ConfigKey(
637
+ "listing.door_lifetime_secs",
638
+ "listing_door_lifetime_secs",
639
+ shown_default(DEFAULT_DOOR_LIFETIME_SECS),
640
+ "How long each door lives (7200 to 604800). The relay counts the 7-day "
641
+ "maximum on its own clock, so the default leaves an hour for clock skew. A "
642
+ "door never outlives the listing token. The runtime renews the door at half "
643
+ "its lifetime (SPEC 6.6.1).",
644
+ ),
645
+ ConfigKey(
646
+ "knock.agent_may_knock",
647
+ "knock_agent_may_knock",
648
+ "`true`",
649
+ "Whether the agent token may knock on a listing (`agentconnect knock`, `POST "
650
+ "/v0/knocks`). `false` leaves knocking to the human token. Approving a knock is "
651
+ "always the human's, whatever this says ([discovery.md](discovery.md#knocking)).",
652
+ ),
653
+ ConfigKey(
654
+ "knock.domain_check",
655
+ "knock_domain_check",
656
+ "`true`",
657
+ "Before a knock, fetch the listing owner's `/.well-known/agentconnect.json` to "
658
+ "check the owner domain it claims. The domain's web server learns that someone "
659
+ "is about to knock, and from which address. `agentconnect knock` asks unless "
660
+ "`--yes` or `--json`; `--no-domain-check` skips it.",
661
+ ),
662
+ ConfigKey(
663
+ "knock.max_outgoing",
664
+ "knock_max_outgoing",
665
+ shown_default(MAX_OUTGOING_KNOCKS),
666
+ "Knocks this runtime may have waiting for an answer at once (1 to 8, SPEC 6.6.2).",
667
+ ),
668
+ ConfigKey(
669
+ "knock.per_hour",
670
+ "knock_per_hour",
671
+ shown_default(MAX_KNOCKS_PER_HOUR),
672
+ "Knocks this runtime sends in an hour at most (1 to 20, the relay's limit), and "
673
+ "never more than one on the same door in 10 minutes.",
674
+ ),
675
+ ConfigKey(
676
+ "knock.max_pending_per_door",
677
+ "knock_max_pending_per_door",
678
+ shown_default(MAX_PENDING_KNOCKS_PER_DOOR),
679
+ "Knocks on one of your doors that may wait for your approval at once (1 to 32, "
680
+ "SPEC 6.6.3); the rest are dropped. One per knocking identity.",
681
+ ),
682
+ ConfigKey(
683
+ "knock.opened_per_door_per_minute",
684
+ "knock_opened_per_door_per_minute",
685
+ shown_default(MAX_KNOCKS_OPENED_PER_MINUTE),
686
+ "Sealed knocks on one door the runtime opens in a minute (1 to 10); the rest are "
687
+ "dropped unopened, so a flood can't spend the CPU that opening one takes.",
688
+ ),
689
+ ConfigKey(
690
+ "knock.failures_per_sender_per_hour",
691
+ "knock_failures_per_sender_per_hour",
692
+ shown_default(DEFAULT_KNOCK_FAILURES_PER_HOUR),
693
+ "Knocks from one sender mailbox that may fail the owner's checks in an hour "
694
+ "(1 to 100). Past it, that mailbox's knocks are dropped unopened until the hour "
695
+ "is over.",
696
+ ),
697
+ ConfigKey(
698
+ "knock.ledger_capacity",
699
+ "knock_ledger_capacity",
700
+ shown_default(ac.DEFAULT_LEDGER_CAPACITY),
701
+ f"Knocks the replay ledger remembers at most ({MIN_LEDGER_CAPACITY} to "
702
+ f"{ac.DEFAULT_LEDGER_CAPACITY}). It forgets none early: when it is full, new "
703
+ "knocks are dropped until old ones expire.",
704
+ ),
705
+ ConfigKey(
706
+ "groups.leave_timeout_secs",
707
+ "groups_leave_timeout_secs",
708
+ shown_default(MAX_LEAVE_TIMEOUT_SECS),
709
+ f"How long a leave waits for another member to commit it ({MIN_LEAVE_TIMEOUT_SECS} "
710
+ f"to {MAX_LEAVE_TIMEOUT_SECS}). Then, once it has caught up with the relay, the runtime "
711
+ "deletes the group's state and reports `left` with `confirmed: false`, unless this "
712
+ "instance is the owner's: it keeps the group and reports `leave_blocked` "
713
+ "([groups.md](groups.md#a-leave-nobody-commits)).",
714
+ ),
715
+ ConfigKey(
716
+ "groups.cleanup_departed_roles",
717
+ "groups_cleanup_departed_roles",
718
+ "`true`",
719
+ "Whether the owner's instance drops an identity that left the group from `admins` and "
720
+ "`floor_moderator`, with a policy change that only narrows roles (SPEC 9.2). `false` "
721
+ "leaves the roles in place, and the identity gets them back if it rejoins "
722
+ "([groups.md](groups.md#departed-roles)).",
723
+ ),
724
+ )
725
+
726
+ # Environment variables that don't override a single key.
727
+ CONFIG_ENV_EXTRA: tuple[tuple[str, str], ...] = (("AC_CONFIG", "The path of the TOML file."),)
728
+
729
+ # Config fields with no key in the file (set in code, mostly by tests).
730
+ CONFIG_FIELDS_WITHOUT_KEY = frozenset({"relay_rest_url"})
731
+
732
+
733
+ def _check_relay_url(url: str) -> str:
734
+ parts = urlsplit(url)
735
+ if parts.scheme not in ("ws", "wss") or not parts.netloc:
736
+ msg = f"relay url must be ws:// or wss://, got {url!r}"
737
+ raise ConfigError(msg)
738
+ if parts.scheme == "ws" and parts.hostname not in ("localhost", "127.0.0.1", "::1"):
739
+ msg = "plain ws:// is only allowed for loopback relays"
740
+ raise ConfigError(msg)
741
+ return url
742
+
743
+
744
+ def _is_loopback(host: str) -> bool:
745
+ if host == "localhost":
746
+ return True
747
+ try:
748
+ return ipaddress.ip_address(host).is_loopback
749
+ except ValueError:
750
+ return False
751
+
752
+
753
+ def check_callback_url(url: str, allow_remote: bool) -> str:
754
+ """A webhook callback must be loopback unless ``allow_remote`` is set,
755
+ and a remote one must use ``https`` (message text travels in the body)."""
756
+ parts = urlsplit(url)
757
+ host = parts.hostname
758
+ if parts.scheme not in ("http", "https") or host is None or host == "":
759
+ msg = f"webhook callback_url must be http:// or https://, got {url!r}"
760
+ raise ConfigError(msg)
761
+ if "@" in parts.netloc:
762
+ msg = "webhook callback_url must not carry credentials"
763
+ raise ConfigError(msg)
764
+ loopback = _is_loopback(host)
765
+ if not loopback:
766
+ if not allow_remote:
767
+ msg = "webhook callback_url is not loopback; set webhook.allow_remote to allow it"
768
+ raise ConfigError(msg)
769
+ if parts.scheme != "https":
770
+ msg = "a remote webhook callback_url must use https"
771
+ raise ConfigError(msg)
772
+ return url
773
+
774
+
775
+ def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]:
776
+ value = data.get(name, {})
777
+ if not isinstance(value, dict):
778
+ msg = f"[{name}] must be a table"
779
+ raise ConfigError(msg)
780
+ return value
781
+
782
+
783
+ def _typed[T](table: Mapping[str, Any], key: str, kind: type[T], default: T) -> T:
784
+ value = table.get(key, default)
785
+ if not isinstance(value, kind) or (kind is int and isinstance(value, bool)):
786
+ msg = f"{key} must be {kind.__name__}"
787
+ raise ConfigError(msg)
788
+ return value
789
+
790
+
791
+ MAX_CONTEXT_MESSAGES = 100
792
+
793
+
794
+ def _path(table: Mapping[str, Any], key: str) -> Path | None:
795
+ raw = _typed(table, key, str, "")
796
+ return Path(raw).expanduser() if raw else None
797
+
798
+
799
+ def _adapters(daemon: Mapping[str, Any]) -> tuple[str, ...]:
800
+ """``daemon.adapters``, checked: known names, each once, and not next to
801
+ ``daemon.default_adapter`` (its one-element alias)."""
802
+ one = daemon.get("default_adapter")
803
+ if one is not None and one not in (*ADAPTER_NAMES, "none"):
804
+ msg = f"daemon.default_adapter: unknown adapter {one!r} (one of {ADAPTER_NAMES} or none)"
805
+ raise ConfigError(msg)
806
+ if "adapters" not in daemon:
807
+ return ()
808
+ if one is not None:
809
+ msg = "set daemon.adapters or daemon.default_adapter, not both"
810
+ raise ConfigError(msg)
811
+ names = daemon["adapters"]
812
+ if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
813
+ msg = "daemon.adapters must be a list of adapter names"
814
+ raise ConfigError(msg)
815
+ for name in names:
816
+ if name not in ADAPTER_NAMES:
817
+ msg = f"daemon.adapters: unknown adapter {name!r} (one of {ADAPTER_NAMES})"
818
+ raise ConfigError(msg)
819
+ if names.count(name) > 1:
820
+ msg = f"daemon.adapters names {name!r} more than once"
821
+ raise ConfigError(msg)
822
+ return tuple(str(n) for n in names)
823
+
824
+
825
+ def _a2a(data: Mapping[str, Any]) -> dict[str, Any]:
826
+ table = _section(data, "a2a")
827
+ url = _typed(table, "local_agent_url", str, "") or None
828
+ if url is not None:
829
+ parts = urlsplit(url)
830
+ if parts.scheme not in ("http", "https") or not parts.netloc:
831
+ msg = f"a2a.local_agent_url must be http:// or https://, got {url!r}"
832
+ raise ConfigError(msg)
833
+ port = _typed(table, "facade_port", int, 0)
834
+ if not 0 <= port <= 65535: # noqa: PLR2004
835
+ msg = "a2a.facade_port must be 0..65535"
836
+ raise ConfigError(msg)
837
+ context = _typed(table, "context_messages", int, 20)
838
+ if not 0 <= context <= MAX_CONTEXT_MESSAGES:
839
+ msg = f"a2a.context_messages must be 0..{MAX_CONTEXT_MESSAGES}"
840
+ raise ConfigError(msg)
841
+ timeout = _typed(table, "reply_timeout", int, 600)
842
+ if timeout <= 0:
843
+ msg = "a2a.reply_timeout must be a positive number of seconds"
844
+ raise ConfigError(msg)
845
+ return {
846
+ "a2a_local_agent_url": url,
847
+ "a2a_local_agent_token_file": _path(table, "local_agent_token_file"),
848
+ "a2a_card_keys_file": _path(table, "card_keys_file"),
849
+ "a2a_facade_port": port,
850
+ "a2a_context_messages": context,
851
+ "a2a_frame_text": _typed(table, "frame_text", bool, True),
852
+ "a2a_reply_timeout": timeout,
853
+ }
854
+
855
+
856
+ def _users(table: Mapping[str, Any], key: str) -> tuple[str, ...]:
857
+ raw = _typed(table, key, list, [])
858
+ if not all(isinstance(u, str | int) and not isinstance(u, bool) for u in raw):
859
+ msg = f"isolation.{key} must be a list of user names or UIDs"
860
+ raise ConfigError(msg)
861
+ return tuple(str(u) for u in raw)
862
+
863
+
864
+ def _isolation(data: Mapping[str, Any]) -> dict[str, Any]:
865
+ table = _section(data, "isolation")
866
+ group = table.get("group", "agentconnect")
867
+ if not isinstance(group, str | int) or isinstance(group, bool) or group == "":
868
+ msg = "isolation.group must be a group name or GID"
869
+ raise ConfigError(msg)
870
+ return {
871
+ "isolation": _typed(table, "enabled", bool, False),
872
+ "isolation_agent_dir": _path(table, "agent_dir") or Path("/run/agentconnect"),
873
+ "isolation_group": str(group),
874
+ "isolation_agent_users": _users(table, "agent_users"),
875
+ "isolation_human_users": _users(table, "human_users"),
876
+ }
877
+
878
+
879
+ MAX_AUDIT_RETENTION_DAYS = 365
880
+ MIN_TRANSCRIPT_BYTES = 1024 * 1024
881
+
882
+
883
+ def _policy(table: Mapping[str, Any]) -> dict[str, Any]:
884
+ """The safety policy keys of ``[policy]`` (agentconnect.policy.KNOBS)."""
885
+ out: dict[str, Any] = {}
886
+ for knob in KNOBS:
887
+ if knob.name in table:
888
+ try:
889
+ out[f"policy_{knob.name}"] = knob.check(table[knob.name])
890
+ except PolicyError as e:
891
+ msg = f"policy.{e}"
892
+ raise ConfigError(msg) from None
893
+ days = _typed(table, "audit_retention_days", int, DEFAULT_AUDIT_RETENTION_DAYS)
894
+ if not 1 <= days <= MAX_AUDIT_RETENTION_DAYS:
895
+ msg = f"policy.audit_retention_days must be 1..{MAX_AUDIT_RETENTION_DAYS}"
896
+ raise ConfigError(msg)
897
+ out["policy_audit_retention_days"] = days
898
+ size = _typed(table, "transcript_max_bytes", int, DEFAULT_TRANSCRIPT_MAX_BYTES)
899
+ if not MIN_TRANSCRIPT_BYTES <= size <= DEFAULT_TRANSCRIPT_MAX_BYTES:
900
+ msg = f"policy.transcript_max_bytes must be {MIN_TRANSCRIPT_BYTES}.."
901
+ msg += str(DEFAULT_TRANSCRIPT_MAX_BYTES)
902
+ raise ConfigError(msg)
903
+ out["policy_transcript_max_bytes"] = size
904
+ for key, lo, hi in (
905
+ ("audit_max_rows_per_group", 1000, DEFAULT_AUDIT_GROUP_ROWS),
906
+ ("audit_max_rows_per_sender", 100, DEFAULT_AUDIT_SENDER_ROWS),
907
+ ("max_pending_joins_per_invite", 1, MAX_PENDING_JOINS_PER_INVITE),
908
+ ("join_ledger_per_invite", MIN_JOIN_LEDGER_PER_INVITE, MAX_JOIN_LEDGER_PER_INVITE),
909
+ ):
910
+ value = _typed(table, key, int, hi)
911
+ if not lo <= value <= hi:
912
+ msg = f"policy.{key} must be {lo}..{hi}"
913
+ raise ConfigError(msg)
914
+ out[f"policy_{key}"] = value
915
+ return out
916
+
917
+
918
+ NEVER_AUTO = (
919
+ "auto_approve isn't a key of [{table}]: publishing a listing and approving a "
920
+ "knock always need a human"
921
+ )
922
+
923
+
924
+ def _closed(table: Mapping[str, Any], name: str, allowed: frozenset[str]) -> None:
925
+ """The discovery tables refuse keys they don't know (a typo would
926
+ otherwise leave a default in force), ``auto_approve`` with a reason."""
927
+ if "auto_approve" in table:
928
+ raise ConfigError(NEVER_AUTO.format(table=name))
929
+ unknown = sorted(set(table) - allowed)
930
+ if unknown:
931
+ msg = f"unknown key(s) in [{name}]: {', '.join(unknown)}"
932
+ raise ConfigError(msg)
933
+
934
+
935
+ def _b64url(text: str) -> bytes | None:
936
+ """Canonical unpadded base64url, or None."""
937
+ try:
938
+ raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
939
+ except (ValueError, binascii.Error):
940
+ return None
941
+ canonical = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
942
+ return raw if canonical == text else None
943
+
944
+
945
+ def _directory_url(url: str, host: str) -> str:
946
+ parts = urlsplit(url)
947
+ plain = parts.scheme == "http" and parts.hostname is not None and _is_loopback(parts.hostname)
948
+ if parts.scheme != "https" and not plain:
949
+ msg = f"directory.url must be https:// (or http:// for a loopback directory), got {url!r}"
950
+ raise ConfigError(msg)
951
+ if parts.hostname in (None, "") or "@" in parts.netloc or parts.query or parts.fragment:
952
+ msg = f"directory.url for {host} must be a plain base URL"
953
+ raise ConfigError(msg)
954
+ if parts.path not in ("", "/"):
955
+ msg = f"directory.url for {host} must not have a path"
956
+ raise ConfigError(msg)
957
+ if parts.scheme == "https" and _never_dialled(str(parts.hostname)):
958
+ # The client dials public addresses only (agentconnect.ownerfile);
959
+ # a name must resolve to one too, or requests fail as `blocked`.
960
+ msg = (
961
+ f"directory.url for {host} names a loopback or private address; an https "
962
+ "directory must be on a public address (http:// is for a loopback test directory)"
963
+ )
964
+ raise ConfigError(msg)
965
+ return f"{parts.scheme}://{parts.netloc}"
966
+
967
+
968
+ def _never_dialled(hostname: str) -> bool:
969
+ if hostname == "localhost" or hostname.endswith(".localhost"):
970
+ return True
971
+ try:
972
+ ipaddress.ip_address(hostname)
973
+ except ValueError:
974
+ return False
975
+ return not ac.is_public_address(hostname)
976
+
977
+
978
+ def _directories(data: Mapping[str, Any]) -> tuple[DirectoryConfig, ...]:
979
+ """The ``[[directory]]`` tables (none by default: ADR-006, decision D1)."""
980
+ raw = data.get("directory", [])
981
+ if not isinstance(raw, list) or not all(isinstance(t, dict) for t in raw):
982
+ msg = "directories are [[directory]] tables (an array of tables)"
983
+ raise ConfigError(msg)
984
+ out: list[DirectoryConfig] = []
985
+ for table in raw:
986
+ _closed(table, "directory", frozenset({"host", "keys", "url"}))
987
+ host_raw = _typed(table, "host", str, "")
988
+ try:
989
+ host = ac.canonical_relay_host(host_raw)
990
+ except ac.AgentConnectError:
991
+ msg = f"directory.host {host_raw!r} is not a valid host"
992
+ raise ConfigError(msg) from None
993
+ texts = _typed(table, "keys", list, [])
994
+ if not 1 <= len(texts) <= MAX_DIRECTORY_KEYS or not all(isinstance(k, str) for k in texts):
995
+ msg = f"directory.keys for {host} must list one or two DK.pub values"
996
+ raise ConfigError(msg)
997
+ keys = tuple(k for k in (_b64url(t) for t in texts) if k is not None)
998
+ if len(keys) != len(texts):
999
+ msg = f"directory.keys for {host} must be unpadded base64url"
1000
+ raise ConfigError(msg)
1001
+ try:
1002
+ ac.check_directory_keys(host, list(keys))
1003
+ except ac.AgentConnectError as e:
1004
+ msg = f"directory.keys for {host}: {e}"
1005
+ raise ConfigError(msg) from None
1006
+ if any(d.host == host for d in out):
1007
+ msg = f"directory {host} is configured twice"
1008
+ raise ConfigError(msg)
1009
+ url = _directory_url(_typed(table, "url", str, "") or f"https://{host}", host)
1010
+ out.append(DirectoryConfig(host, keys, url))
1011
+ return tuple(out)
1012
+
1013
+
1014
+ def _listing(data: Mapping[str, Any], environ: Mapping[str, str]) -> dict[str, Any]:
1015
+ table = _section(data, "listing")
1016
+ _closed(table, "listing", frozenset({"card_file", "token_file", "door_lifetime_secs"}))
1017
+ lifetime = _typed(table, "door_lifetime_secs", int, DEFAULT_DOOR_LIFETIME_SECS)
1018
+ if not MIN_DOOR_LIFETIME_SECS <= lifetime <= ac.MAX_DOOR_LIFETIME_SECS:
1019
+ msg = (
1020
+ f"listing.door_lifetime_secs must be {MIN_DOOR_LIFETIME_SECS}.."
1021
+ f"{ac.MAX_DOOR_LIFETIME_SECS} (SPEC 6.6.1)"
1022
+ )
1023
+ raise ConfigError(msg)
1024
+ home = _xdg(environ, "XDG_CONFIG_HOME", ".config")
1025
+ return {
1026
+ "listing_card_file": _path(table, "card_file") or home / "listing-card.json",
1027
+ "listing_token_file": _path(table, "token_file") or home / "listing-token.json",
1028
+ "listing_door_lifetime_secs": lifetime,
1029
+ }
1030
+
1031
+
1032
+ #: ``[knock]`` integer keys: (default, lowest, highest). The highest is the
1033
+ #: SPEC's limit, so a setting can only tighten it.
1034
+ _KNOCK_LIMITS: dict[str, tuple[int, int, int]] = {
1035
+ "max_outgoing": (MAX_OUTGOING_KNOCKS, 1, MAX_OUTGOING_KNOCKS),
1036
+ "per_hour": (MAX_KNOCKS_PER_HOUR, 1, MAX_KNOCKS_PER_HOUR),
1037
+ "max_pending_per_door": (MAX_PENDING_KNOCKS_PER_DOOR, 1, MAX_PENDING_KNOCKS_PER_DOOR),
1038
+ "opened_per_door_per_minute": (
1039
+ MAX_KNOCKS_OPENED_PER_MINUTE,
1040
+ 1,
1041
+ MAX_KNOCKS_OPENED_PER_MINUTE,
1042
+ ),
1043
+ "failures_per_sender_per_hour": (
1044
+ DEFAULT_KNOCK_FAILURES_PER_HOUR,
1045
+ 1,
1046
+ MAX_KNOCK_FAILURES_PER_HOUR,
1047
+ ),
1048
+ "ledger_capacity": (
1049
+ ac.DEFAULT_LEDGER_CAPACITY,
1050
+ MIN_LEDGER_CAPACITY,
1051
+ ac.DEFAULT_LEDGER_CAPACITY,
1052
+ ),
1053
+ }
1054
+
1055
+
1056
+ def _knock(data: Mapping[str, Any]) -> dict[str, Any]:
1057
+ """``[knock]``. There is no key that approves a knock (SPEC 6.6.4)."""
1058
+ table = _section(data, "knock")
1059
+ _closed(table, "knock", frozenset({"agent_may_knock", "domain_check", *_KNOCK_LIMITS}))
1060
+ out: dict[str, Any] = {
1061
+ "knock_agent_may_knock": _typed(table, "agent_may_knock", bool, True),
1062
+ "knock_domain_check": _typed(table, "domain_check", bool, True),
1063
+ }
1064
+ for key, (default, lo, hi) in _KNOCK_LIMITS.items():
1065
+ value = _typed(table, key, int, default)
1066
+ if not lo <= value <= hi:
1067
+ msg = f"knock.{key} must be {lo}..{hi} (a local limit may only be lower)"
1068
+ raise ConfigError(msg)
1069
+ out[f"knock_{key}"] = value
1070
+ return out
1071
+
1072
+
1073
+ def _groups(data: Mapping[str, Any]) -> dict[str, Any]:
1074
+ """``[groups]``: membership (docs/groups.md). Every key can only tighten."""
1075
+ table = _section(data, "groups")
1076
+ unknown = sorted(set(table) - {"leave_timeout_secs", "cleanup_departed_roles"})
1077
+ if unknown:
1078
+ msg = f"unknown key(s) in [groups]: {', '.join(unknown)}"
1079
+ raise ConfigError(msg)
1080
+ timeout = _typed(table, "leave_timeout_secs", int, MAX_LEAVE_TIMEOUT_SECS)
1081
+ if not MIN_LEAVE_TIMEOUT_SECS <= timeout <= MAX_LEAVE_TIMEOUT_SECS:
1082
+ msg = (
1083
+ f"groups.leave_timeout_secs must be {MIN_LEAVE_TIMEOUT_SECS}..{MAX_LEAVE_TIMEOUT_SECS}"
1084
+ )
1085
+ raise ConfigError(msg)
1086
+ return {
1087
+ "groups_leave_timeout_secs": timeout,
1088
+ "groups_cleanup_departed_roles": _typed(table, "cleanup_departed_roles", bool, True),
1089
+ }
1090
+
1091
+
1092
+ def _sdk(data: Mapping[str, Any]) -> dict[str, Any]:
1093
+ timeout = _typed(_section(data, "sdk"), "handler_timeout_secs", int, 120)
1094
+ if not 1 <= timeout <= 3600: # noqa: PLR2004
1095
+ msg = "sdk handler_timeout_secs must be 1..3600"
1096
+ raise ConfigError(msg)
1097
+ return {"sdk_handler_timeout": timeout}
1098
+
1099
+
1100
+ def _resolve_relay(values: dict[str, Any]) -> None:
1101
+ """Validate the relay URL and fill in its host. Both stay empty when no
1102
+ relay is configured (``Config.require_relay`` rejects that where one is needed)."""
1103
+ if values["relay_url"]:
1104
+ values["relay_url"] = _check_relay_url(values["relay_url"])
1105
+ if not values["relay_host"]:
1106
+ # The URL's authority without any userinfo (SPEC 8.1.1).
1107
+ values["relay_host"] = urlsplit(values["relay_url"]).netloc.rpartition("@")[2]
1108
+ if values["relay_host"]:
1109
+ try:
1110
+ values["relay_host"] = ac.canonical_relay_host(values["relay_host"])
1111
+ except ac.AgentConnectError:
1112
+ msg = f"relay host {values['relay_host']!r} is not a valid host[:port]"
1113
+ raise ConfigError(msg) from None
1114
+
1115
+
1116
+ def _event_retention(daemon: Mapping[str, Any]) -> int:
1117
+ days = _typed(daemon, "event_retention_days", int, DEFAULT_EVENT_RETENTION_DAYS)
1118
+ if not 1 <= days <= MAX_EVENT_RETENTION_DAYS:
1119
+ msg = f"daemon.event_retention_days must be 1..{MAX_EVENT_RETENTION_DAYS}"
1120
+ raise ConfigError(msg)
1121
+ return days
1122
+
1123
+
1124
+ def load_config(
1125
+ path: Path | None = None,
1126
+ environ: Mapping[str, str] = os.environ,
1127
+ *,
1128
+ use_file: bool = True,
1129
+ **overrides: Any,
1130
+ ) -> Config:
1131
+ """Read the TOML file (if present), then apply env and keyword overrides.
1132
+ ``use_file=False`` reads no file at all (the embedded SDK, which never
1133
+ picks up the separate daemon's ``config.toml`` by accident)."""
1134
+ if path is None:
1135
+ env_path = environ.get("AC_CONFIG", "")
1136
+ path = Path(env_path) if env_path else default_config_path(environ)
1137
+ data: dict[str, Any] = {}
1138
+ if use_file and path.exists():
1139
+ try:
1140
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
1141
+ except tomllib.TOMLDecodeError as e:
1142
+ msg = f"{path}: {e}"
1143
+ raise ConfigError(msg) from None
1144
+ relay = _section(data, "relay")
1145
+ daemon = _section(data, "daemon")
1146
+ policy = _section(data, "policy")
1147
+ api = _section(data, "api")
1148
+ webhook = _section(data, "webhook")
1149
+ sse = _section(data, "sse")
1150
+
1151
+ relay_url = environ.get("AC_RELAY_URL") or _typed(relay, "url", str, "")
1152
+ data_dir_raw = environ.get("AC_DATA_DIR") or _typed(daemon, "data_dir", str, "")
1153
+ socket_raw = _typed(api, "socket", str, "")
1154
+ keystore = _typed(daemon, "keystore", str, "auto")
1155
+ if keystore not in KEYSTORES:
1156
+ msg = f"keystore must be one of {KEYSTORES}"
1157
+ raise ConfigError(msg)
1158
+ port = _typed(api, "tcp_port", int, 0)
1159
+ if not 0 <= port <= 65535: # noqa: PLR2004
1160
+ msg = "tcp_port must be 0..65535"
1161
+ raise ConfigError(msg)
1162
+ values: dict[str, Any] = {
1163
+ "relay_url": relay_url,
1164
+ "relay_host": environ.get("AC_RELAY_HOST") or _typed(relay, "host", str, ""),
1165
+ "data_dir": Path(data_dir_raw).expanduser() if data_dir_raw else default_data_dir(environ),
1166
+ "display_name": _typed(daemon, "display_name", str, "agent"),
1167
+ "default_adapter": _typed(daemon, "default_adapter", str, "none"),
1168
+ "adapters": _adapters(daemon),
1169
+ "keystore": keystore,
1170
+ "event_retention_days": _event_retention(daemon),
1171
+ "auto_approve": _typed(policy, "auto_approve", bool, False),
1172
+ "api_socket": Path(socket_raw).expanduser() if socket_raw else None,
1173
+ "api_tcp_port": port,
1174
+ "webhook_url": _typed(webhook, "callback_url", str, "") or None,
1175
+ "webhook_allow_remote": _typed(webhook, "allow_remote", bool, False),
1176
+ "webhook_timeout": _typed(webhook, "timeout_secs", int, 120),
1177
+ "sse_retention": _typed(sse, "retention_secs", int, 3600),
1178
+ }
1179
+ values.update(_a2a(data) | _isolation(data) | _policy(policy) | _sdk(data))
1180
+ values.update(_listing(data, environ), directories=_directories(data))
1181
+ values.update(_knock(data) | _groups(data))
1182
+ values.update(overrides)
1183
+ if not 60 <= values["sse_retention"] <= 604_800: # noqa: PLR2004
1184
+ msg = "sse retention_secs must be 60..604800"
1185
+ raise ConfigError(msg)
1186
+ if not 1 <= values["webhook_timeout"] <= 3600: # noqa: PLR2004
1187
+ msg = "webhook timeout_secs must be 1..3600"
1188
+ raise ConfigError(msg)
1189
+ if values["webhook_url"] is not None:
1190
+ check_callback_url(values["webhook_url"], values["webhook_allow_remote"])
1191
+ _resolve_relay(values)
1192
+ cfg = Config(**values)
1193
+ if cfg.webhook_url is None and "webhook" in cfg.adapter_names:
1194
+ msg = 'adapter "webhook" needs webhook.callback_url'
1195
+ raise ConfigError(msg)
1196
+ if not cfg.a2a_enabled and "a2a" in cfg.adapter_names:
1197
+ msg = 'adapter "a2a" needs a2a.local_agent_url or a2a.facade_port'
1198
+ raise ConfigError(msg)
1199
+ for warning in config_warnings(cfg):
1200
+ LOG.warning("config_warning", warning=warning)
1201
+ return cfg
1202
+
1203
+
1204
+ def config_warnings(cfg: Config) -> list[str]:
1205
+ """What the config allows but probably doesn't mean."""
1206
+ out = []
1207
+ if cfg.a2a_enabled and cfg.adapter_names and "a2a" not in cfg.adapter_names:
1208
+ out.append(
1209
+ "[a2a] is set but daemon.adapters doesn't list a2a: the A2A adapter runs, "
1210
+ "but groups deliver to it only where the human pins it "
1211
+ "(agentconnect group adapters), so its facade gets no replies elsewhere"
1212
+ )
1213
+ return out