@matt82198/aesop 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +24 -9
  3. package/aesop.config.example.json +16 -0
  4. package/bin/cli.js +107 -34
  5. package/daemons/run-watchdog.sh +6 -2
  6. package/docs/CONFIGURE.md +31 -20
  7. package/docs/FIRST-WAVE.md +29 -9
  8. package/docs/INSTALL.md +181 -26
  9. package/docs/MICROKERNEL.md +452 -0
  10. package/docs/PORTING.md +4 -0
  11. package/docs/README.md +7 -3
  12. package/docs/THE-AESOP-HYPOTHESIS.md +156 -0
  13. package/driver/CLAUDE.md +123 -123
  14. package/driver/README.md +40 -2
  15. package/driver/adjudication_gate.py +367 -0
  16. package/driver/aesop.config.example.json +20 -4
  17. package/driver/backend_config.py +603 -12
  18. package/driver/claude_code_driver.py +9 -17
  19. package/driver/codex_driver.py +80 -25
  20. package/driver/context_pack.py +454 -0
  21. package/driver/openai_compatible_driver.py +53 -11
  22. package/driver/openai_transport.py +31 -10
  23. package/driver/orchestrator_backend.py +332 -0
  24. package/driver/orchestrator_driver.py +589 -0
  25. package/driver/proc_util.py +134 -0
  26. package/driver/wave_loop.py +801 -59
  27. package/driver/wave_scheduler.py +361 -37
  28. package/monitor/collect-signals.mjs +83 -0
  29. package/package.json +1 -1
  30. package/state_store/coordination.py +65 -5
  31. package/tools/ci_merge_wait.py +88 -42
  32. package/tools/ci_shard_runner.py +128 -0
  33. package/tools/doctor.js +124 -12
  34. package/tools/reproduce.js +11 -2
  35. package/tools/seated_shadow_adjudication.py +920 -0
  36. package/tools/self_stats.py +61 -6
  37. package/tools/shadow_adjudication.py +1024 -0
  38. package/tools/verify_test_suite_count.py +250 -0
  39. package/tools/verify_ui_trio_redaction_proof.py +292 -0
  40. package/tools/wave_manifest_lint.py +485 -0
  41. package/tools/wave_scorecard.py +430 -0
  42. package/ui/config.py +1 -1
  43. package/ui/cost.py +176 -3
  44. package/ui/web/dist/assets/{index-CP68RIh3.js → index-4rp6B_ID.js} +1 -1
  45. package/ui/web/dist/assets/{index-CNQxaiOW.css → index-B2lTtpsH.css} +1 -1
  46. package/ui/web/dist/index.html +2 -2
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env python3
2
+ """Context pack builder for the OrchestratorDriver seam.
3
+
4
+ Enforces cardinal rule 4 in code: the orchestrator reads ONLY control files
5
+ from the file brain, never arbitrary paths. build_context_pack() constructs
6
+ size-bounded, deterministic snapshots from allowlisted sources with a manifest
7
+ of what was included/truncated.
8
+
9
+ Context sources are logical names, NOT paths:
10
+ 'state' -> STATE.md (from repo/conductor root)
11
+ 'buildlog_tail' -> last N lines of BUILDLOG.md
12
+ 'tracker_open' -> subset of open items from state/tracker.json
13
+ 'brief:<explicit-path>' -> explicitly passed file under repo/conductor allowlist
14
+
15
+ Attempts to read arbitrary paths raise ContextPackViolation (the code-level
16
+ enforcement of cardinal rule 4).
17
+
18
+ Packs are size-bounded (default ~32KB) with deterministic truncation
19
+ (oldest-first for logs).
20
+
21
+ stdlib-only, ASCII-only, Windows + Linux safe.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Dict, List, Optional, Tuple
29
+
30
+
31
+ class ContextPackViolation(Exception):
32
+ """Raised when a context pack request violates the allowlist."""
33
+ pass
34
+
35
+
36
+ @dataclass
37
+ class ContextPack:
38
+ """Snapshot of orchestrator-readable control files, size-bounded.
39
+
40
+ Attributes:
41
+ decision_type: Decision class name (e.g., 'rank_backlog', 'adjudicate_findings').
42
+ sources_requested: Logical names passed to build_context_pack().
43
+ content: Dict mapping source -> (text content | error message).
44
+ evidence: Dict mapping evidence_name -> text (code excerpts, repro output, etc.).
45
+ manifest: List of {source, included, truncated, truncation_reason, size_bytes}.
46
+ evidence_manifest: List of {name, included, truncated, truncation_reason, size_bytes}.
47
+ total_size_bytes: Sum of all content sizes.
48
+ total_size_cap: The size limit enforced (default ~32KB).
49
+ evidence_size_bytes: Sum of all evidence sizes.
50
+ evidence_size_cap: Separate size limit for evidence (default ~4KB).
51
+ """
52
+
53
+ decision_type: str
54
+ sources_requested: Tuple[str, ...] = field(default_factory=tuple)
55
+ content: Dict[str, str] = field(default_factory=dict)
56
+ evidence: Dict[str, str] = field(default_factory=dict)
57
+ manifest: List[Dict] = field(default_factory=list)
58
+ evidence_manifest: List[Dict] = field(default_factory=list)
59
+ total_size_bytes: int = 0
60
+ total_size_cap: int = 32768 # ~32KB default
61
+ evidence_size_bytes: int = 0
62
+ evidence_size_cap: int = 4096 # ~4KB default for evidence
63
+
64
+
65
+ def _is_allowed_path(path: str, repo_root: str, conductor_root: str) -> bool:
66
+ """Check if a path is in the allowlist (repo or conductor root only).
67
+
68
+ Args:
69
+ path: The path to check (normalized, absolute or relative).
70
+ repo_root: Root of the aesop repo.
71
+ conductor_root: Root of the conductor3 directory.
72
+
73
+ Returns:
74
+ True if the path is under repo_root or conductor_root; False otherwise.
75
+ """
76
+ p = Path(path).resolve()
77
+ try:
78
+ p.relative_to(Path(repo_root).resolve())
79
+ return True
80
+ except ValueError:
81
+ pass
82
+
83
+ try:
84
+ p.relative_to(Path(conductor_root).resolve())
85
+ return True
86
+ except ValueError:
87
+ pass
88
+
89
+ return False
90
+
91
+
92
+ def build_context_pack(
93
+ decision_type: str,
94
+ sources: Dict[str, Optional[str]],
95
+ repo_root: Optional[str] = None,
96
+ conductor_root: Optional[str] = None,
97
+ size_cap: int = 32768,
98
+ evidence: Optional[Dict[str, str]] = None,
99
+ evidence_cap: int = 4096,
100
+ ) -> ContextPack:
101
+ """Build a size-bounded context pack from allowlisted sources.
102
+
103
+ The orchestrator reads ONLY:
104
+ - STATE.md (the working plan/phase).
105
+ - BUILDLOG.md (append-only wave audit trail).
106
+ - MEMORY.md and similar control files (git-tracked state).
107
+ - state/tracker.json (work items).
108
+ - Explicitly passed files under the repo or conductor roots.
109
+ - Evidence excerpts (code snippets, repro output, etc.) passed explicitly.
110
+
111
+ Args:
112
+ decision_type: Name of the decision (e.g., 'rank_backlog').
113
+ sources: Dict mapping logical names to file paths or None.
114
+ Recognized logical sources:
115
+ 'state' -> reads STATE.md from repo/conductor root.
116
+ 'buildlog_tail' -> last N lines of BUILDLOG.md.
117
+ 'tracker_open' -> open items subset of tracker.json.
118
+ 'brief:<path>' -> explicit path (must be under allowlist).
119
+
120
+ repo_root: Path to the aesop repo root. If None, uses cwd.
121
+ conductor_root: Path to conductor3 root. If None, assumes ~/conductor3.
122
+ size_cap: Size limit in bytes for main content (default 32KB). Truncation
123
+ is deterministic (oldest-first for logs).
124
+ evidence: Optional dict mapping evidence_name -> evidence_text.
125
+ Evidence must be passed explicitly (not read from arbitrary paths).
126
+ Appended to pack under '[evidence]' section; size-bounded separately.
127
+ evidence_cap: Size limit in bytes for evidence content (default 4KB).
128
+ Truncation is deterministic (truncate end of text).
129
+
130
+ Returns:
131
+ ContextPack with content dict + evidence dict + manifest.
132
+
133
+ Raises:
134
+ ContextPackViolation: if a source path violates the allowlist.
135
+ """
136
+ if repo_root is None:
137
+ repo_root = os.getcwd()
138
+ if conductor_root is None:
139
+ conductor_root = os.path.expanduser("~/conductor3")
140
+
141
+ pack = ContextPack(
142
+ decision_type=decision_type,
143
+ sources_requested=tuple(sources.keys()),
144
+ total_size_cap=size_cap,
145
+ evidence_size_cap=evidence_cap,
146
+ )
147
+
148
+ for source_name, source_spec in sources.items():
149
+ if not source_name:
150
+ continue
151
+
152
+ content_dict, manifest_entry = _read_source(
153
+ source_name, source_spec, repo_root, conductor_root
154
+ )
155
+
156
+ pack.content.update(content_dict)
157
+ pack.manifest.append(manifest_entry)
158
+ for text in content_dict.values():
159
+ pack.total_size_bytes += len(text.encode("utf-8"))
160
+
161
+ # Enforce size cap on main content: truncate oldest-first (log sources first).
162
+ if pack.total_size_bytes > pack.total_size_cap:
163
+ _truncate_pack(pack, pack.total_size_cap)
164
+
165
+ # Add evidence if provided (explicit, allowlist-only).
166
+ if evidence:
167
+ for evidence_name, evidence_text in evidence.items():
168
+ if not evidence_name:
169
+ continue
170
+
171
+ pack.evidence[evidence_name] = evidence_text
172
+ evidence_bytes = len(evidence_text.encode("utf-8"))
173
+ pack.evidence_size_bytes += evidence_bytes
174
+
175
+ pack.evidence_manifest.append(
176
+ {
177
+ "name": evidence_name,
178
+ "included": True,
179
+ "truncated": False,
180
+ "truncation_reason": None,
181
+ "size_bytes": evidence_bytes,
182
+ }
183
+ )
184
+
185
+ # Enforce size cap on evidence: truncate end-of-text if needed.
186
+ if pack.evidence_size_bytes > pack.evidence_size_cap:
187
+ _truncate_evidence(pack, pack.evidence_size_cap)
188
+
189
+ return pack
190
+
191
+
192
+ def _read_source(
193
+ source_name: str,
194
+ source_spec: Optional[str],
195
+ repo_root: str,
196
+ conductor_root: str,
197
+ ) -> Tuple[Dict[str, str], Dict]:
198
+ """Read one context source and return (content_dict, manifest_entry).
199
+
200
+ Args:
201
+ source_name: Logical name (e.g., 'state', 'buildlog_tail:50').
202
+ source_spec: File path or None (for implicit sources like 'state').
203
+ repo_root: Repo root path.
204
+ conductor_root: Conductor root path.
205
+
206
+ Returns:
207
+ (dict of {source_name: text}, manifest_entry dict)
208
+
209
+ Raises:
210
+ ContextPackViolation: if the path is not allowlisted.
211
+ """
212
+ manifest = {
213
+ "source": source_name,
214
+ "included": False,
215
+ "truncated": False,
216
+ "truncation_reason": None,
217
+ "size_bytes": 0,
218
+ }
219
+
220
+ # Implicit sources (no path given).
221
+ if source_name == "state":
222
+ # Read STATE.md from repo or conductor root.
223
+ state_file = Path(repo_root) / "STATE.md"
224
+ if not state_file.exists():
225
+ state_file = Path(conductor_root) / "STATE.md"
226
+ if state_file.exists():
227
+ try:
228
+ text = state_file.read_text(encoding="utf-8")
229
+ manifest["included"] = True
230
+ manifest["size_bytes"] = len(text.encode("utf-8"))
231
+ return {source_name: text}, manifest
232
+ except OSError as e:
233
+ return {source_name: f"Error reading STATE.md: {e}"}, manifest
234
+ else:
235
+ return {source_name: "STATE.md not found"}, manifest
236
+
237
+ elif source_name.startswith("buildlog_tail:"):
238
+ # Read last N lines of BUILDLOG.md.
239
+ parts = source_name.split(":", 1)
240
+ try:
241
+ tail_count = int(parts[1])
242
+ except (ValueError, IndexError):
243
+ tail_count = 20 # default
244
+
245
+ buildlog_file = Path(repo_root) / "BUILDLOG.md"
246
+ if not buildlog_file.exists():
247
+ buildlog_file = Path(conductor_root) / "BUILDLOG.md"
248
+
249
+ if buildlog_file.exists():
250
+ try:
251
+ content = buildlog_file.read_text(encoding="utf-8")
252
+ # Split into lines (preserving empty lines except trailing).
253
+ all_lines = content.split("\n")
254
+ # Remove last empty line if file ended with newline.
255
+ if all_lines and all_lines[-1] == "":
256
+ all_lines = all_lines[:-1]
257
+ # Take last N lines.
258
+ tail_lines = all_lines[-tail_count:] if tail_count > 0 else all_lines
259
+ text = "\n".join(tail_lines)
260
+ manifest["included"] = True
261
+ manifest["size_bytes"] = len(text.encode("utf-8"))
262
+ return {source_name: text}, manifest
263
+ except OSError as e:
264
+ return {source_name: f"Error reading BUILDLOG.md: {e}"}, manifest
265
+ else:
266
+ return {source_name: "BUILDLOG.md not found"}, manifest
267
+
268
+ elif source_name == "tracker_open":
269
+ # Read open items from state/tracker.json.
270
+ tracker_file = Path(repo_root) / "state" / "tracker.json"
271
+ if not tracker_file.exists():
272
+ tracker_file = Path(conductor_root) / "state" / "tracker.json"
273
+
274
+ if tracker_file.exists():
275
+ try:
276
+ with open(tracker_file, encoding="utf-8") as f:
277
+ tracker_data = json.load(f)
278
+ # Extract open items (status != 'closed' or similar).
279
+ open_items = [
280
+ item
281
+ for item in tracker_data.get("items", [])
282
+ if item.get("status") != "closed"
283
+ ]
284
+ text = json.dumps(open_items, indent=2, ensure_ascii=True)
285
+ manifest["included"] = True
286
+ manifest["size_bytes"] = len(text.encode("utf-8"))
287
+ return {source_name: text}, manifest
288
+ except (OSError, json.JSONDecodeError) as e:
289
+ return {
290
+ source_name: f"Error reading tracker_open: {e}"
291
+ }, manifest
292
+ else:
293
+ return {source_name: "tracker.json not found"}, manifest
294
+
295
+ elif source_name.startswith("brief:"):
296
+ # Explicit file path (must be allowlisted).
297
+ path_spec = source_name[6:] # Remove 'brief:' prefix.
298
+ if not path_spec:
299
+ raise ContextPackViolation(
300
+ f"brief: source has no path: {source_name}"
301
+ )
302
+
303
+ # Resolve ONCE, validate the RESOLVED path, then read that exact path.
304
+ # (Resolving separately for the check and the read is a TOCTOU window:
305
+ # a symlink swap between the two resolves could redirect the read
306
+ # outside the allowlist.)
307
+ brief_file = Path(path_spec).resolve()
308
+ if not _is_allowed_path(str(brief_file), repo_root, conductor_root):
309
+ raise ContextPackViolation(
310
+ f"brief:{path_spec} is not under allowlisted roots "
311
+ f"({repo_root}, {conductor_root})"
312
+ )
313
+ if brief_file.exists():
314
+ try:
315
+ text = brief_file.read_text(encoding="utf-8")
316
+ manifest["included"] = True
317
+ manifest["size_bytes"] = len(text.encode("utf-8"))
318
+ return {source_name: text}, manifest
319
+ except OSError as e:
320
+ return {source_name: f"Error reading {path_spec}: {e}"}, manifest
321
+ else:
322
+ return {source_name: f"File not found: {path_spec}"}, manifest
323
+
324
+ else:
325
+ # Unknown source type (not allowlisted).
326
+ raise ContextPackViolation(
327
+ f"Unknown context source '{source_name}'; "
328
+ f"allowed: 'state', 'buildlog_tail:N', 'tracker_open', 'brief:<path>'"
329
+ )
330
+
331
+
332
+ def _truncate_pack(pack: ContextPack, size_cap: int) -> None:
333
+ """Truncate pack content to fit within size_cap.
334
+
335
+ Truncates oldest-first (log sources: buildlog_tail before state).
336
+ Mutates pack.content and pack.manifest in place.
337
+
338
+ Args:
339
+ pack: The context pack to truncate.
340
+ size_cap: The target size limit in bytes.
341
+ """
342
+ # Sort manifest by source type: logs (buildlog_tail) first, then others.
343
+ log_sources = [
344
+ m for m in pack.manifest if m["source"].startswith("buildlog_tail")
345
+ ]
346
+ other_sources = [
347
+ m for m in pack.manifest if not m["source"].startswith("buildlog_tail")
348
+ ]
349
+ sorted_manifest = log_sources + other_sources
350
+
351
+ # Truncate from log sources first, aggressively.
352
+ for manifest_entry in sorted_manifest:
353
+ if pack.total_size_bytes <= size_cap:
354
+ break
355
+
356
+ source_name = manifest_entry["source"]
357
+ if source_name not in pack.content:
358
+ continue
359
+
360
+ text = pack.content[source_name]
361
+ current_size = len(text.encode("utf-8"))
362
+
363
+ # For buildlog_tail, drop oldest lines (beginning of text).
364
+ if source_name.startswith("buildlog_tail"):
365
+ lines = text.split("\n")
366
+ # Aggressively reduce: start with 50% of lines, then 25%, etc.
367
+ best_text = text
368
+ best_size = current_size
369
+ for reduction in [2, 4, 10, 100]:
370
+ target_lines = max(1, len(lines) // reduction)
371
+ new_text = "\n".join(lines[-target_lines:])
372
+ new_size = len(new_text.encode("utf-8"))
373
+ # Accept this reduction if it helps us get closer to the cap.
374
+ if new_size < best_size:
375
+ best_text = new_text
376
+ best_size = new_size
377
+ if pack.total_size_bytes - current_size + new_size <= size_cap:
378
+ break
379
+
380
+ pack.content[source_name] = best_text
381
+ pack.total_size_bytes -= current_size - best_size
382
+ # Manifest honesty: only claim truncation if content actually shrank.
383
+ if best_size < current_size:
384
+ manifest_entry["truncated"] = True
385
+ manifest_entry["truncation_reason"] = "size_cap_exceeded"
386
+ manifest_entry["size_bytes"] = best_size
387
+
388
+ # For other sources, truncate end of text.
389
+ elif pack.total_size_bytes > size_cap:
390
+ best_text = text
391
+ best_size = current_size
392
+ for reduction in [2, 4, 10, 100]:
393
+ target_size = max(100, current_size // reduction)
394
+ new_text = text[:target_size] + "\n... TRUNCATED ..."
395
+ new_size = len(new_text.encode("utf-8"))
396
+ # Accept this reduction if it helps us get closer to the cap.
397
+ if new_size < best_size:
398
+ best_text = new_text
399
+ best_size = new_size
400
+ if pack.total_size_bytes - current_size + new_size <= size_cap:
401
+ break
402
+
403
+ pack.content[source_name] = best_text
404
+ pack.total_size_bytes -= current_size - best_size
405
+ # Manifest honesty: only claim truncation if content actually shrank.
406
+ if best_size < current_size:
407
+ manifest_entry["truncated"] = True
408
+ manifest_entry["truncation_reason"] = "size_cap_exceeded"
409
+ manifest_entry["size_bytes"] = best_size
410
+
411
+
412
+ def _truncate_evidence(pack: ContextPack, size_cap: int) -> None:
413
+ """Truncate evidence content to fit within size_cap.
414
+
415
+ Truncates end-of-text for each evidence item (newest first, then oldest).
416
+ Mutates pack.evidence and pack.evidence_manifest in place.
417
+
418
+ Args:
419
+ pack: The context pack with evidence to truncate.
420
+ size_cap: The target size limit in bytes for evidence.
421
+ """
422
+ # Truncate evidence items (reverse order: newest/last-added first).
423
+ for manifest_entry in reversed(pack.evidence_manifest):
424
+ if pack.evidence_size_bytes <= size_cap:
425
+ break
426
+
427
+ evidence_name = manifest_entry["name"]
428
+ if evidence_name not in pack.evidence:
429
+ continue
430
+
431
+ text = pack.evidence[evidence_name]
432
+ current_size = len(text.encode("utf-8"))
433
+
434
+ # Truncate end of text.
435
+ best_text = text
436
+ best_size = current_size
437
+ for reduction in [2, 4, 10, 100]:
438
+ target_size = max(100, current_size // reduction)
439
+ new_text = text[:target_size] + "\n... TRUNCATED ..."
440
+ new_size = len(new_text.encode("utf-8"))
441
+ # Accept this reduction if it helps us get closer to the cap.
442
+ if new_size < best_size:
443
+ best_text = new_text
444
+ best_size = new_size
445
+ if pack.evidence_size_bytes - current_size + new_size <= size_cap:
446
+ break
447
+
448
+ pack.evidence[evidence_name] = best_text
449
+ pack.evidence_size_bytes -= current_size - best_size
450
+ # Manifest honesty: only claim truncation if content actually shrank.
451
+ if best_size < current_size:
452
+ manifest_entry["truncated"] = True
453
+ manifest_entry["truncation_reason"] = "evidence_size_cap_exceeded"
454
+ manifest_entry["size_bytes"] = best_size
@@ -29,12 +29,19 @@ from agent_driver import ( # noqa: E402
29
29
  ROLE_VERIFY,
30
30
  ROLE_WORKER,
31
31
  )
32
+ from backend_config import ( # noqa: E402
33
+ validate_base_url,
34
+ validate_is_local_base_url,
35
+ )
32
36
  from codex_driver import CodexDriver # noqa: E402
33
- from openai_transport import _AuthStripRedirectHandler # noqa: E402
37
+ from openai_transport import _AuthStripRedirectHandler, MAX_RESPONSE_SIZE # noqa: E402
34
38
 
35
39
 
36
40
  def make_openai_compatible_transport(
37
- base_url: str, api_key_env: str = "OPENAI_API_KEY", timeout_s: float = 120.0
41
+ base_url: str,
42
+ api_key_env: str = "OPENAI_API_KEY",
43
+ timeout_s: float = 120.0,
44
+ is_local: bool = False,
38
45
  ):
39
46
  """Factory function: return a transport callable for an OpenAI-compatible endpoint.
