agentconnect-cli 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
agentconnect/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
|