@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.
- package/CHANGELOG.md +34 -1
- package/README.md +26 -11
- package/aesop.config.example.json +16 -0
- package/bin/cli.js +5 -2
- package/daemons/install-tasks.ps1 +193 -0
- package/daemons/run-hidden.vbs +39 -0
- package/docs/INSTALL.md +148 -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 +3 -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
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
58
|
-
#
|
|
59
|
-
#
|
|
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
|
|
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}'
|
|
65
|
-
f"Set {api_key_env} before running,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
105
|
-
# The pattern os.environ.get("OPENAI_API_KEY")
|
|
106
|
-
# because the RHS contains dots/parens.
|
|
107
|
-
|
|
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
|
-
|
|
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 (
|
|
152
|
-
error_body = exc.fp.read(
|
|
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"]
|