40
47
 
@@ -42,27 +49,40 @@ def make_openai_compatible_transport(
42
49
  base_url: e.g. "https://openrouter.ai/api/v1", "http://localhost:11434/v1"
43
50
  api_key_env: environment variable name for the API key (default "OPENAI_API_KEY")
44
51
  timeout_s: HTTP timeout in seconds
52
+ is_local: if True, a missing API key is replaced by a dummy Bearer
53
+ instead of raising. Requires a loopback base_url (validated here):
54
+ the key waiver keys off this VALIDATED flag, never off the URL
55
+ text (a substring check is spoofable, e.g. "localhost.attacker.example").
45
56
 
46
57
  Returns:
47
58
  A transport callable (payload dict) -> dict matching the CodexDriver contract.
48
59
 
49
60
  Raises:
50
- RuntimeError: if the API key env var is not set (unless base_url suggests local Ollama).
61
+ ValueError: if is_local=True with a non-loopback base_url.
62
+ RuntimeError: at call time, if the API key env var is not set and
63
+ is_local is False (no URL-based waiver).
51
64
  """
52
65
  import urllib.error
53
66
  import urllib.request
54
67
 
68
+ # is_local waives the key requirement, so pin it to loopback at the
69
+ # factory too (defense in depth for direct factory callers).
70
+ if is_local:
71
+ validate_is_local_base_url(base_url)
72
+
55
73
  def transport(payload: dict) -> dict:
56
74
  """POST to the OpenAI-compatible endpoint via urllib."""
57
- # For local Ollama, the API key may be unused/dummy; for hosted services, get it.
58
- # The pattern os.environ.get("OPENAI_API_KEY") does not trigger secret_scan
59
- # because the RHS contains dots/parens; we use a variable for flexibility.
75
+ # For validated-local endpoints the API key may be unused/dummy; for
76
+ # everything else it is required. The waiver is gated on the
77
+ # loopback-validated is_local flag ONLY -- never on the URL text.
60
78
  retrieved_key = os.environ.get(api_key_env)
61
- if not retrieved_key and "localhost" not in base_url.lower():
79
+ if not retrieved_key and not is_local:
62
80
  raise RuntimeError(
63
81
  f"{api_key_env} environment variable is not set, and "
64
- f"base_url '{base_url}' does not look like localhost. "
65
- f"Set {api_key_env} before running, or use a FakeTransport in tests."
82
+ f"base_url '{base_url}' is not a validated local endpoint "
83
+ f"(is_local). Set {api_key_env} before running, set "
84
+ f"is_local=True for a loopback endpoint, or use a "
85
+ f"FakeTransport in tests."
66
86
  )
67
87
 
68
88
  # Use dummy key for local Ollama if not set.
@@ -89,7 +109,16 @@ def make_openai_compatible_transport(
89
109
  opener = urllib.request.build_opener(_AuthStripRedirectHandler())
90
110
  with opener.open(request, timeout=timeout_s) as response:
91
111
  status = response.status
92
- body = response.read().decode("utf-8")
112
+ # Cap response read at MAX_RESPONSE_SIZE bytes before parsing.
113
+ # Read MAX_RESPONSE_SIZE+1 bytes to detect if response exceeds limit.
114
+ body_bytes = response.read(MAX_RESPONSE_SIZE + 1)
115
+ if len(body_bytes) > MAX_RESPONSE_SIZE:
116
+ raise RuntimeError(
117
+ f"Response size limit exceeded: {len(body_bytes)} bytes > "
118
+ f"{MAX_RESPONSE_SIZE} bytes ({MAX_RESPONSE_SIZE // 1024}KB). "
119
+ f"The response is too large."
120
+ )
121
+ body = body_bytes.decode("utf-8")
93
122
 
94
123
  # Classify non-2xx as an error.
95
124
  if not (200 <= status < 300):
@@ -144,7 +173,9 @@ class OpenAICompatibleDriver(CodexDriver):
144
173
  api_key_env: Environment variable name for API key (default "OPENAI_API_KEY").
145
174
  For local Ollama, can be unused/dummy.
146
175
  is_local: If True, marks this as a local/small model and sets verification tier to 3.
147
- Default False (hosted models -> tier 2).
176
+ Default False (hosted models -> tier 2). Requires a loopback base_url
177
+ (localhost/127.0.0.1/::1): is_local disables the key requirement, so a
178
+ remote base_url is rejected at construction (ValueError).
148
179
  model_map: Optional role-to-model mapping for setup/verify roles (default=None, uses role-based fallback).
149
180
  transport: Optional injectable transport callable for testing (default=None, builds from base_url).
150
181
  now: callable returning time.time() for testing (default=time.time).
@@ -152,6 +183,16 @@ class OpenAICompatibleDriver(CodexDriver):
152
183
  max_retries: max in-turn retries on malformed JSON (default 2).
153
184
  timeout_s: HTTP timeout in seconds (default 120).
154
185
  """
186
+ # SSRF guard: validate base_url UNCONDITIONALLY at construction
187
+ # (parity with OpenAICompatibleOrchestratorBackend.__init__), so a
188
+ # direct construction with a private/link-local address fails even
189
+ # when the config-layer checks were bypassed.
190
+ validate_base_url(base_url)
191
+ # is_local disables the API-key requirement, so it must be pinned to
192
+ # a loopback base_url (parity with the orchestrator seat + config
193
+ # layer): is_local + remote would ship prompts with a dummy Bearer.
194
+ if is_local:
195
+ validate_is_local_base_url(base_url)
155
196
  self._base_url = base_url
156
197
  self._model = model
157
198
  self._api_key_env = api_key_env
@@ -163,6 +204,7 @@ class OpenAICompatibleDriver(CodexDriver):
163
204
  base_url=base_url,
164
205
  api_key_env=api_key_env,
165
206
  timeout_s=timeout_s,
207
+ is_local=is_local,
166
208
  )
