@matt82198/aesop 0.3.1 → 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.
@@ -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=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
- # Default API key env var name (assembled to avoid secret-scan false positive).
186
- default_key_env = "OPENAI" + "_" + "API" + "_" + "KEY"
187
- api_key_env = config.get("api_key_env", default_key_env)
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-3.5-turbo) @ OpenAI: tier=2"
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
- driver = CodexDriver(model_map=config.get("model_map", {}))
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":