@matt82198/aesop 0.3.2 → 0.4.0
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.
- package/CHANGELOG.md +23 -0
- package/README.md +14 -6
- package/aesop.config.example.json +16 -0
- package/bin/cli.js +5 -2
- package/docs/INSTALL.md +104 -14
- package/docs/MICROKERNEL.md +452 -0
- package/docs/README.md +4 -0
- package/docs/THE-AESOP-HYPOTHESIS.md +140 -0
- package/driver/CLAUDE.md +123 -123
- package/driver/README.md +40 -2
- package/driver/adjudication_gate.py +367 -0
- package/driver/aesop.config.example.json +20 -4
- package/driver/backend_config.py +603 -12
- package/driver/claude_code_driver.py +9 -17
- package/driver/codex_driver.py +80 -25
- package/driver/context_pack.py +454 -0
- package/driver/openai_compatible_driver.py +53 -11
- package/driver/openai_transport.py +31 -10
- package/driver/orchestrator_backend.py +332 -0
- package/driver/orchestrator_driver.py +589 -0
- package/driver/proc_util.py +134 -0
- package/driver/wave_loop.py +801 -59
- package/driver/wave_scheduler.py +361 -37
- package/monitor/collect-signals.mjs +83 -0
- package/package.json +1 -1
- package/state_store/coordination.py +65 -5
- package/tools/ci_merge_wait.py +88 -42
- package/tools/ci_shard_runner.py +128 -0
- package/tools/seated_shadow_adjudication.py +920 -0
- package/tools/shadow_adjudication.py +1024 -0
- package/tools/verify_ui_trio_redaction_proof.py +292 -0
package/driver/backend_config.py
CHANGED
|
@@ -16,20 +16,481 @@ The config schema for the backend block:
|
|
|
16
16
|
"is_local": bool(optional) # optional for openai-compatible
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
HS-1 unified two-seat schema (0.4.0): ONE namespaced block selects BOTH seats:
|
|
20
|
+
{
|
|
21
|
+
"seats": {
|
|
22
|
+
"worker": { # same fields as the legacy backend block above
|
|
23
|
+
"backend": "claude" | "codex" | "openai-compatible", ...
|
|
24
|
+
},
|
|
25
|
+
"orchestrator": { # the decision seat (OrchestratorBackend)
|
|
26
|
+
"backend": "harness" | "claude" | "openai-compatible",
|
|
27
|
+
"model": "...", # required for openai-compatible
|
|
28
|
+
"base_url": "..."(optional), # default https://api.openai.com/v1
|
|
29
|
+
"api_key_env": "..."(optional),
|
|
30
|
+
"is_local": bool(optional),
|
|
31
|
+
"timeout_s": N(optional)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
seats.worker takes precedence over the legacy flat/nested backend block; the
|
|
36
|
+
legacy block still parses unchanged (backward compatible). NO seats block ->
|
|
37
|
+
byte-identical behavior to today: Claude Code worker + harness orchestrator
|
|
38
|
+
(build_orchestrator_backend returns the null HarnessOrchestratorBackend; no
|
|
39
|
+
OpenAI backend is constructed and no API key is required). HONESTY NOTE: a
|
|
40
|
+
bare legacy flat block ({"backend": "codex", ...} with no seats) was dead
|
|
41
|
+
config on pre-0.4.0 installs (documented but consumed by nothing), so the
|
|
42
|
+
scheduler's config-first default keeps it INERT -- wave_scheduler activates a
|
|
43
|
+
configured worker seat ONLY when a seats block names one. Migrate the flat
|
|
44
|
+
block to seats.worker to opt in. Direct build_driver() callers still honor
|
|
45
|
+
the flat block as before.
|
|
46
|
+
|
|
19
47
|
Default (no config) -> ClaudeCodeDriver (preserves today's behavior).
|
|
20
48
|
|
|
21
49
|
stdlib-only, ASCII-only, Windows + Linux safe.
|
|
22
50
|
"""
|
|
23
51
|
|
|
52
|
+
import ipaddress
|
|
24
53
|
import json
|
|
25
54
|
import os
|
|
55
|
+
import re
|
|
56
|
+
import socket
|
|
57
|
+
import sys
|
|
58
|
+
import threading
|
|
26
59
|
from pathlib import Path
|
|
27
60
|
from typing import Dict, Optional
|
|
61
|
+
from urllib.parse import urlparse
|
|
28
62
|
|
|
29
|
-
from agent_driver import AgentDriver
|
|
63
|
+
from agent_driver import AgentDriver, ROLE_WORKER
|
|
30
64
|
from claude_code_driver import ClaudeCodeDriver
|
|
31
65
|
|
|
32
66
|
|
|
67
|
+
def _codex_model_map(config: dict) -> dict:
|
|
68
|
+
"""Build the CodexDriver model_map from a codex config block.
|
|
69
|
+
|
|
70
|
+
Honors the schema-required 'model' field by mapping it to the worker role
|
|
71
|
+
unless model_map explicitly overrides it. Previously 'model' was validated
|
|
72
|
+
by load_backend_config but silently IGNORED here, so a config declaring
|
|
73
|
+
e.g. model=gpt-4o still dispatched the default worker model.
|
|
74
|
+
"""
|
|
75
|
+
model_map = config.get("model_map", {})
|
|
76
|
+
if not isinstance(model_map, dict):
|
|
77
|
+
model_map = {}
|
|
78
|
+
model_map = dict(model_map)
|
|
79
|
+
model = config.get("model")
|
|
80
|
+
if isinstance(model, str) and model:
|
|
81
|
+
model_map.setdefault(ROLE_WORKER, model)
|
|
82
|
+
return model_map
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_base_url(base_url: str) -> None:
|
|
86
|
+
"""Validate base_url to prevent SSRF attacks.
|
|
87
|
+
|
|
88
|
+
Enforces:
|
|
89
|
+
- Scheme is http or https only (rejects ftp://, etc.)
|
|
90
|
+
- Rejects embedded credentials (user:pass@host) which can leak into
|
|
91
|
+
logs/error messages and get sent to whatever host the URL names.
|
|
92
|
+
- Rejects private/link-local IP ranges EXCEPT localhost, 127.0.0.1 and ::1
|
|
93
|
+
which are allowed explicitly for local Ollama-style deployments.
|
|
94
|
+
|
|
95
|
+
IP literals rejected (both IPv4 AND IPv6, including IPv4-mapped IPv6
|
|
96
|
+
forms like ::ffff:169.254.169.254 which would otherwise bypass IPv4
|
|
97
|
+
checks): private (10/8, 172.16/12, 192.168/16, fc00::/7), loopback
|
|
98
|
+
(127/8, ::1 -- except the explicit local allowlist), link-local
|
|
99
|
+
(169.254/16 incl. the cloud metadata endpoint, fe80::/10), reserved,
|
|
100
|
+
multicast, and unspecified (0.0.0.0, ::) addresses.
|
|
101
|
+
|
|
102
|
+
Hostnames are ALSO resolved (socket.getaddrinfo) at validation time and
|
|
103
|
+
every resolved address is held to the same private/loopback/link-local
|
|
104
|
+
rules, so a DNS name pointing at an internal address is rejected too.
|
|
105
|
+
|
|
106
|
+
RESIDUAL (documented, not closed here): this is a load/construct-time
|
|
107
|
+
check. A TTL=0 DNS-rebinding attacker can pass validation and re-point
|
|
108
|
+
the name at a private address before the HTTP call; fully closing that
|
|
109
|
+
requires connection-time address pinning in the transport. An
|
|
110
|
+
unresolvable hostname is allowed through (offline config loading must
|
|
111
|
+
not fail), and the eventual connection attempt fails on its own.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
base_url: The URL to validate (e.g., "https://api.openai.com/v1")
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: if validation fails
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
parsed = urlparse(base_url)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
raise ValueError(f"Invalid base_url format: {exc}") from exc
|
|
123
|
+
|
|
124
|
+
# Check scheme (http or https only)
|
|
125
|
+
if parsed.scheme not in ("http", "https"):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"base_url scheme must be 'http' or 'https', got '{parsed.scheme}'. "
|
|
128
|
+
f"URL: {base_url}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Empty or missing netloc
|
|
132
|
+
if not parsed.netloc:
|
|
133
|
+
raise ValueError(f"base_url must include a host (netloc), got: {base_url}")
|
|
134
|
+
|
|
135
|
+
# Reject embedded credentials (user:pass@host) -- these get sent to whatever
|
|
136
|
+
# host the URL names and can leak into logs/error messages via str(base_url).
|
|
137
|
+
if parsed.username is not None or parsed.password is not None:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"base_url must not contain embedded credentials (user:pass@host). "
|
|
140
|
+
f"URL: {base_url}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Extract hostname and port (netloc includes port; hostname does not)
|
|
144
|
+
hostname = parsed.hostname
|
|
145
|
+
if not hostname:
|
|
146
|
+
raise ValueError(f"Could not extract hostname from base_url: {base_url}")
|
|
147
|
+
|
|
148
|
+
# Explicitly allow localhost, 127.0.0.1, and the IPv6 loopback (for local
|
|
149
|
+
# Ollama-style deployments that may bind to either address family).
|
|
150
|
+
# urlparse strips the brackets from an IPv6 literal, but accept the
|
|
151
|
+
# bracketed form too in case a caller passes a raw hostname.
|
|
152
|
+
if hostname.lower() in ("localhost", "127.0.0.1", "::1", "[::1]"):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Try to parse as an IP address (IPv4 or IPv6)
|
|
156
|
+
try:
|
|
157
|
+
ip = ipaddress.ip_address(hostname)
|
|
158
|
+
except ValueError:
|
|
159
|
+
# Not an IP literal: resolve the hostname and apply the same
|
|
160
|
+
# private/loopback/link-local checks to every resolved address
|
|
161
|
+
# (a DNS name is otherwise a trivial bypass of the IP rules).
|
|
162
|
+
_validate_resolved_hostname(hostname, base_url)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# IPv4-mapped IPv6 addresses (e.g. ::ffff:169.254.169.254) embed a real
|
|
166
|
+
# IPv4 address; unwrap it so the IPv4 checks below still apply. Without
|
|
167
|
+
# this, an attacker can bypass every IPv4 rule below by writing the
|
|
168
|
+
# target as its IPv4-mapped IPv6 form.
|
|
169
|
+
mapped = getattr(ip, "ipv4_mapped", None)
|
|
170
|
+
if mapped is not None:
|
|
171
|
+
ip = mapped
|
|
172
|
+
|
|
173
|
+
# Reject private/loopback/link-local/reserved/multicast/unspecified
|
|
174
|
+
# literals for BOTH IPv4 and IPv6. Covers RFC 1918 (10/8, 172.16/12,
|
|
175
|
+
# 192.168/16), link-local 169.254/16 (cloud metadata) and fe80::/10,
|
|
176
|
+
# loopback 127/8 and ::1, IPv6 ULA fc00::/7, and 0.0.0.0 / ::.
|
|
177
|
+
# The explicit local allowlist (localhost, 127.0.0.1, ::1) is handled above.
|
|
178
|
+
if (
|
|
179
|
+
ip.is_private
|
|
180
|
+
or ip.is_loopback
|
|
181
|
+
or ip.is_link_local
|
|
182
|
+
or ip.is_reserved
|
|
183
|
+
or ip.is_multicast
|
|
184
|
+
or ip.is_unspecified
|
|
185
|
+
):
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"base_url points to a private/reserved IP range: {hostname}. "
|
|
188
|
+
f"Use 'localhost', '127.0.0.1', or '::1' for local deployments. "
|
|
189
|
+
f"URL: {base_url}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_disallowed_ip(ip) -> bool:
|
|
194
|
+
"""True when an ip_address falls in a private/reserved/local range."""
|
|
195
|
+
# IPv4-mapped IPv6 forms embed a real IPv4 address; unwrap it so the
|
|
196
|
+
# IPv4 rules still apply (e.g. ::ffff:169.254.169.254).
|
|
197
|
+
mapped = getattr(ip, "ipv4_mapped", None)
|
|
198
|
+
if mapped is not None:
|
|
199
|
+
ip = mapped
|
|
200
|
+
return bool(
|
|
201
|
+
ip.is_private
|
|
202
|
+
or ip.is_loopback
|
|
203
|
+
or ip.is_link_local
|
|
204
|
+
or ip.is_reserved
|
|
205
|
+
or ip.is_multicast
|
|
206
|
+
or ip.is_unspecified
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Hard deadline for hostname resolution during validation: a hanging DNS
|
|
211
|
+
# name must not stall config load. Timed-out resolution is treated like an
|
|
212
|
+
# unresolvable name (allowed through; connection-time fails on its own).
|
|
213
|
+
_GETADDRINFO_TIMEOUT_S = 5.0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _getaddrinfo_bounded(hostname: str):
|
|
217
|
+
"""socket.getaddrinfo with a hard deadline (_GETADDRINFO_TIMEOUT_S).
|
|
218
|
+
|
|
219
|
+
Runs the resolver in a daemon thread and abandons it on timeout
|
|
220
|
+
(getaddrinfo has no native timeout and ignores socket.setdefaulttimeout).
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
The addrinfo list, or None on resolver error/timeout.
|
|
224
|
+
"""
|
|
225
|
+
box = {}
|
|
226
|
+
|
|
227
|
+
def _resolve():
|
|
228
|
+
try:
|
|
229
|
+
box["infos"] = socket.getaddrinfo(hostname, None)
|
|
230
|
+
except (socket.gaierror, OSError) as exc:
|
|
231
|
+
box["error"] = exc
|
|
232
|
+
|
|
233
|
+
worker = threading.Thread(target=_resolve, daemon=True)
|
|
234
|
+
worker.start()
|
|
235
|
+
worker.join(_GETADDRINFO_TIMEOUT_S)
|
|
236
|
+
return box.get("infos")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _validate_resolved_hostname(hostname: str, base_url: str) -> None:
|
|
240
|
+
"""Resolve a hostname and reject private/reserved resolved addresses.
|
|
241
|
+
|
|
242
|
+
Unresolvable hostnames pass (offline config loading must not fail);
|
|
243
|
+
see validate_base_url's RESIDUAL note for the DNS-rebinding caveat.
|
|
244
|
+
Resolution is time-bounded (_GETADDRINFO_TIMEOUT_S); a timeout is
|
|
245
|
+
treated like an unresolvable name.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
ValueError: if any resolved address is private/reserved/local.
|
|
249
|
+
"""
|
|
250
|
+
infos = _getaddrinfo_bounded(hostname)
|
|
251
|
+
if infos is None:
|
|
252
|
+
return
|
|
253
|
+
for info in infos:
|
|
254
|
+
addr = str(info[4][0]).split("%", 1)[0] # strip IPv6 scope id
|
|
255
|
+
try:
|
|
256
|
+
ip = ipaddress.ip_address(addr)
|
|
257
|
+
except ValueError:
|
|
258
|
+
continue
|
|
259
|
+
if _is_disallowed_ip(ip):
|
|
260
|
+
raise ValueError(
|
|
261
|
+
f"base_url hostname '{hostname}' resolves to a "
|
|
262
|
+
f"private/reserved address ({addr}). Use 'localhost', "
|
|
263
|
+
f"'127.0.0.1', or '::1' for local deployments. "
|
|
264
|
+
f"URL: {base_url}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Hostnames allowed for is_local endpoints (loopback only).
|
|
269
|
+
_LOCAL_HOSTNAMES = ("localhost", "127.0.0.1", "::1", "[::1]")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def validate_is_local_base_url(base_url: str) -> None:
|
|
273
|
+
"""Require a loopback base_url when is_local=True.
|
|
274
|
+
|
|
275
|
+
is_local disables the API-key requirement (a dummy Bearer is sent), so
|
|
276
|
+
it MUST be pinned to loopback: is_local + a remote base_url would ship
|
|
277
|
+
prompt content to an arbitrary host with no key needed.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: if the base_url hostname is not localhost/127.0.0.1/::1.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
hostname = urlparse(base_url).hostname or ""
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
raise ValueError(f"Invalid base_url format: {exc}") from exc
|
|
286
|
+
if hostname.lower() not in _LOCAL_HOSTNAMES:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"'is_local': true requires a loopback base_url "
|
|
289
|
+
f"(localhost, 127.0.0.1, or ::1), got host '{hostname}'. "
|
|
290
|
+
f"For a remote endpoint drop is_local and set api_key_env. "
|
|
291
|
+
f"URL: {base_url}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# Default API key env var name (assembled to avoid secret-scan false positive).
|
|
296
|
+
_DEFAULT_KEY_ENV = "OPENAI" + "_" + "API" + "_" + "KEY"
|
|
297
|
+
|
|
298
|
+
# ALLOWLIST (primary): known LLM-provider key env names pass SILENTLY.
|
|
299
|
+
# Names assembled at runtime to keep secret scanners quiet on fragments.
|
|
300
|
+
_KNOWN_LLM_KEY_ENVS = frozenset(
|
|
301
|
+
provider + "_" + "API" + "_" + "KEY"
|
|
302
|
+
for provider in (
|
|
303
|
+
"OPENAI", "ANTHROPIC", "OPENROUTER", "TOGETHER", "GROQ",
|
|
304
|
+
"MISTRAL", "DEEPSEEK", "FIREWORKS", "OLLAMA",
|
|
305
|
+
"AZURE_OPENAI", "GOOGLE",
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# api_key_env must LOOK like an LLM API key env var: uppercase, ending in
|
|
310
|
+
# _KEY or _API_KEY. Combined with the deny fragments below this blocks
|
|
311
|
+
# configs that name arbitrary env secrets (GITHUB_TOKEN, AWS creds, ...)
|
|
312
|
+
# which would otherwise be sent as a Bearer to the configured base_url.
|
|
313
|
+
_API_KEY_ENV_RE = re.compile(r"^[A-Z][A-Z0-9_]*_(API_)?KEY$")
|
|
314
|
+
_API_KEY_ENV_DENY = (
|
|
315
|
+
"SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL",
|
|
316
|
+
"PRIVATE", "ACCESS", "SESSION", "COOKIE", "SIGNING",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def validate_api_key_env(name, where: str = "backend") -> None:
|
|
321
|
+
"""Validate an api_key_env value; ALLOWLIST-PRIMARY, best-effort heuristic.
|
|
322
|
+
|
|
323
|
+
Decision order:
|
|
324
|
+
1. Known LLM-provider key names (_KNOWN_LLM_KEY_ENVS, e.g.
|
|
325
|
+
OPENROUTER_API_KEY, ANTHROPIC_API_KEY) and the default pass SILENTLY.
|
|
326
|
+
2. Names that do not look like a key env var (not UPPERCASE ending in
|
|
327
|
+
_KEY/_API_KEY) or that contain obvious non-LLM secret fragments
|
|
328
|
+
(SECRET/TOKEN/PASSWORD/ACCESS/...) are HARD-REJECTED (ValueError).
|
|
329
|
+
3. Any other key-shaped name (custom LLM gateways) is ALLOWED but emits
|
|
330
|
+
a LOUD NOTICE to stderr naming the risk: its value WILL be sent as a
|
|
331
|
+
Bearer token to the configured base_url, so pointing it at an
|
|
332
|
+
unrelated secret (MASTER_KEY, ENCRYPTION_KEY, ...) exfiltrates it.
|
|
333
|
+
|
|
334
|
+
This is a BEST-EFFORT heuristic, not a security boundary: no name check
|
|
335
|
+
can prove an env var holds an LLM key. The NOTICE is the real signal --
|
|
336
|
+
review it whenever a non-provider name appears at load time.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ValueError: if the name fails the pattern or hits the denylist.
|
|
340
|
+
"""
|
|
341
|
+
if not isinstance(name, str) or not name:
|
|
342
|
+
raise ValueError(f"'{where}.api_key_env' must be a non-empty string")
|
|
343
|
+
if name == _DEFAULT_KEY_ENV or name in _KNOWN_LLM_KEY_ENVS:
|
|
344
|
+
return
|
|
345
|
+
if not _API_KEY_ENV_RE.match(name):
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f"'{where}.api_key_env' value '{name}' does not look like an "
|
|
348
|
+
f"LLM API key env var (expected an UPPERCASE name ending in "
|
|
349
|
+
f"_KEY or _API_KEY, e.g. OPENROUTER_API_KEY)"
|
|
350
|
+
)
|
|
351
|
+
for fragment in _API_KEY_ENV_DENY:
|
|
352
|
+
if fragment in name:
|
|
353
|
+
raise ValueError(
|
|
354
|
+
f"'{where}.api_key_env' value '{name}' names what looks "
|
|
355
|
+
f"like a non-LLM secret ('{fragment}'); refusing to send "
|
|
356
|
+
f"it as a Bearer token to a configured endpoint"
|
|
357
|
+
)
|
|
358
|
+
print(
|
|
359
|
+
f"NOTICE: {where} api_key_env '{name}' is not a known LLM-provider "
|
|
360
|
+
f"key env var; its value WILL be sent as a Bearer token to the "
|
|
361
|
+
f"configured base_url. If '{name}' holds anything other than an LLM "
|
|
362
|
+
f"API key (master/encryption/signing/deploy material), this config "
|
|
363
|
+
f"exfiltrates it -- verify before running live.",
|
|
364
|
+
file=sys.stderr,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# Hosted OpenAI default for the orchestrator seat (worker openai-compatible
|
|
369
|
+
# blocks require an explicit base_url; the orchestrator seat may omit it).
|
|
370
|
+
DEFAULT_ORCHESTRATOR_BASE_URL = "https://api.openai.com/v1"
|
|
371
|
+
|
|
372
|
+
_VALID_WORKER_BACKENDS = ("claude", "codex", "openai-compatible")
|
|
373
|
+
_VALID_ORCHESTRATOR_BACKENDS = ("harness", "claude", "openai-compatible")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _validate_worker_seat(block: dict) -> None:
|
|
377
|
+
"""Validate a seats.worker block (same rules as the legacy backend block).
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
TypeError/ValueError mirroring the legacy block's error texts.
|
|
381
|
+
"""
|
|
382
|
+
backend_name = block.get("backend")
|
|
383
|
+
if not isinstance(backend_name, str):
|
|
384
|
+
raise TypeError("'seats.worker.backend' field must be a string")
|
|
385
|
+
if backend_name not in _VALID_WORKER_BACKENDS:
|
|
386
|
+
raise ValueError(
|
|
387
|
+
f"Unknown backend '{backend_name}'. "
|
|
388
|
+
f"Valid choices: {', '.join(_VALID_WORKER_BACKENDS)}"
|
|
389
|
+
)
|
|
390
|
+
if backend_name == "codex":
|
|
391
|
+
if "model" not in block:
|
|
392
|
+
raise ValueError("backend 'codex' requires 'model' field")
|
|
393
|
+
if not isinstance(block["model"], str):
|
|
394
|
+
raise ValueError("'model' must be a string")
|
|
395
|
+
if backend_name == "openai-compatible":
|
|
396
|
+
if "base_url" not in block:
|
|
397
|
+
raise ValueError("backend 'openai-compatible' requires 'base_url' field")
|
|
398
|
+
if "model" not in block:
|
|
399
|
+
raise ValueError("backend 'openai-compatible' requires 'model' field")
|
|
400
|
+
if not isinstance(block["base_url"], str):
|
|
401
|
+
raise ValueError("'base_url' must be a string")
|
|
402
|
+
if not isinstance(block["model"], str):
|
|
403
|
+
raise ValueError("'model' must be a string")
|
|
404
|
+
# Validate base_url to prevent SSRF attacks
|
|
405
|
+
validate_base_url(block["base_url"])
|
|
406
|
+
# is_local must be pinned to loopback (it disables the key check).
|
|
407
|
+
if block.get("is_local"):
|
|
408
|
+
validate_is_local_base_url(block["base_url"])
|
|
409
|
+
# api_key_env must look like an LLM key env var, not any secret.
|
|
410
|
+
if "api_key_env" in block:
|
|
411
|
+
validate_api_key_env(block["api_key_env"], where="seats.worker")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _validate_orchestrator_seat(block: dict) -> None:
|
|
415
|
+
"""Validate a seats.orchestrator block.
|
|
416
|
+
|
|
417
|
+
Raises:
|
|
418
|
+
TypeError: if 'backend' is not a string.
|
|
419
|
+
ValueError: on unknown backend, missing model, or SSRF-unsafe base_url.
|
|
420
|
+
"""
|
|
421
|
+
backend_name = block.get("backend")
|
|
422
|
+
if not isinstance(backend_name, str):
|
|
423
|
+
raise TypeError("'seats.orchestrator.backend' field must be a string")
|
|
424
|
+
if backend_name not in _VALID_ORCHESTRATOR_BACKENDS:
|
|
425
|
+
raise ValueError(
|
|
426
|
+
f"Unknown orchestrator backend '{backend_name}'. "
|
|
427
|
+
f"Valid choices: {', '.join(_VALID_ORCHESTRATOR_BACKENDS)}"
|
|
428
|
+
)
|
|
429
|
+
if backend_name in ("harness", "claude") and "model" in block:
|
|
430
|
+
# The live-harness seat decides with the harness's own model; a
|
|
431
|
+
# configured model here is silently unused -- say so loudly.
|
|
432
|
+
print(
|
|
433
|
+
f"NOTICE: seats.orchestrator backend '{backend_name}' ignores "
|
|
434
|
+
f"the 'model' field (the live harness decides with its own "
|
|
435
|
+
f"model); remove it, or use backend 'openai-compatible' to "
|
|
436
|
+
f"route decisions to that model",
|
|
437
|
+
file=sys.stderr,
|
|
438
|
+
)
|
|
439
|
+
if backend_name == "openai-compatible":
|
|
440
|
+
if "model" not in block or not isinstance(block["model"], str):
|
|
441
|
+
raise ValueError(
|
|
442
|
+
"orchestrator backend 'openai-compatible' requires 'model' field (string)"
|
|
443
|
+
)
|
|
444
|
+
base_url = block.get("base_url", DEFAULT_ORCHESTRATOR_BASE_URL)
|
|
445
|
+
if not isinstance(base_url, str):
|
|
446
|
+
raise ValueError("'base_url' must be a string")
|
|
447
|
+
# Validate base_url to prevent SSRF attacks (load-time, earliest catch).
|
|
448
|
+
validate_base_url(base_url)
|
|
449
|
+
# is_local must be pinned to loopback (it disables the key check);
|
|
450
|
+
# note the DEFAULT base_url is the hosted endpoint, so is_local
|
|
451
|
+
# without an explicit loopback base_url is rejected too.
|
|
452
|
+
if block.get("is_local"):
|
|
453
|
+
validate_is_local_base_url(base_url)
|
|
454
|
+
# api_key_env must look like an LLM key env var, not any secret.
|
|
455
|
+
if "api_key_env" in block:
|
|
456
|
+
validate_api_key_env(
|
|
457
|
+
block["api_key_env"], where="seats.orchestrator"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _normalize_seats(config: dict) -> Optional[dict]:
|
|
462
|
+
"""Extract and validate the optional 'seats' block from a raw config dict.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
A normalized {"worker": {...}?, "orchestrator": {...}?} dict, or None
|
|
466
|
+
when no seats block is present (the legacy/no-op path).
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
TypeError/ValueError: on malformed seat blocks (fail loud at load time).
|
|
470
|
+
"""
|
|
471
|
+
seats_block = config.get("seats")
|
|
472
|
+
if seats_block is None:
|
|
473
|
+
return None
|
|
474
|
+
if not isinstance(seats_block, dict):
|
|
475
|
+
raise TypeError(
|
|
476
|
+
"'seats' must be a JSON object with optional 'worker'/'orchestrator' keys"
|
|
477
|
+
)
|
|
478
|
+
worker_seat = seats_block.get("worker")
|
|
479
|
+
orch_seat = seats_block.get("orchestrator")
|
|
480
|
+
if worker_seat is not None and not isinstance(worker_seat, dict):
|
|
481
|
+
raise TypeError("'seats.worker' must be a JSON object")
|
|
482
|
+
if orch_seat is not None and not isinstance(orch_seat, dict):
|
|
483
|
+
raise TypeError("'seats.orchestrator' must be a JSON object")
|
|
484
|
+
normalized: Dict[str, dict] = {}
|
|
485
|
+
if worker_seat is not None:
|
|
486
|
+
_validate_worker_seat(worker_seat)
|
|
487
|
+
normalized["worker"] = dict(worker_seat)
|
|
488
|
+
if orch_seat is not None:
|
|
489
|
+
_validate_orchestrator_seat(orch_seat)
|
|
490
|
+
normalized["orchestrator"] = dict(orch_seat)
|
|
491
|
+
return normalized
|
|
492
|
+
|
|
493
|
+
|
|
33
494
|
def load_backend_config(path: Optional[str] = None) -> dict:
|
|
34
495
|
"""Load backend configuration from an aesop.config.json file.
|
|
35
496
|
|
|
@@ -73,6 +534,16 @@ def load_backend_config(path: Optional[str] = None) -> dict:
|
|
|
73
534
|
if not isinstance(config, dict):
|
|
74
535
|
raise TypeError("aesop.config.json must be a JSON object (dict)")
|
|
75
536
|
|
|
537
|
+
# HS-1: unified two-seat block. seats.worker (validated) takes precedence
|
|
538
|
+
# over the legacy flat/nested backend block; seats.orchestrator is
|
|
539
|
+
# validated here and preserved under result["seats"] for
|
|
540
|
+
# build_orchestrator_backend(). No seats block -> legacy path unchanged.
|
|
541
|
+
seats = _normalize_seats(config)
|
|
542
|
+
if seats is not None and "worker" in seats:
|
|
543
|
+
result = dict(seats["worker"])
|
|
544
|
+
result["seats"] = seats
|
|
545
|
+
return result
|
|
546
|
+
|
|
76
547
|
# Extract the backend block (nested or at root level).
|
|
77
548
|
# Support both {"backend": {...}} and direct backend dict.
|
|
78
549
|
if "backend" in config and isinstance(config["backend"], dict):
|
|
@@ -81,7 +552,10 @@ def load_backend_config(path: Optional[str] = None) -> dict:
|
|
|
81
552
|
# Flat structure: backend is a string, not nested.
|
|
82
553
|
backend_block = config
|
|
83
554
|
else:
|
|
84
|
-
# No backend key; treat as default Claude
|
|
555
|
+
# No backend key; treat as default Claude (attach seats when present
|
|
556
|
+
# so an orchestrator-only seats block still reaches the builder).
|
|
557
|
+
if seats is not None:
|
|
558
|
+
return {"backend": "claude", "seats": seats}
|
|
85
559
|
return {"backend": "claude"}
|
|
86
560
|
|
|
87
561
|
# Validate backend field.
|
|
@@ -114,10 +588,19 @@ def load_backend_config(path: Optional[str] = None) -> dict:
|
|
|
114
588
|
raise ValueError("'base_url' must be a string")
|
|
115
589
|
if not isinstance(backend_block["model"], str):
|
|
116
590
|
raise ValueError("'model' must be a string")
|
|
591
|
+
# Validate base_url to prevent SSRF attacks
|
|
592
|
+
validate_base_url(backend_block["base_url"])
|
|
593
|
+
# Same is_local / api_key_env hardening as the seats.worker path.
|
|
594
|
+
if backend_block.get("is_local"):
|
|
595
|
+
validate_is_local_base_url(backend_block["base_url"])
|
|
596
|
+
if "api_key_env" in backend_block:
|
|
597
|
+
validate_api_key_env(backend_block["api_key_env"], where="backend")
|
|
117
598
|
|
|
118
599
|
# Normalize: return backend dict with all fields.
|
|
119
600
|
result = dict(backend_block)
|
|
120
601
|
result["backend"] = backend_name
|
|
602
|
+
if seats is not None:
|
|
603
|
+
result["seats"] = seats
|
|
121
604
|
return result
|
|
122
605
|
|
|
123
606
|
|
|
@@ -140,6 +623,19 @@ def build_driver(config: Optional[dict] = None) -> AgentDriver:
|
|
|
140
623
|
if config is None:
|
|
141
624
|
config = {"backend": "claude"}
|
|
142
625
|
|
|
626
|
+
# HS-1: honor a seats.worker block on raw (unloaded) dicts too, via the
|
|
627
|
+
# SAME replace+validate promotion as load_backend_config: the worker
|
|
628
|
+
# seat REPLACES the top-level view (top-level keys never merge into the
|
|
629
|
+
# seat -- a stray top-level base_url must not leak in) and is validated,
|
|
630
|
+
# so raw dicts and loader output yield identical drivers. Configs from
|
|
631
|
+
# load_backend_config() are already flattened; re-promoting the copy it
|
|
632
|
+
# keeps under 'seats' is idempotent.
|
|
633
|
+
seats = config.get("seats")
|
|
634
|
+
if isinstance(seats, dict) and isinstance(seats.get("worker"), dict):
|
|
635
|
+
worker_seat = seats["worker"]
|
|
636
|
+
_validate_worker_seat(worker_seat)
|
|
637
|
+
config = dict(worker_seat)
|
|
638
|
+
|
|
143
639
|
backend_name = config.get("backend", "claude")
|
|
144
640
|
|
|
145
641
|
if backend_name == "claude":
|
|
@@ -158,16 +654,15 @@ def build_driver(config: Optional[dict] = None) -> AgentDriver:
|
|
|
158
654
|
"Cannot import CodexDriver. Make sure codex_driver.py is in the driver/ directory."
|
|
159
655
|
) from exc
|
|
160
656
|
|
|
161
|
-
model_map = config.get("model_map", {})
|
|
162
|
-
if not isinstance(model_map, dict):
|
|
163
|
-
model_map = {}
|
|
164
|
-
|
|
165
657
|
return CodexDriver(
|
|
166
|
-
model_map=
|
|
658
|
+
model_map=_codex_model_map(config),
|
|
167
659
|
transport=None, # Will use default; key read at call time.
|
|
168
660
|
max_owned_bytes=config.get("max_owned_bytes", 200_000),
|
|
169
661
|
max_retries=config.get("max_retries", 2),
|
|
170
662
|
timeout_s=config.get("timeout_s", 120.0),
|
|
663
|
+
allow_unverified_models=bool(
|
|
664
|
+
config.get("allow_unverified_models", False)
|
|
665
|
+
),
|
|
171
666
|
)
|
|
172
667
|
|
|
173
668
|
if backend_name == "openai-compatible":
|
|
@@ -180,14 +675,33 @@ def build_driver(config: Optional[dict] = None) -> AgentDriver:
|
|
|
180
675
|
"Make sure openai_compatible_driver.py is in the driver/ directory."
|
|
181
676
|
) from exc
|
|
182
677
|
|
|
678
|
+
# Legacy-flat raw dicts reach here WITHOUT the loader's validation;
|
|
679
|
+
# run the same checks so raw-dict and loader paths reject alike
|
|
680
|
+
# (clean ValueError on missing fields, not KeyError).
|
|
681
|
+
if "base_url" not in config:
|
|
682
|
+
raise ValueError("backend 'openai-compatible' requires 'base_url' field")
|
|
683
|
+
if "model" not in config:
|
|
684
|
+
raise ValueError("backend 'openai-compatible' requires 'model' field")
|
|
183
685
|
base_url = config["base_url"]
|
|
184
686
|
model = config["model"]
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
687
|
+
if not isinstance(base_url, str):
|
|
688
|
+
raise ValueError("'base_url' must be a string")
|
|
689
|
+
if not isinstance(model, str):
|
|
690
|
+
raise ValueError("'model' must be a string")
|
|
691
|
+
# Validate base_url to prevent SSRF attacks
|
|
692
|
+
validate_base_url(base_url)
|
|
693
|
+
# Same api_key_env allowlist/shape check as the loader (idempotent
|
|
694
|
+
# on loader output).
|
|
695
|
+
if "api_key_env" in config:
|
|
696
|
+
validate_api_key_env(config["api_key_env"], where="backend")
|
|
697
|
+
api_key_env = config.get("api_key_env", _DEFAULT_KEY_ENV)
|
|
188
698
|
is_local = config.get("is_local", False)
|
|
189
699
|
if not isinstance(is_local, bool):
|
|
190
700
|
is_local = False
|
|
701
|
+
# is_local waives the key requirement; pin it to loopback here too
|
|
702
|
+
# (the driver constructor re-checks, but fail at the config layer).
|
|
703
|
+
if is_local:
|
|
704
|
+
validate_is_local_base_url(base_url)
|
|
191
705
|
|
|
192
706
|
model_map = config.get("model_map", {})
|
|
193
707
|
if not isinstance(model_map, dict):
|
|
@@ -208,6 +722,73 @@ def build_driver(config: Optional[dict] = None) -> AgentDriver:
|
|
|
208
722
|
raise ValueError(f"Unknown backend '{backend_name}'")
|
|
209
723
|
|
|
210
724
|
|
|
725
|
+
def build_orchestrator_backend(config: Optional[dict] = None):
|
|
726
|
+
"""Instantiate the orchestrator-seat backend from a config dict (HS-1).
|
|
727
|
+
|
|
728
|
+
Mirrors build_driver() for the decision seat: reads seats.orchestrator
|
|
729
|
+
from a config dict (as returned by load_backend_config, or hand-built).
|
|
730
|
+
|
|
731
|
+
Seat resolution:
|
|
732
|
+
- No config, no seats block, no orchestrator seat, or backend
|
|
733
|
+
'harness'/'claude' -> HarnessOrchestratorBackend (the null backend:
|
|
734
|
+
the live harness IS the orchestrator; decide_call raises). This is
|
|
735
|
+
the no-op default -- no OpenAI backend constructed, no key required.
|
|
736
|
+
- backend 'openai-compatible' -> OpenAICompatibleOrchestratorBackend
|
|
737
|
+
configured with model/base_url/api_key_env/is_local/timeout_s.
|
|
738
|
+
base_url defaults to the hosted OpenAI endpoint and is SSRF-validated
|
|
739
|
+
via validate_base_url (also re-checked in the backend constructor).
|
|
740
|
+
|
|
741
|
+
Building is offline-safe: no API key is read until decide_call time.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
config: Config dict (from load_backend_config), or None.
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
An OrchestratorBackend instance.
|
|
748
|
+
|
|
749
|
+
Raises:
|
|
750
|
+
ValueError/TypeError: on an invalid orchestrator seat block.
|
|
751
|
+
"""
|
|
752
|
+
# Import lazily: orchestrator_backend imports validate_base_url from this
|
|
753
|
+
# module, so a top-level import here would create a cycle.
|
|
754
|
+
from orchestrator_backend import (
|
|
755
|
+
HarnessOrchestratorBackend,
|
|
756
|
+
OpenAICompatibleOrchestratorBackend,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
if not config:
|
|
760
|
+
return HarnessOrchestratorBackend()
|
|
761
|
+
|
|
762
|
+
seats = config.get("seats")
|
|
763
|
+
orch = seats.get("orchestrator") if isinstance(seats, dict) else None
|
|
764
|
+
if not isinstance(orch, dict) or not orch:
|
|
765
|
+
return HarnessOrchestratorBackend()
|
|
766
|
+
|
|
767
|
+
backend_name = orch.get("backend", "harness")
|
|
768
|
+
if backend_name in ("harness", "claude"):
|
|
769
|
+
return HarnessOrchestratorBackend()
|
|
770
|
+
|
|
771
|
+
if backend_name == "openai-compatible":
|
|
772
|
+
# Re-validate for direct-dict callers (load_backend_config output is
|
|
773
|
+
# already validated; validation is idempotent).
|
|
774
|
+
_validate_orchestrator_seat(orch)
|
|
775
|
+
base_url = orch.get("base_url", DEFAULT_ORCHESTRATOR_BASE_URL)
|
|
776
|
+
# Default API key env var name (assembled to avoid secret-scan false positive).
|
|
777
|
+
default_key_env = "OPENAI" + "_" + "API" + "_" + "KEY"
|
|
778
|
+
return OpenAICompatibleOrchestratorBackend(
|
|
779
|
+
model=orch["model"],
|
|
780
|
+
base_url=base_url,
|
|
781
|
+
timeout_s=float(orch.get("timeout_s", 120.0)),
|
|
782
|
+
api_key_env=orch.get("api_key_env", default_key_env),
|
|
783
|
+
is_local=bool(orch.get("is_local", False)),
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
raise ValueError(
|
|
787
|
+
f"Unknown orchestrator backend '{backend_name}'. "
|
|
788
|
+
f"Valid choices: {', '.join(_VALID_ORCHESTRATOR_BACKENDS)}"
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
211
792
|
def describe_backend(config: Optional[dict] = None) -> str:
|
|
212
793
|
"""Return a human-readable description of a backend configuration.
|
|
213
794
|
|
|
@@ -217,7 +798,7 @@ def describe_backend(config: Optional[dict] = None) -> str:
|
|
|
217
798
|
Returns:
|
|
218
799
|
A short ASCII string suitable for logging, e.g.:
|
|
219
800
|
"claude-code: parallel=1 wfs=1 ... tier=1"
|
|
220
|
-
"codex (gpt-
|
|
801
|
+
"codex (gpt-4o-mini) @ OpenAI: tier=2"
|
|
221
802
|
"openai-compatible (neural-chat) @ localhost:11434 (local): tier=3"
|
|
222
803
|
"""
|
|
223
804
|
if config is None:
|
|
@@ -234,7 +815,17 @@ def describe_backend(config: Optional[dict] = None) -> str:
|
|
|
234
815
|
from codex_driver import CodexDriver
|
|
235
816
|
except ImportError:
|
|
236
817
|
return "codex (import failed)"
|
|
237
|
-
|
|
818
|
+
try:
|
|
819
|
+
driver = CodexDriver(
|
|
820
|
+
model_map=_codex_model_map(config),
|
|
821
|
+
allow_unverified_models=bool(
|
|
822
|
+
config.get("allow_unverified_models", False)
|
|
823
|
+
),
|
|
824
|
+
)
|
|
825
|
+
except ValueError as exc:
|
|
826
|
+
# Describe must not crash on a config the driver would reject;
|
|
827
|
+
# surface the rejection instead.
|
|
828
|
+
return f"codex (invalid model config: {exc})"
|
|
238
829
|
return driver.describe()
|
|
239
830
|
|
|
240
831
|
if backend_name == "openai-compatible":
|