167
209
 
168
210
  # If no model_map provided, use a simple single-model fallback.
@@ -26,6 +26,10 @@ import urllib.request
26
26
  # Type alias documenting the transport callable contract.
27
27
  Transport = callable # (payload: dict) -> dict
28
28
 
29
+ # Maximum response size (100 KB) to prevent OOM from hostile/broken endpoints.
30
+ # Enforced at the read() level BEFORE parsing to fail-safe on excessive responses.
31
+ MAX_RESPONSE_SIZE = 100 * 1024 # 100KB in bytes
32
+
29
33
 
30
34
  class _AuthStripRedirectHandler(urllib.request.HTTPRedirectHandler):
31
35
  """Custom redirect handler that strips Authorization on cross-origin redirects.
@@ -84,7 +88,10 @@ class _AuthStripRedirectHandler(urllib.request.HTTPRedirectHandler):
84
88
 
85
89
 
86
90
  def default_openai_transport(
87
- payload: dict, timeout_s: float = 120.0, base_url: str = "https://api.openai.com/v1"
91
+ payload: dict,
92
+ timeout_s: float = 120.0,
93
+ base_url: str = "https://api.openai.com/v1",
94
+ api_key: str = None,
88
95
  ) -> dict:
89
96
  """Default transport: POST to OpenAI Chat Completions via urllib.
90
97
 
@@ -92,19 +99,24 @@ def default_openai_transport(
92
99
  payload: OpenAI Chat Completions request body (messages, model, etc.).
93
100
  timeout_s: HTTP timeout in seconds (default 120).
94
101
  base_url: OpenAI API base URL (default production).
102
+ api_key: Optional pre-resolved API key (HS-1 seats: callers that honor
103
+ a configured api_key_env or an is_local dummy key pass it here).
104
+ When None, falls back to reading OPENAI_API_KEY from the
105
+ environment (the legacy behavior, unchanged).
95
106
 
96
107
  Returns:
97
108
  Parsed JSON response (choices, usage, etc.).
98
109
 
99
110
  Raises:
100
- RuntimeError: if OPENAI_API_KEY env var is not set, or if the HTTP
101
- status is not 200-299.
111
+ RuntimeError: if no api_key was given and OPENAI_API_KEY env var is
112
+ not set, or if the HTTP status is not 200-299.
102
113
  urllib.error.URLError: if the HTTP request fails.
103
114
  """
104
- # Read API key at call time from environment; NEVER hardcoded.
105
- # The pattern os.environ.get("OPENAI_API_KEY") does not trigger secret_scan
106
- # because the RHS contains dots/parens.
107
- api_key = os.environ.get("OPENAI_API_KEY")
115
+ # Read API key at call time from environment (unless pre-resolved by the
116
+ # caller); NEVER hardcoded. The pattern os.environ.get("OPENAI_API_KEY")
117
+ # does not trigger secret_scan because the RHS contains dots/parens.
118
+ if not api_key:
119
+ api_key = os.environ.get("OPENAI_API_KEY")
108
120
  if not api_key:
109
121
  raise RuntimeError(
110
122
  "OPENAI_API_KEY environment variable is not set. "
@@ -130,7 +142,16 @@ def default_openai_transport(
130
142
  opener = urllib.request.build_opener(_AuthStripRedirectHandler())
131
143
  with opener.open(request, timeout=timeout_s) as response:
132
144
  status = response.status
133
- body = response.read().decode("utf-8")
145
+ # Cap response read at MAX_RESPONSE_SIZE bytes before parsing.
146
+ # Read MAX_RESPONSE_SIZE+1 bytes to detect if response exceeds limit.
147
+ body_bytes = response.read(MAX_RESPONSE_SIZE + 1)
148
+ if len(body_bytes) > MAX_RESPONSE_SIZE:
149
+ raise RuntimeError(
150
+ f"Response size limit exceeded: {len(body_bytes)} bytes > "
151
+ f"{MAX_RESPONSE_SIZE} bytes ({MAX_RESPONSE_SIZE // 1024}KB). "
152
+ f"The response is too large."
153
+ )
154
+ body = body_bytes.decode("utf-8")
134
155
 
135
156
  # Classify non-2xx as an error.
136
157
  if not (200 <= status < 300):
@@ -148,8 +169,8 @@ def default_openai_transport(
148
169
  error_message = None
149
170
  if exc.fp:
150
171
  try:
151
- # Read bounded response body (~500 bytes) to avoid memory issues.
152
- error_body = exc.fp.read(500).decode("utf-8", errors="replace")
172
+ # Read bounded response body (cap at MAX_RESPONSE_SIZE).
173
+ error_body = exc.fp.read(MAX_RESPONSE_SIZE + 1).decode("utf-8", errors="replace")
153
174
  error_data = json.loads(error_body)
154
175
  if isinstance(error_data, dict) and "error" in error_data:
155
176
  error_obj = error_data["error"]