outerloop-science 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/harness.py ADDED
@@ -0,0 +1,1449 @@
1
+ """The harness seam: one agent session in, one result out.
2
+
3
+ `Harness.run` takes rendered brief text and a workspace directory and returns
4
+ a :class:`SessionResult`. Provider quirks (auth, CLI flags, output parsing)
5
+ live inside adapters; context policy lives in `brief` and is shared by every
6
+ backend — that separation is what makes backend comparisons honest
7
+ (docs/design/architecture.md, "The backend seam").
8
+
9
+ Sessions run in a scrubbed environment: an explicit allowlist plus the one
10
+ API key the backend needs. The bot PAT and anything else in the orchestrator's
11
+ environment never reach a session (threat model: credential theft).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import signal
21
+ import stat
22
+ import subprocess
23
+ import uuid
24
+ from dataclasses import dataclass, field, replace
25
+ from pathlib import Path
26
+ from typing import Any, Protocol
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ # What a session's environment contains — nothing else survives from the
31
+ # parent. HOME is deliberately NOT inherited: it is redirected to a per-run
32
+ # directory so a session cannot read key files under the real home or poison
33
+ # other runs via ~/.claude state. (Residual risk: the filesystem itself is
34
+ # not sandboxed — same-user absolute paths remain readable. See the threat
35
+ # model; the bot PAT must not live on the account that runs sessions until
36
+ # OS-level sandboxing lands.)
37
+ SESSION_ENV_ALLOWLIST = ("PATH", "TERM", "LANG", "LC_ALL", "TMPDIR")
38
+
39
+ # Fallbacks only — every orchestrated path threads effective_limits in
40
+ # explicitly (climb/steward CLIs pass turns and minutes; the follow-up CLI
41
+ # derives its timeout from the job walltime). Kept at the session ceilings
42
+ # so a site that forgets still grants the intended budget rather than
43
+ # silently undercutting it.
44
+ DEFAULT_TIMEOUT_S = 5400
45
+ DEFAULT_MAX_TURNS = 120
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SessionResult:
50
+ """What happened in one session — everything the orchestrator needs to
51
+ judge, bill, and report it. The workspace diff is captured by the caller
52
+ (it owns the git clone); the harness owns only the session.
53
+
54
+ On the "timeout" path, cost and session id are unknown (the CLI is killed
55
+ before it reports); budget accounting must treat a timeout as worst-case
56
+ spend, not zero.
57
+ """
58
+
59
+ stop_reason: str # backend's stop reason, or "timeout" / "spawn-error"
60
+ is_error: bool
61
+ cost_usd: float
62
+ num_turns: int
63
+ session_id: str
64
+ final_text: str # the agent's closing message (the research report draft)
65
+ transcript_path: str # raw backend output, api-key-redacted, on disk
66
+ # human-readable cause when is_error: the backend's error subtype and
67
+ # messages (e.g. "error_max_turns: Reached maximum number of turns
68
+ # (60)"), or our own explanation on the timeout path. This is what
69
+ # reports and issue comments show — stop_reason alone reads as noise
70
+ # ("tool_use") when a session dies mid-tool-call.
71
+ error_detail: str = ""
72
+
73
+
74
+ class Harness(Protocol):
75
+ """One coding session over a workspace. Implementations are adapters.
76
+
77
+ A *run* (one hypothesis) may span many sessions: a session that launches a
78
+ long experiment ends, and when results arrive the orchestrator wakes the
79
+ agent with `resume_session_id` — restoring its full working context — and
80
+ a wake prompt carrying the results. Session state lives in the per-run
81
+ HOME next to the workspace, so wakes survive orchestrator restarts and can
82
+ land on a different cluster node (shared filesystem).
83
+
84
+ A backend MAY declare a class attribute `supports_resume = False` when it
85
+ has no trustworthy headless resume. The revise loop checks it (via getattr,
86
+ default True) and DRAFTS instead of calling run() with a resume id — a resume
87
+ that silently starts fresh or fails would revise blind or lose a verified
88
+ improvement. All three current backends resume (`supports_resume=True`;
89
+ hermes via saved-transcript rehydration). Optional, so test doubles need not
90
+ declare it."""
91
+
92
+ def run(
93
+ self, brief_text: str, workspace: Path, resume_session_id: str | None = None
94
+ ) -> SessionResult: ...
95
+
96
+
97
+ def session_env(api_key: str, key_variable: str, home: Path) -> dict[str, str]:
98
+ """The scrubbed environment a session runs with."""
99
+ env = {name: os.environ[name] for name in SESSION_ENV_ALLOWLIST if name in os.environ}
100
+ env["HOME"] = str(home)
101
+ env[key_variable] = api_key
102
+ return env
103
+
104
+
105
+ def _open_nofollow_dir(name: str, dir_fd: int) -> int:
106
+ """`openat` `name` as a directory under `dir_fd` without following a final
107
+ symlink; -1 if it is missing, a symlink (ELOOP), or not a directory."""
108
+ try:
109
+ return os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY, dir_fd=dir_fd)
110
+ except OSError:
111
+ return -1
112
+
113
+
114
+ def _rmtree_at(dir_fd: int, name: str) -> None:
115
+ """Recursively delete directory `name` under `dir_fd`, anchored on file
116
+ descriptors and `O_NOFOLLOW` at every level. No path component is ever
117
+ resolved by name after the first open, so a session that swaps a directory
118
+ for a symlink mid-delete cannot divert it outside the tree (TOCTOU-safe).
119
+ Best-effort: a missing entry, a symlink, or a non-directory `name` is a
120
+ no-op."""
121
+ fd = _open_nofollow_dir(name, dir_fd)
122
+ if fd < 0:
123
+ return # gone, a symlink (ELOOP), or not a directory
124
+ try:
125
+ for child in os.listdir(fd):
126
+ try:
127
+ st = os.stat(child, dir_fd=fd, follow_symlinks=False)
128
+ except OSError:
129
+ continue
130
+ if stat.S_ISDIR(st.st_mode):
131
+ _rmtree_at(fd, child)
132
+ else:
133
+ with contextlib.suppress(OSError):
134
+ os.unlink(child, dir_fd=fd)
135
+ finally:
136
+ os.close(fd)
137
+ with contextlib.suppress(OSError):
138
+ os.rmdir(name, dir_fd=dir_fd)
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class VertexConfig:
143
+ """Claude-on-Vertex auth (ADC): set to bill Anthropic sessions to a GCP
144
+ project instead of an Anthropic API key. The single owner of the
145
+ AUTORESEARCH_VERTEX_* env contract is `vertex_from_env`."""
146
+
147
+ project: str
148
+ region: str = "global"
149
+ # GOOGLE_APPLICATION_CREDENTIALS. Sessions run with a scrubbed per-run
150
+ # HOME, so ambient ADC discovery inside the session can never find the
151
+ # real ~/.config/gcloud — vertex_from_env resolves that default to an
152
+ # explicit path up front. "" only where a metadata server provides
153
+ # credentials (GCE / workload identity).
154
+ adc_file: str = ""
155
+
156
+ def env(self) -> dict[str, str]:
157
+ out = {
158
+ "CLAUDE_CODE_USE_VERTEX": "1",
159
+ "ANTHROPIC_VERTEX_PROJECT_ID": self.project,
160
+ "CLOUD_ML_REGION": self.region,
161
+ }
162
+ if self.adc_file:
163
+ out["GOOGLE_APPLICATION_CREDENTIALS"] = self.adc_file
164
+ return out
165
+
166
+
167
+ def vertex_from_env() -> VertexConfig | None:
168
+ """The deployment's Vertex coordinates, or None (Anthropic-direct). A live
169
+ flip needs only the env: set AUTORESEARCH_VERTEX_PROJECT to route every
170
+ claude session through Vertex; unset it to fall back to the API key."""
171
+ project = os.environ.get("AUTORESEARCH_VERTEX_PROJECT", "").strip()
172
+ if not project:
173
+ return None
174
+ adc = os.path.expanduser(os.environ.get("AUTORESEARCH_VERTEX_ADC", "").strip())
175
+ if not adc:
176
+ # resolve the gcloud default NOW, under the real HOME — the session's
177
+ # HOME is a scrubbed per-run directory where ambient discovery would
178
+ # find nothing. Absent file: leave "" for metadata-server credentials.
179
+ default = os.path.expanduser("~/.config/gcloud/application_default_credentials.json")
180
+ if os.path.isfile(default):
181
+ adc = default
182
+ return VertexConfig(
183
+ project=project,
184
+ region=os.environ.get("AUTORESEARCH_VERTEX_REGION", "global").strip() or "global",
185
+ adc_file=adc,
186
+ )
187
+
188
+
189
+ def redact(text: str, secrets: tuple[str, ...]) -> str:
190
+ """Strip known secrets from text before it is stored anywhere. Installation
191
+ tokens minted after a call site snapshotted its tuple are covered too: the
192
+ App provider rotates ~hourly, so the process-wide issued set is consulted
193
+ at write time, not capture time."""
194
+ from outerloop.appauth import issued_tokens
195
+
196
+ for secret in (*secrets, *issued_tokens()):
197
+ if secret:
198
+ text = text.replace(secret, "[redacted]")
199
+ return text
200
+
201
+
202
+ def _error_result(stop_reason: str, transcript_path: str = "", detail: str = "") -> SessionResult:
203
+ return SessionResult(
204
+ stop_reason=stop_reason,
205
+ is_error=True,
206
+ error_detail=(detail or stop_reason)[:500],
207
+ cost_usd=0.0,
208
+ num_turns=0,
209
+ session_id="",
210
+ final_text="",
211
+ transcript_path=transcript_path,
212
+ )
213
+
214
+
215
+ # Substrings that mean "the API itself is unavailable to us" — credit,
216
+ # limit, auth, throttling. Matched only against error surfaces of an
217
+ # is_error result (backend error text, never agent prose). The first group is
218
+ # Anthropic-shaped; the second matches the OpenAI-compatible / hermes+OpenRouter
219
+ # 401 shapes, whose text differs (e.g. "HTTP 401: Missing Authentication
220
+ # header", "No auth credentials found") so the Anthropic patterns miss them.
221
+ OUTAGE_PATTERNS = (
222
+ "credit balance",
223
+ "usage limit",
224
+ "spending limit",
225
+ "billing",
226
+ "authentication_error",
227
+ "invalid x-api-key",
228
+ "rate_limit_error",
229
+ "overloaded_error",
230
+ "401",
231
+ "missing authentication",
232
+ "no auth credentials",
233
+ "invalid api key",
234
+ )
235
+
236
+
237
+ def backend_id(harness: object) -> str:
238
+ """`backend/model` attribution for a round stamp — which reviewer wrote
239
+ this. Empty for unknown harness types (test fakes): the stamp then omits
240
+ the reviewer clause rather than naming something misleading."""
241
+ names = {
242
+ "ClaudeCodeHarness": "claude",
243
+ "CodexHarness": "codex",
244
+ "HermesHarness": "hermes",
245
+ }
246
+ backend = names.get(type(harness).__name__, "")
247
+ if not backend:
248
+ return ""
249
+ model = str(getattr(harness, "model", "") or "").strip()
250
+ return f"{backend}/{model}" if model else backend
251
+
252
+
253
+ def outage(result: SessionResult) -> bool:
254
+ """True when the session failed because the API refused us — dead
255
+ credits, spend cap, bad key, throttling — not because of anything in
256
+ the run. Callers treat this as an infrastructure outage: pause the
257
+ lanes, and never bill the failure against a run's retry caps or a
258
+ work order's attempts."""
259
+ if not result.is_error:
260
+ return False
261
+ # error_detail is backend error text and always wins; final_text is
262
+ # consulted ONLY when no detail exists AND it carries the legacy CLI
263
+ # error shape ("API Error ..."), never agent prose — a failed session's
264
+ # report that merely MENTIONS billing or limits must not trip a latch
265
+ # that pauses every lane.
266
+ surface = result.error_detail.casefold()
267
+ if not surface:
268
+ text = result.final_text.strip().casefold()
269
+ if not text.startswith("api error"):
270
+ return False
271
+ surface = text
272
+ return any(pattern in surface for pattern in OUTAGE_PATTERNS)
273
+
274
+
275
+ def budget_exhausted(result: SessionResult) -> bool:
276
+ """True when the session stopped because OUR limits ran out — turns or
277
+ session walltime — rather than because anything failed. Callers report
278
+ this as the budget-exhausted ending, never as an error: "caps hit
279
+ mid-run" is one of the six honest deaths, not a malfunction."""
280
+ return result.stop_reason == "timeout" or result.error_detail.startswith("error_max_turns")
281
+
282
+
283
+ def _write_private(directory: Path, stem: str, suffix: str, text: str) -> str:
284
+ """Atomically create a fresh owner-only file and write `text` to it.
285
+
286
+ O_EXCL closes the TOCTOU between name choice and creation, and it also
287
+ refuses symlinks (a session could plant a dangling link where its own
288
+ transcript will land, redirecting the write to an arbitrary same-user
289
+ file). Returns the path written, or "" — storage failures must not crash
290
+ the adapter."""
291
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
292
+ for n in range(1, 1000):
293
+ path = directory / (f"{stem}{suffix}" if n == 1 else f"{stem}-{n}{suffix}")
294
+ try:
295
+ fd = os.open(path, flags, 0o600)
296
+ except FileExistsError:
297
+ continue
298
+ except OSError as exc:
299
+ log.warning("could not store transcript at %s: %s", path, exc)
300
+ return ""
301
+ with os.fdopen(fd, "w") as handle:
302
+ handle.write(text)
303
+ return str(path)
304
+ log.warning("could not find a free transcript name in %s", directory)
305
+ return ""
306
+
307
+
308
+ def _write_private_fixed(path: Path, text: str) -> bool:
309
+ """Write `text` to a fixed path, refusing to follow a symlink (a
310
+ session-writable dir could hold a planted link redirecting the write to an
311
+ arbitrary same-user file). True on success. Truncates an existing regular
312
+ file; O_NOFOLLOW makes os.open raise on a symlink."""
313
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
314
+ try:
315
+ fd = os.open(path, flags, 0o600)
316
+ with os.fdopen(fd, "w") as handle:
317
+ handle.write(text)
318
+ except OSError:
319
+ return False
320
+ return True
321
+
322
+
323
+ def _read_no_follow(path: Path) -> str | None:
324
+ """Read a file's text without following a symlink. None on any error (a
325
+ missing file lost to a race, or a planted link O_NOFOLLOW refuses)."""
326
+ try:
327
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
328
+ with os.fdopen(fd, "r", errors="replace") as handle:
329
+ return handle.read()
330
+ except OSError:
331
+ return None
332
+
333
+
334
+ def _collect_hermes_sample(session_home: Path) -> Any:
335
+ """Load this run's trajectory (newest `sample_*.json`) and delete every one
336
+ of them. Hardened for a session-writable dir: the mtime sort key cannot
337
+ raise out of run() on a vanished file, the read does not follow symlinks,
338
+ and unlink removes the link itself (never its target). Returns the parsed
339
+ sample, or None."""
340
+
341
+ def _mtime(p: Path) -> float:
342
+ try:
343
+ return p.stat().st_mtime
344
+ except OSError:
345
+ return 0.0
346
+
347
+ candidates = sorted(session_home.glob("sample_*.json"), key=_mtime, reverse=True)
348
+ sample: Any = None
349
+ if candidates:
350
+ text = _read_no_follow(candidates[0])
351
+ if text is not None:
352
+ with contextlib.suppress(json.JSONDecodeError):
353
+ sample = json.loads(text)
354
+ for stale in candidates:
355
+ with contextlib.suppress(OSError):
356
+ stale.unlink() # trajectories can embed the brief; don't accumulate
357
+ return sample
358
+
359
+
360
+ # --- hermes headless resume -------------------------------------------------
361
+ # A headless CLI "resumes" by restarting with its prior context restored (that
362
+ # is all claude --resume and codex exec resume do). Hermes has no --resume flag,
363
+ # but it reads its brief from a file, so resume = the next brief carries the
364
+ # prior conversation. The harness keeps its OWN linear transcript (the message
365
+ # it sent + the assistant reply it parsed each turn), rehydrates it into the
366
+ # resume brief, and persists it in the per-run home beside the other resume
367
+ # state — so a resumed hermes session physically cannot start context-blind.
368
+
369
+ _RESUME_STEM = "resume-"
370
+
371
+
372
+ def _resume_transcript_path(session_home: Path, session_id: str) -> Path:
373
+ # session_id is harness-minted (uuid hex) — no path traversal — but keep it
374
+ # to the basename defensively.
375
+ return session_home / f"{_RESUME_STEM}{os.path.basename(session_id)}.json"
376
+
377
+
378
+ def _load_resume_transcript(session_home: Path, session_id: str) -> list[dict[str, str]] | None:
379
+ """Load a saved transcript for `session_id`. None = not found (the caller
380
+ must NOT silently start fresh — a resume with no restored context is an
381
+ error). The per-run home is session-writable, so read without following a
382
+ symlink."""
383
+ text = _read_no_follow(_resume_transcript_path(session_home, session_id))
384
+ if text is None:
385
+ return None
386
+ try:
387
+ data = json.loads(text)
388
+ except json.JSONDecodeError:
389
+ return None
390
+ turns = data.get("turns") if isinstance(data, dict) else None
391
+ if not isinstance(turns, list) or not turns:
392
+ return None
393
+ # Accept ONLY a COMPLETE, well-formed conversation, and reject the whole file
394
+ # on any deviation. The transcript is one we write ([user, assistant] pairs
395
+ # with non-blank text) into a session-writable home, so anything else —
396
+ # empty, whitespace-only, a non-dict entry, an unknown role, or a partial
397
+ # exchange missing the user instructions or the assistant reply — is
398
+ # corruption or tampering and must surface as "unavailable" (the caller
399
+ # errors) rather than resume with missing context, the exact context-blind
400
+ # resume this path exists to reject. We distrust the file as a whole rather
401
+ # than salvage a subset.
402
+ cleaned: list[dict[str, str]] = []
403
+ for t in turns:
404
+ if not isinstance(t, dict):
405
+ return None
406
+ role = str(t.get("role", ""))
407
+ content = str(t.get("text", ""))
408
+ if role not in ("user", "assistant") or not content.strip():
409
+ return None
410
+ cleaned.append({"role": role, "text": content})
411
+ roles = {t["role"] for t in cleaned}
412
+ if "user" not in roles or "assistant" not in roles:
413
+ return None # a real prior conversation has instructions AND a reply
414
+ return cleaned
415
+
416
+
417
+ def _save_resume_transcript(
418
+ session_home: Path, session_id: str, turns: list[dict[str, str]]
419
+ ) -> None:
420
+ """Persist the transcript (0600, symlink-refusing) so the next resume can
421
+ rehydrate it. Best-effort: a failure just means the next resume errors
422
+ (resume-unavailable) rather than starting blind."""
423
+ _write_private_fixed(
424
+ _resume_transcript_path(session_home, session_id), json.dumps({"turns": turns})
425
+ )
426
+
427
+
428
+ def _render_resume_transcript(turns: list[dict[str, str]]) -> str:
429
+ """Render prior turns as a readable prefix for the resume brief."""
430
+ blocks = ["=== Earlier in this session (your prior context) ==="]
431
+ for t in turns:
432
+ who = "You were told" if t["role"] == "user" else "You replied"
433
+ blocks.append(f"[{who}]\n{t['text']}")
434
+ blocks.append("=== End of prior context; continue with the new instructions below ===")
435
+ return "\n\n".join(blocks)
436
+
437
+
438
+ def _float(value: Any, default: float = 0.0) -> float:
439
+ try:
440
+ return float(value)
441
+ except (ValueError, TypeError):
442
+ return default
443
+
444
+
445
+ def _int(value: Any, default: int = 0) -> int:
446
+ try:
447
+ return int(value)
448
+ except (ValueError, TypeError):
449
+ return default
450
+
451
+
452
+ @dataclass
453
+ class ClaudeCodeHarness:
454
+ """Headless Claude Code (`claude -p`): the JSON output carries cost,
455
+ usage, session id, and stop reason.
456
+
457
+ `run` never raises: every failure comes back as an error SessionResult.
458
+ """
459
+
460
+ api_key: str
461
+ binary: str = "claude"
462
+ model: str = "claude-opus-5"
463
+ max_turns: int = DEFAULT_MAX_TURNS
464
+ timeout_s: int = DEFAULT_TIMEOUT_S
465
+ # The working set for code + running the repo's own tests. Note the env
466
+ # DOES hold the session API key (the CLI needs it), so Bash here is a
467
+ # trusted-ish surface; the brief is the only untrusted-ish input and it
468
+ # passes the task-source gate upstream.
469
+ allowed_tools: tuple[str, ...] = ("Write", "Edit", "Read", "Glob", "Grep", "Bash")
470
+ extra_args: tuple[str, ...] = field(default_factory=tuple)
471
+ # --bare skips hooks, plugin sync, auto-memory, and CLAUDE.md
472
+ # auto-discovery, and restricts auth to ANTHROPIC_API_KEY. REQUIRED for
473
+ # judge sessions whose cwd contains an untrusted checkout: a PR-authored
474
+ # CLAUDE.md or .claude/settings.json (hooks run commands) must never load
475
+ # as instructions. Off for author sessions, where the target repo's own
476
+ # CLAUDE.md is useful contributor guidance.
477
+ bare: bool = False
478
+ # Apptainer image for session containment. When set,
479
+ # the session runs under `apptainer exec --containall --cleanenv`: no host
480
+ # $HOME, no host env, no same-user absolute paths — the session sees only
481
+ # the workspace, its per-run HOME, and the read-only claude binary. This
482
+ # closes the threat model's shared-filesystem residual risk. Images stay
483
+ # generic (python + uv + git); the binary is bind-mounted in.
484
+ container_image: str = ""
485
+ apptainer_binary: str = "apptainer"
486
+ # Claude-on-Vertex (ADC) instead of the Anthropic API key; the api_key is
487
+ # ignored when set. Contained sessions get the ADC file bind-mounted.
488
+ vertex: VertexConfig | None = None
489
+
490
+ CONTAINER_CLAUDE = "/opt/agent/claude"
491
+ CONTAINER_ADC = "/opt/agent/adc.json"
492
+ supports_resume = True # native --resume
493
+
494
+ def run(
495
+ self, brief_text: str, workspace: Path, resume_session_id: str | None = None
496
+ ) -> SessionResult:
497
+ # Both live OUTSIDE the git clone: the transcript must never enter the
498
+ # diff that gets committed/pushed, and the per-RUN home (0700; reused
499
+ # across this run's sessions, never across runs — the orchestrator
500
+ # gives every run a fresh workspace path) is what lets a later wake
501
+ # restore the agent's working context.
502
+ transcript_stem = f"{workspace.name}-session"
503
+ session_home = workspace.parent / f"{workspace.name}-home"
504
+ try:
505
+ session_home.mkdir(parents=True, exist_ok=True, mode=0o700)
506
+ # mkdir's mode is umask-masked and ignored entirely on reuse;
507
+ # enforce it either way.
508
+ os.chmod(session_home, 0o700)
509
+ except OSError as exc:
510
+ log.warning("could not create session home %s: %s", session_home, exc)
511
+ return _error_result("workspace-error")
512
+ # A read-only session (no mutating tool) gets a permission mode that
513
+ # denies edits rather than auto-accepting them — defense in depth
514
+ # behind the tool allowlist, so a stray edit tool cannot auto-apply.
515
+ mutating = {"Write", "Edit", "Bash"}.intersection(self.allowed_tools)
516
+ permission_mode = "acceptEdits" if mutating else "default"
517
+ claude_argv = [
518
+ self.CONTAINER_CLAUDE if self.container_image else self.binary,
519
+ "-p",
520
+ # The brief travels on stdin: argv is world-readable via /proc on
521
+ # shared nodes, and briefs carry private research text.
522
+ "Follow the brief provided on stdin.",
523
+ "--model",
524
+ self.model,
525
+ "--output-format",
526
+ "json",
527
+ "--max-turns",
528
+ str(self.max_turns),
529
+ "--allowedTools",
530
+ ",".join(self.allowed_tools),
531
+ "--permission-mode",
532
+ permission_mode,
533
+ *(["--bare"] if self.bare else []),
534
+ *self.extra_args,
535
+ ]
536
+ if resume_session_id:
537
+ claude_argv += ["--resume", resume_session_id]
538
+ if self.container_image:
539
+ # bind sources must be absolute or apptainer fails at mount time
540
+ workspace = workspace.resolve()
541
+ session_home = session_home.resolve()
542
+ if not os.path.isabs(self.binary):
543
+ # a relative bind source fails at mount time deep inside
544
+ # apptainer; catch the misconfiguration here instead
545
+ log.warning("container sessions need an absolute claude path")
546
+ return _error_result("config-error")
547
+ command = [
548
+ self.apptainer_binary,
549
+ "exec",
550
+ "--containall",
551
+ "--cleanenv",
552
+ "--bind",
553
+ f"{workspace}:{workspace}",
554
+ # --home (NOT --env HOME=..., which apptainer silently
555
+ # refuses): mounts the per-run home at the same path inside
556
+ # and sets $HOME to it — native resume state survives
557
+ # contained/uncontained flips and lands on the shared FS,
558
+ # not a tmpfs that evaporates at session end.
559
+ "--home",
560
+ f"{session_home}:{session_home}",
561
+ "--bind",
562
+ f"{self.binary}:{self.CONTAINER_CLAUDE}:ro",
563
+ *(
564
+ ["--bind", f"{Path(self.vertex.adc_file).resolve()}:{self.CONTAINER_ADC}:ro"]
565
+ if self.vertex is not None and self.vertex.adc_file
566
+ else []
567
+ ),
568
+ "--pwd",
569
+ str(workspace),
570
+ self.container_image,
571
+ *claude_argv,
572
+ ]
573
+ else:
574
+ command = claude_argv
575
+ try:
576
+ # start_new_session puts the CLI and every descendant (Bash-tool
577
+ # children included) in one process group we can kill as a unit —
578
+ # a timed-out session must not leave orphans holding the API key
579
+ # and writing into the clone.
580
+ if self.vertex is not None:
581
+ # vertex auth: ADC only — ANTHROPIC_API_KEY is deliberately
582
+ # absent so the CLI cannot fall back to direct billing
583
+ env = session_env("", "ANTHROPIC_API_KEY", session_home)
584
+ del env["ANTHROPIC_API_KEY"]
585
+ vertex_env = dict(self.vertex.env())
586
+ if self.container_image and self.vertex.adc_file:
587
+ vertex_env["GOOGLE_APPLICATION_CREDENTIALS"] = self.CONTAINER_ADC
588
+ env |= vertex_env
589
+ if self.container_image:
590
+ for k, v in vertex_env.items():
591
+ env[f"APPTAINERENV_{k}"] = v
592
+ else:
593
+ env = session_env(self.api_key, "ANTHROPIC_API_KEY", session_home)
594
+ if self.container_image:
595
+ # --cleanenv drops the host environment inside the container
596
+ # EXCEPT APPTAINERENV_* variables, which apptainer injects
597
+ # with the prefix stripped — the key travels via the
598
+ # environment, never argv (argv is world-readable in /proc).
599
+ env["APPTAINERENV_ANTHROPIC_API_KEY"] = self.api_key
600
+ process = subprocess.Popen(
601
+ command,
602
+ cwd=workspace,
603
+ env=env,
604
+ stdin=subprocess.PIPE,
605
+ stdout=subprocess.PIPE,
606
+ stderr=subprocess.PIPE,
607
+ text=True,
608
+ start_new_session=True,
609
+ )
610
+ except OSError as exc:
611
+ log.warning("could not spawn %s: %s", self.binary, exc)
612
+ return _error_result("spawn-error")
613
+
614
+ try:
615
+ stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
616
+ except subprocess.TimeoutExpired:
617
+ with contextlib.suppress(ProcessLookupError, PermissionError):
618
+ os.killpg(process.pid, signal.SIGKILL)
619
+ # Bounded drain: a descendant that left the process group (setsid)
620
+ # can hold the pipe open past the kill; run() must still return.
621
+ try:
622
+ stdout, _ = process.communicate(timeout=10)
623
+ except subprocess.TimeoutExpired:
624
+ process.kill()
625
+ stdout = ""
626
+ with contextlib.suppress(subprocess.TimeoutExpired):
627
+ stdout, _ = process.communicate(timeout=5)
628
+ path = _write_private(
629
+ workspace.parent, transcript_stem, ".json", redact(stdout or "", (self.api_key,))
630
+ )
631
+ log.warning("session timed out after %ss in %s", self.timeout_s, workspace)
632
+ return _error_result(
633
+ "timeout",
634
+ path,
635
+ detail=f"session hit its {self.timeout_s}s walltime and was killed",
636
+ )
637
+
638
+ stdout = redact(stdout, (self.api_key,))
639
+ transcript_path = _write_private(workspace.parent, transcript_stem, ".json", stdout)
640
+ data = _parse_result(stdout)
641
+ if data is None:
642
+ stderr_tail = redact(stderr, (self.api_key,))[-500:]
643
+ log.warning("unparseable session output (exit %s): %s", process.returncode, stderr_tail)
644
+ return _error_result("unparseable-output", transcript_path)
645
+ # Field-level salvage: a quirky cost value must not cost us the
646
+ # session id (which resume depends on) or vice versa.
647
+ is_error = bool(data.get("is_error", process.returncode != 0))
648
+ subtype = str(data.get("subtype") or "")
649
+ errors = data.get("errors")
650
+ # capture a real backend cause in ANY form — a list of messages, or a
651
+ # non-list (dict/str) the CLI might return — so the lift guard below
652
+ # never mistakes a present cause for an absent one.
653
+ if isinstance(errors, list):
654
+ messages = "; ".join(str(e) for e in errors if e)
655
+ elif errors:
656
+ messages = str(errors)
657
+ else:
658
+ messages = ""
659
+ detail = f"{subtype}: {messages}" if subtype and messages else (subtype or messages)
660
+ final_text = str(data.get("result") or "")
661
+ # The CLI can flag is_error while stamping a content-free subtype
662
+ # ("success") and leaving the real cause only in `result` (is_error,
663
+ # subtype "success", result "API Error: 400 ... usage limits"). That
664
+ # machine "API Error ..." text (never agent prose) is the
665
+ # authoritative cause, so surface it as the detail: downstream notes,
666
+ # the operator log, and the outage latch (its classifier AND its
667
+ # throttle-duration check, which reads rate_limit/overloaded off the
668
+ # detail) all then see the real error instead of "success". Lift ONLY
669
+ # for the contradiction we have actually observed: an is_error whose
670
+ # subtype is empty or the self-contradictory "success", with no backend
671
+ # `messages`. Under those, `result` is machine error text, not agent
672
+ # prose. A REAL subtype (error_max_turns, error_during_execution, ...) is
673
+ # left alone: `result` there may be the agent's own closing message, and
674
+ # lifting a message that merely starts "API Error" would let agent prose
675
+ # trip the latch — a false outage pausing every lane is worse than the
676
+ # rare mis-scoped one.
677
+ if (
678
+ is_error
679
+ and not messages
680
+ and subtype in ("", "success")
681
+ and final_text.strip().casefold().startswith("api error")
682
+ ):
683
+ detail = final_text.strip()
684
+ # bounded here so every downstream note/report/comment inherits it
685
+ detail = detail[:500]
686
+ return SessionResult(
687
+ stop_reason=str(data.get("stop_reason") or data.get("subtype") or "unknown"),
688
+ is_error=is_error,
689
+ error_detail=detail if is_error else "",
690
+ cost_usd=_float(data.get("total_cost_usd")),
691
+ num_turns=_int(data.get("num_turns")),
692
+ session_id=str(data.get("session_id") or ""),
693
+ final_text=final_text,
694
+ transcript_path=transcript_path,
695
+ )
696
+
697
+
698
+ def _parse_result(stdout: str) -> dict[str, Any] | None:
699
+ """The result object from the CLI's JSON output, or None.
700
+
701
+ When stdout is not one clean JSON object, prefer the FIRST candidate that
702
+ looks like the CLI's result (carries session_id/total_cost_usd): the CLI
703
+ prints its result before any stray output that follows it, so forward
704
+ order keeps a trailing look-alike from substituting its fields into
705
+ billing and resume.
706
+ """
707
+ text = stdout.strip()
708
+ if not text:
709
+ return None
710
+ with contextlib.suppress(json.JSONDecodeError):
711
+ data = json.loads(text)
712
+ return data if isinstance(data, dict) else None
713
+ candidates: list[dict[str, Any]] = []
714
+ for chunk in (*text.splitlines(), text[text.find("{") : text.rfind("}") + 1]):
715
+ with contextlib.suppress(json.JSONDecodeError):
716
+ data = json.loads(chunk)
717
+ if isinstance(data, dict):
718
+ candidates.append(data)
719
+ for candidate in candidates:
720
+ if "session_id" in candidate or "total_cost_usd" in candidate:
721
+ return candidate
722
+ return candidates[0] if candidates else None
723
+
724
+
725
+ def _codex_command(
726
+ binary: str,
727
+ model: str,
728
+ sandbox: str,
729
+ workspace: Path,
730
+ last_message_path: Path,
731
+ resume_session_id: str | None,
732
+ extra_args: tuple[str, ...],
733
+ ) -> list[str]:
734
+ """Argv for one headless `codex exec` run.
735
+
736
+ The prompt is NOT an argument: `codex exec` reads it from stdin when no
737
+ positional prompt is given, keeping the brief out of world-readable /proc
738
+ argv (the same rule as the Claude adapter). Flags verified against
739
+ codex-cli 0.130.0 (`codex exec[ resume] --help`): --json, --model,
740
+ --output-last-message, --skip-git-repo-check, and the stdin prompt behavior.
741
+
742
+ Fresh and resume take DIFFERENT flags. `codex exec` has `--sandbox` and
743
+ `--cd`; `codex exec resume <id>` has NEITHER (passing them is an argparse
744
+ error) — it restores the recorded session, INCLUDING that session's sandbox
745
+ and cwd (verified on codex-cli 0.130.0). A `danger-full-access` session
746
+ (every role: the deployment's container or ephemeral runner is the
747
+ boundary, not codex's own sandbox) adds
748
+ `--dangerously-bypass-approvals-and-sandbox` on resume — the sandbox is
749
+ inherited anyway, but the flag also skips approvals so a headless
750
+ write-heavy turn cannot stall.
751
+ """
752
+ # --model is omitted when empty so codex uses its configured default; a
753
+ # wrong model id is a 404 ("Model not found"), so only pin a verified one.
754
+ model_flag = ["--model", model] if model else []
755
+ tail = [
756
+ "--output-last-message", # final message -> file (reliable final_text)
757
+ str(last_message_path),
758
+ "--skip-git-repo-check",
759
+ *extra_args,
760
+ ]
761
+ if resume_session_id:
762
+ # no --sandbox/--cd on resume: the recorded session's sandbox is
763
+ # inherited; the bypass flag also skips approvals so a headless turn
764
+ # cannot stall waiting for one.
765
+ bypass = (
766
+ ["--dangerously-bypass-approvals-and-sandbox"]
767
+ if sandbox == "danger-full-access"
768
+ else []
769
+ )
770
+ return [binary, "exec", "resume", resume_session_id, "--json", *model_flag, *bypass, *tail]
771
+ return [
772
+ binary,
773
+ "exec",
774
+ "--json", # JSONL events on stdout (session id, usage)
775
+ *model_flag,
776
+ "--sandbox",
777
+ sandbox, # "danger-full-access": the deployment's boundary, not codex's
778
+ "--cd",
779
+ str(workspace),
780
+ *tail,
781
+ ]
782
+
783
+
784
+ def _parse_codex_result(
785
+ stdout: str, last_message: str, returncode: int, transcript_path: str = "", stderr: str = ""
786
+ ) -> SessionResult:
787
+ """Best-effort SessionResult from `codex exec --json` output.
788
+
789
+ `final_text` comes from the --output-last-message file, which is reliable.
790
+ `session_id` is the `thread.started` event's `thread_id`, verified against
791
+ codex-cli 0.130.0 (a `codex exec resume <thread_id>` recalls the session).
792
+ Cost is left at 0 (these backends are subscription or token metered; the
793
+ budget layer meters them by a session/token proxy). Never raises.
794
+ """
795
+ # Event schema verified against codex-cli 0.130.0:
796
+ # thread.started -> thread_id (the session id)
797
+ # error -> message
798
+ # turn.failed -> error.message
799
+ # turn.completed -> success (usage carries tokens)
800
+ session_id = ""
801
+ saw_error = False
802
+ errors: list[str] = []
803
+ for line in stdout.splitlines():
804
+ text = line.strip()
805
+ if not text.startswith("{"):
806
+ continue # timestamped log lines interleave the JSONL; skip them
807
+ try:
808
+ event = json.loads(text)
809
+ except json.JSONDecodeError:
810
+ continue
811
+ if not isinstance(event, dict):
812
+ continue
813
+ etype = str(event.get("type", ""))
814
+ if etype == "thread.started" and not session_id:
815
+ thread_id = event.get("thread_id")
816
+ if isinstance(thread_id, str):
817
+ session_id = thread_id
818
+ elif etype == "error":
819
+ saw_error = True
820
+ message = event.get("message")
821
+ if isinstance(message, str):
822
+ errors.append(message)
823
+ elif etype == "turn.failed":
824
+ saw_error = True
825
+ err = event.get("error")
826
+ message = err.get("message") if isinstance(err, dict) else None
827
+ if isinstance(message, str):
828
+ errors.append(message)
829
+ is_error = returncode != 0 or saw_error
830
+ detail = "; ".join(errors)[:500]
831
+ # Fall back to stderr so a failed run (e.g. a bad flag, no matching event)
832
+ # carries some cause instead of an empty detail.
833
+ if is_error and not detail and stderr.strip():
834
+ detail = stderr.strip()[-500:]
835
+ return SessionResult(
836
+ stop_reason="error" if is_error else "completed",
837
+ is_error=is_error,
838
+ error_detail=detail if is_error else "",
839
+ cost_usd=0.0,
840
+ num_turns=0,
841
+ session_id=session_id,
842
+ final_text=last_message.strip(),
843
+ transcript_path=transcript_path,
844
+ )
845
+
846
+
847
+ @dataclass
848
+ class CodexHarness:
849
+ """Headless OpenAI Codex CLI (`codex exec`) — a second Harness backend.
850
+
851
+ Stage 1's swappability proof (docs/design/consolidation.md). CLI flags AND
852
+ headless resume are verified against codex-cli 0.130.0 (`session_id` = the
853
+ `thread.started` `thread_id`; resume recalls it); cost parsing stays
854
+ best-effort (these backends are metered by a session/token proxy in the
855
+ budget layer).
856
+
857
+ CONTAINED mode (`container_image` set): both `codex login` and `codex exec`
858
+ run inside `apptainer exec --containall --cleanenv`, sharing a bound
859
+ `--home` so the login's auth.json is visible to the exec. apptainer is the
860
+ boundary — no host FS beyond the two binds; `--cleanenv` scrubs the env
861
+ down to the role's own key, so the process tree carries no foreign token
862
+ for a /proc read to lift (the boundary is the scrubbed env, not PID
863
+ isolation) — the same posture as the Claude backend. `--sandbox
864
+ danger-full-access` uniformly: codex's own sandbox (`workspace-write`)
865
+ needs bubblewrap, absent in the image and unreliable nested in apptainer,
866
+ and the deployment's boundary (this container, or the ephemeral runner in
867
+ the uncontained case) already confines the session. The host codex binary
868
+ is bind-mounted read-only into the container (like claude), so the image
869
+ stays codex-free and codex updates by swapping one host binary.
870
+
871
+ `run` never raises: every failure comes back as an error SessionResult.
872
+ """
873
+
874
+ api_key: str
875
+ binary: str = "codex"
876
+ # empty -> codex's configured default (a wrong id 404s); pin only a verified one
877
+ model: str = ""
878
+ # the deployment's container or ephemeral runner is the boundary; codex's own
879
+ # sandbox stays off (it needs bubblewrap — absent/unreliable in apptainer)
880
+ sandbox: str = "danger-full-access"
881
+ timeout_s: int = DEFAULT_TIMEOUT_S
882
+ extra_args: tuple[str, ...] = field(default_factory=tuple)
883
+ # when set, login+exec run inside this apptainer image; empty = uncontained
884
+ # (the deployment's runner is the boundary)
885
+ container_image: str = ""
886
+ apptainer_binary: str = "apptainer"
887
+ # in-container path the host codex binary is bound to (bind-from-host, like
888
+ # ClaudeCodeHarness.CONTAINER_CLAUDE — the image stays codex-free, and codex
889
+ # is updated by swapping one host binary, no rebuild). A bare class attribute
890
+ # (no annotation) so the dataclass does not treat it as a field.
891
+ CONTAINER_CODEX = "/opt/agent/codex"
892
+ # Headless resume is validated on codex-cli 0.130.0: a contained
893
+ # `codex exec resume <thread_id>` recalls prior-turn context (the session id
894
+ # is the `thread.started` event's `thread_id`, restored from the bound
895
+ # --home). So the revise/wake/followup loops resume codex like Claude.
896
+ supports_resume = True
897
+
898
+ def _apptainer_argv(
899
+ self, inner: list[str], session_home: Path, workspace: Path | None
900
+ ) -> list[str]:
901
+ """Wrap a codex argv in `apptainer exec --containall --cleanenv`. The
902
+ run-home is bound via `--home` (auth.json survives login->exec and lands
903
+ on the shared FS); the host codex binary is bind-mounted read-only (like
904
+ the claude author); the workspace is bound and set as --pwd only for the
905
+ exec, never the login."""
906
+ argv = [
907
+ self.apptainer_binary,
908
+ "exec",
909
+ "--containall",
910
+ "--cleanenv",
911
+ "--home",
912
+ f"{session_home}:{session_home}",
913
+ "--bind",
914
+ f"{self.binary}:{self.CONTAINER_CODEX}:ro",
915
+ ]
916
+ if workspace is not None:
917
+ argv += ["--bind", f"{workspace}:{workspace}", "--pwd", str(workspace)]
918
+ return [*argv, self.container_image, *inner]
919
+
920
+ def _login(self, session_home: Path) -> SessionResult | None:
921
+ """Write auth.json into the per-run HOME. None on success, an error
922
+ SessionResult on failure. The key travels on stdin, never argv. In
923
+ AUTHOR mode the login runs inside apptainer with the same bound --home,
924
+ so auth.json lands where the contained exec will read it."""
925
+ env = session_env(self.api_key, "OPENAI_API_KEY", session_home)
926
+ if self.container_image:
927
+ # --cleanenv drops host env inside the container except APPTAINERENV_*
928
+ # (prefix stripped by apptainer): the key travels via env, not argv.
929
+ env["APPTAINERENV_OPENAI_API_KEY"] = self.api_key
930
+ login_argv = self._apptainer_argv(
931
+ [self.CONTAINER_CODEX, "login", "--with-api-key"], session_home, None
932
+ )
933
+ else:
934
+ login_argv = [self.binary, "login", "--with-api-key"]
935
+ try:
936
+ proc = subprocess.run(
937
+ login_argv,
938
+ input=self.api_key,
939
+ cwd=session_home,
940
+ env=env,
941
+ capture_output=True,
942
+ text=True,
943
+ timeout=60,
944
+ )
945
+ except OSError as exc:
946
+ log.warning("could not spawn codex login: %s", exc)
947
+ return _error_result("codex-login-error", detail=str(exc)[:200])
948
+ except subprocess.TimeoutExpired:
949
+ return _error_result("codex-login-error", detail="codex login timed out")
950
+ if proc.returncode != 0:
951
+ detail = redact((proc.stderr or proc.stdout or "").strip(), (self.api_key,))[-300:]
952
+ log.warning("codex login failed: %s", detail)
953
+ return _error_result("codex-login-error", detail=detail or "codex login failed")
954
+ return None
955
+
956
+ def _purge_auth(self, session_home: Path) -> None:
957
+ """Delete the API key that `codex login` wrote to auth.json, so it does
958
+ not persist on disk past the session."""
959
+ with contextlib.suppress(OSError):
960
+ (session_home / ".codex" / "auth.json").unlink()
961
+
962
+ def run(
963
+ self, brief_text: str, workspace: Path, resume_session_id: str | None = None
964
+ ) -> SessionResult:
965
+ # Contained runs bind workspace + home into apptainer, whose bind sources
966
+ # must be ABSOLUTE (a relative one fails at mount time); resolving here
967
+ # also keeps codex's --cd / --output-last-message aligned with the mount
968
+ # points inside the container.
969
+ if self.container_image:
970
+ workspace = workspace.resolve()
971
+ if not os.path.isabs(self.binary):
972
+ # the host codex is bind-mounted; a relative bind source fails at
973
+ # mount time deep inside apptainer — catch it here instead
974
+ log.warning("contained codex needs an absolute binary path")
975
+ return _error_result("config-error")
976
+ transcript_stem = f"{workspace.name}-codex"
977
+ session_home = workspace.parent / f"{workspace.name}-home"
978
+ try:
979
+ session_home.mkdir(parents=True, exist_ok=True, mode=0o700)
980
+ os.chmod(session_home, 0o700)
981
+ except OSError as exc:
982
+ log.warning("could not create session home %s: %s", session_home, exc)
983
+ return _error_result("workspace-error")
984
+ # Codex leaks temp directories into .codex/.tmp and fails to remove
985
+ # them (the "stale arg0 temp dirs: Directory not empty" aborts); across
986
+ # a run's wakes they pile up into tens of thousands of files, the bulk
987
+ # of the per-run home. Clear codex's scratch before each run — its
988
+ # durable state (auth.json, sessions, the sqlite) is elsewhere under
989
+ # .codex and untouched. A prior session owns this home, so resolve every
990
+ # component it can write — the run home and .codex — with O_NOFOLLOW,
991
+ # anchored on the run directory the orchestrator owns (a contained
992
+ # session's binds expose only the run home and workspace, never their
993
+ # parent; an uncontained session runs as a plain host process, so
994
+ # guarding these two components is the boundary either way). A swap of
995
+ # ws-home or .codex for a symlink then cannot divert the delete out of
996
+ # the run home. Best-effort — this must never abort the run it precedes
997
+ # (the contract is to return a SessionResult, not raise), so an
998
+ # adversarially deep tree's RecursionError or any other error is caught.
999
+ try:
1000
+ run_fd = os.open(session_home.parent, os.O_RDONLY | os.O_DIRECTORY)
1001
+ except OSError:
1002
+ run_fd = -1
1003
+ if run_fd >= 0:
1004
+ home_fd = _open_nofollow_dir(session_home.name, run_fd)
1005
+ os.close(run_fd)
1006
+ if home_fd >= 0:
1007
+ codex_fd = _open_nofollow_dir(".codex", home_fd)
1008
+ if codex_fd >= 0:
1009
+ try:
1010
+ for scratch in (".tmp", "tmp"):
1011
+ _rmtree_at(codex_fd, scratch)
1012
+ except Exception as exc:
1013
+ log.warning("codex scratch cleanup skipped: %s", exc)
1014
+ finally:
1015
+ os.close(codex_fd)
1016
+ os.close(home_fd)
1017
+ # Codex authenticates from ~/.codex/auth.json, not OPENAI_API_KEY alone
1018
+ # (the responses endpoint 401s on env-only). Write auth.json
1019
+ # into the scrubbed per-run HOME with `codex login --with-api-key`
1020
+ # (key on stdin, never argv) before exec.
1021
+ login_error = self._login(session_home)
1022
+ if login_error is not None:
1023
+ return login_error
1024
+ # --output-last-message target lives inside the per-run home (0700),
1025
+ # not the shared parent, so model output is not exposed there while
1026
+ # codex is writing it; cleared first so a stale file can never be read
1027
+ # as this run's result, and deleted again after reading.
1028
+ last_message_path = session_home / "codex-last-message.txt"
1029
+ with contextlib.suppress(OSError):
1030
+ last_message_path.unlink()
1031
+ # contained runs invoke the image's codex (on PATH); uncontained the
1032
+ # host binary. The exec binds the workspace and sets it as --pwd.
1033
+ codex_argv = _codex_command(
1034
+ self.CONTAINER_CODEX if self.container_image else self.binary,
1035
+ self.model,
1036
+ self.sandbox,
1037
+ workspace,
1038
+ last_message_path,
1039
+ resume_session_id,
1040
+ self.extra_args,
1041
+ )
1042
+ command = (
1043
+ self._apptainer_argv(codex_argv, session_home, workspace)
1044
+ if self.container_image
1045
+ else codex_argv
1046
+ )
1047
+ try:
1048
+ env = session_env(self.api_key, "OPENAI_API_KEY", session_home)
1049
+ if self.container_image:
1050
+ env["APPTAINERENV_OPENAI_API_KEY"] = self.api_key
1051
+ process = subprocess.Popen(
1052
+ command,
1053
+ cwd=workspace,
1054
+ env=env,
1055
+ stdin=subprocess.PIPE,
1056
+ stdout=subprocess.PIPE,
1057
+ stderr=subprocess.PIPE,
1058
+ text=True,
1059
+ start_new_session=True,
1060
+ )
1061
+ except OSError as exc:
1062
+ log.warning("could not spawn %s: %s", self.binary, exc)
1063
+ self._purge_auth(session_home)
1064
+ return _error_result("spawn-error")
1065
+ try:
1066
+ stdout, stderr = process.communicate(input=brief_text, timeout=self.timeout_s)
1067
+ except subprocess.TimeoutExpired:
1068
+ with contextlib.suppress(ProcessLookupError, PermissionError):
1069
+ os.killpg(process.pid, signal.SIGKILL)
1070
+ try:
1071
+ stdout, _ = process.communicate(timeout=10)
1072
+ except subprocess.TimeoutExpired:
1073
+ process.kill()
1074
+ stdout = ""
1075
+ with contextlib.suppress(subprocess.TimeoutExpired):
1076
+ stdout, _ = process.communicate(timeout=5)
1077
+ path = _write_private(
1078
+ workspace.parent, transcript_stem, ".jsonl", redact(stdout or "", (self.api_key,))
1079
+ )
1080
+ with contextlib.suppress(OSError):
1081
+ last_message_path.unlink() # clean up on the timeout path too
1082
+ self._purge_auth(session_home)
1083
+ log.warning("codex session timed out after %ss in %s", self.timeout_s, workspace)
1084
+ return _error_result(
1085
+ "timeout",
1086
+ path,
1087
+ detail=f"session hit its {self.timeout_s}s walltime and was killed",
1088
+ )
1089
+ stdout = redact(stdout, (self.api_key,))
1090
+ transcript_path = _write_private(workspace.parent, transcript_stem, ".jsonl", stdout)
1091
+ last_message = ""
1092
+ # errors="replace": a non-UTF-8 last-message file must not raise
1093
+ # UnicodeDecodeError (not an OSError) and break the never-raises contract.
1094
+ with contextlib.suppress(OSError):
1095
+ last_message = redact(last_message_path.read_text(errors="replace"), (self.api_key,))
1096
+ # The final message is preserved in the 0600 transcript; drop the raw
1097
+ # file so model output is not left behind.
1098
+ with contextlib.suppress(OSError):
1099
+ last_message_path.unlink()
1100
+ self._purge_auth(session_home)
1101
+ return _parse_codex_result(
1102
+ stdout,
1103
+ last_message,
1104
+ process.returncode,
1105
+ transcript_path,
1106
+ stderr=redact(stderr, (self.api_key,)),
1107
+ )
1108
+
1109
+
1110
+ def _hermes_command(
1111
+ repo_dir: Path,
1112
+ query: str,
1113
+ model: str,
1114
+ base_url: str,
1115
+ max_turns: int,
1116
+ enabled_toolsets: tuple[str, ...],
1117
+ disabled_toolsets: tuple[str, ...],
1118
+ extra_args: tuple[str, ...],
1119
+ ) -> list[str]:
1120
+ """Argv for one headless hermes run (`run_agent.py`, fire-style flags,
1121
+ hermes-agent v0.20.1).
1122
+
1123
+ The BRIEF is never in argv — it is written to a file and `query` is only a
1124
+ short pointer instruction. The API key is never in argv either: hermes
1125
+ reads it from the env var its SEEDED PROVIDER's registry names (the
1126
+ harness exports it under key_env; openrouter reads OPENROUTER_API_KEY,
1127
+ openai-api reads OPENAI_API_KEY). --save_sample makes hermes write a JSON
1128
+ trajectory to its cwd, which is the machine-readable result channel."""
1129
+ argv = [
1130
+ "uv",
1131
+ "run",
1132
+ "--project",
1133
+ str(repo_dir),
1134
+ "python",
1135
+ str(repo_dir / "run_agent.py"),
1136
+ f"--query={query}",
1137
+ f"--max_turns={max_turns}",
1138
+ "--save_sample",
1139
+ ]
1140
+ if model:
1141
+ argv.append(f"--model={model}")
1142
+ if base_url:
1143
+ argv.append(f"--base_url={base_url}")
1144
+ # Embedded quotes are load-bearing: fire literal-evals flag values, so a
1145
+ # bare `a,b` becomes a Python TUPLE and hermes's .split(",") crashes.
1146
+ # `"a,b"` evals to the string hermes expects.
1147
+ if enabled_toolsets:
1148
+ argv.append(f'--enabled_toolsets="{",".join(enabled_toolsets)}"')
1149
+ if disabled_toolsets:
1150
+ argv.append(f'--disabled_toolsets="{",".join(disabled_toolsets)}"')
1151
+ return [*argv, *extra_args]
1152
+
1153
+
1154
+ def _parse_hermes_result(
1155
+ stdout: str, sample: Any, returncode: int, transcript_path: str = ""
1156
+ ) -> SessionResult:
1157
+ """Best-effort SessionResult from a hermes run.
1158
+
1159
+ Prefers the --save_sample trajectory JSON (assistant messages + turn
1160
+ count); falls back to raw stdout as the final text. Cost is left at 0
1161
+ (metered by the budget layer's proxy). Never raises."""
1162
+ final_text = ""
1163
+ num_turns = 0
1164
+ # hermes saves ShareGPT-format trajectories ({"from": "gpt", "value": ...}),
1165
+ # either as a bare list or wrapped in a dict; accept role/content too.
1166
+ messages: list[Any] = []
1167
+ if isinstance(sample, list):
1168
+ messages = sample
1169
+ elif isinstance(sample, dict):
1170
+ # run_agent.py --save_sample wraps the ShareGPT turns under
1171
+ # "conversations" (hermes v0.20.1); accept "messages"/"trajectory"
1172
+ # too for other paths. Missing this key makes num_turns==0 and
1173
+ # drops a real verdict as a bogus error.
1174
+ wrapped = (
1175
+ sample.get("conversations") or sample.get("messages") or sample.get("trajectory") or []
1176
+ )
1177
+ messages = wrapped if isinstance(wrapped, list) else []
1178
+ assistant = [
1179
+ m
1180
+ for m in messages
1181
+ if isinstance(m, dict)
1182
+ and (m.get("role") == "assistant" or m.get("from") == "gpt")
1183
+ and (m.get("content") or m.get("value"))
1184
+ ]
1185
+ num_turns = len(assistant)
1186
+ if assistant:
1187
+ content = assistant[-1].get("content") or assistant[-1].get("value")
1188
+ final_text = content if isinstance(content, str) else str(content)
1189
+ if not final_text:
1190
+ final_text = stdout.strip()[-20_000:]
1191
+ # hermes exits 0 even when every API call failed; a run with no
1192
+ # assistant output is a failure, not a report
1193
+ is_error = returncode != 0 or num_turns == 0
1194
+ return SessionResult(
1195
+ stop_reason="error" if is_error else "completed",
1196
+ is_error=is_error,
1197
+ error_detail=stdout.strip()[-500:] if is_error else "",
1198
+ cost_usd=0.0,
1199
+ num_turns=num_turns,
1200
+ session_id="", # HermesHarness.run injects the resume id (saved-transcript seam)
1201
+ final_text=final_text,
1202
+ transcript_path=transcript_path,
1203
+ )
1204
+
1205
+
1206
+ @dataclass
1207
+ class HermesHarness:
1208
+ """Headless hermes-agent (Nous Research, MIT) — the OSS backend behind the
1209
+ Harness seam, driven via `uv run <repo>/run_agent.py` from a pinned clone.
1210
+
1211
+ A first-class, interchangeable backend: like claude and codex, its boundary
1212
+ is the deployment's (a container where one exists, the ephemeral runner
1213
+ where one doesn't) plus the tokenless split — the session is spawned with a
1214
+ scrubbed environment and no credential beyond its own key, so its `file`
1215
+ toolset's arbitrary-path reads (including /proc) find nothing to lift, and
1216
+ its writes are confined to the disposable workspace. Roles differ by
1217
+ prompt, never by a hermes-specific posture.
1218
+ Instruction-file surface: hermes auto-loads AGENTS.md from the workspace,
1219
+ which sanitize_checkout already neutralizes in untrusted trees.
1220
+
1221
+ RESUME: hermes has no `--resume` flag, but it reads its brief from a file, so
1222
+ a resume rehydrates the prior conversation into the next brief (the harness
1223
+ keeps its own transcript in the per-run home). A resumed session therefore
1224
+ carries its full prior context — it cannot silently start blind — so the
1225
+ revise/wake loops resume hermes like the other backends.
1226
+
1227
+ `run` never raises: every failure comes back as an error SessionResult.
1228
+ """
1229
+
1230
+ supports_resume = True # via saved-transcript rehydration (no native flag)
1231
+
1232
+ api_key: str # exported via key_env (never argv)
1233
+ repo_dir: Path # pinned hermes-agent checkout
1234
+ # hermes resolves credentials from provider-specific env vars (a registry:
1235
+ # OPENROUTER_API_KEY for the OpenRouter default, ANTHROPIC_API_KEY for the
1236
+ # direct anthropic provider, ...); name the one matching the provider
1237
+ key_env: str = "OPENROUTER_API_KEY"
1238
+ # hermes needs a provider in ~/.hermes/config.yaml (a scrubbed HOME has
1239
+ # none, and it refuses to run "unconfigured"); when set, the harness
1240
+ # pre-seeds a minimal config in the per-run home
1241
+ provider: str = ""
1242
+ # approvals.deny: fnmatch globs hermes refuses before any yolo/mode-off
1243
+ # bypass (headless: a clean deny, never a hang). NOTE it matches SHELL
1244
+ # COMMANDS (the terminal tool), NOT the write_file/patch tool calls.
1245
+ # Useful to hardline-forbid specific dangerous commands.
1246
+ approvals_deny: tuple[str, ...] = ()
1247
+ model: str = "" # OpenRouter format (provider/model); empty -> hermes default
1248
+ base_url: str = "" # empty -> the seeded provider's own endpoint
1249
+ max_turns: int = DEFAULT_MAX_TURNS
1250
+ timeout_s: int = DEFAULT_TIMEOUT_S
1251
+ enabled_toolsets: tuple[str, ...] = ("file",)
1252
+ disabled_toolsets: tuple[str, ...] = (
1253
+ "terminal",
1254
+ "web",
1255
+ "search",
1256
+ "browser",
1257
+ "computer_use",
1258
+ "code_execution",
1259
+ "delegation",
1260
+ "cronjob",
1261
+ "skills",
1262
+ "memory",
1263
+ )
1264
+ extra_args: tuple[str, ...] = field(default_factory=tuple)
1265
+ # Apptainer image for session containment, same stance as the other
1266
+ # backends: when set, the session runs under `apptainer exec --containall
1267
+ # --cleanenv` seeing only the workspace, the per-run home, and a read-only
1268
+ # bind of the pinned hermes repo. The project venv and uv cache live in
1269
+ # the per-run home (UV_PROJECT_ENVIRONMENT/UV_CACHE_DIR), so the repo
1270
+ # bind stays read-only and nothing survives across runs.
1271
+ container_image: str = ""
1272
+ apptainer_binary: str = "apptainer"
1273
+
1274
+ def run(
1275
+ self, brief_text: str, workspace: Path, resume_session_id: str | None = None
1276
+ ) -> SessionResult:
1277
+ transcript_stem = f"{workspace.name}-hermes"
1278
+ session_home = workspace.parent / f"{workspace.name}-home"
1279
+ try:
1280
+ session_home.mkdir(parents=True, exist_ok=True, mode=0o700)
1281
+ os.chmod(session_home, 0o700)
1282
+ except OSError as exc:
1283
+ log.warning("could not create session home %s: %s", session_home, exc)
1284
+ return _error_result("workspace-error")
1285
+ # Resume: rehydrate the prior conversation into this brief. A missing
1286
+ # transcript is a hard error, NOT a blind fresh start (the deployment
1287
+ # must preserve the per-run home for resume, same as claude's $HOME /
1288
+ # codex's --home). A fresh run mints a session id to persist under.
1289
+ session_id = resume_session_id or uuid.uuid4().hex[:16]
1290
+ prior_turns: list[dict[str, str]] = []
1291
+ brief_to_send = brief_text
1292
+ if resume_session_id:
1293
+ loaded = _load_resume_transcript(session_home, resume_session_id)
1294
+ if loaded is None:
1295
+ log.warning("hermes resume %s: no saved transcript in %s", session_id, session_home)
1296
+ return _error_result(
1297
+ "resume-unavailable",
1298
+ detail="no saved transcript to restore this session's context",
1299
+ )
1300
+ prior_turns = loaded
1301
+ brief_to_send = f"{_render_resume_transcript(prior_turns)}\n\n{brief_text}"
1302
+ if self.provider:
1303
+ # minimal headless config: provider + default model, nothing else
1304
+ hermes_dir = session_home / ".hermes"
1305
+ try:
1306
+ hermes_dir.mkdir(mode=0o700, exist_ok=True)
1307
+ config_lines = ["model:\n", f' provider: "{self.provider}"\n']
1308
+ if self.model:
1309
+ config_lines.insert(1, f' default: "{self.model}"\n')
1310
+ if self.approvals_deny:
1311
+ config_lines.append("approvals:\n deny:\n")
1312
+ config_lines += [f' - "{glob}"\n' for glob in self.approvals_deny]
1313
+ if not _write_private_fixed(hermes_dir / "config.yaml", "".join(config_lines)):
1314
+ raise OSError("hermes config write refused (symlink?) or failed")
1315
+ except OSError as exc:
1316
+ log.warning("could not seed hermes config: %s", exc)
1317
+ return _error_result("workspace-error", detail="could not seed hermes config")
1318
+ # The brief travels via a 0600 file (argv is world-readable in /proc);
1319
+ # the --query is only a fixed pointer to it. On resume this file carries
1320
+ # the rehydrated prior context ahead of the new instructions.
1321
+ brief_path = Path(_write_private(session_home, "brief", ".md", brief_to_send))
1322
+ if not brief_path.name:
1323
+ return _error_result("workspace-error", detail="could not store the brief")
1324
+ query = (
1325
+ f"Read the file {brief_path} and follow it as your complete brief. "
1326
+ f"The tree to work on is at {workspace.resolve()}."
1327
+ )
1328
+ command = _hermes_command(
1329
+ Path(self.repo_dir),
1330
+ query,
1331
+ self.model,
1332
+ self.base_url,
1333
+ self.max_turns,
1334
+ self.enabled_toolsets,
1335
+ self.disabled_toolsets,
1336
+ self.extra_args,
1337
+ )
1338
+ if self.container_image:
1339
+ workspace_abs = workspace.resolve()
1340
+ home_abs = session_home.resolve()
1341
+ repo_abs = Path(self.repo_dir).resolve()
1342
+ command = [
1343
+ self.apptainer_binary,
1344
+ "exec",
1345
+ "--containall",
1346
+ "--cleanenv",
1347
+ "--bind",
1348
+ f"{workspace_abs}:{workspace_abs}",
1349
+ # --home mounts the per-run home at the same path inside and
1350
+ # sets $HOME to it — hermes config, brief, samples, and the
1351
+ # resume transcript all persist on the shared FS
1352
+ "--home",
1353
+ f"{home_abs}:{home_abs}",
1354
+ "--bind",
1355
+ f"{repo_abs}:{repo_abs}:ro",
1356
+ "--pwd",
1357
+ str(home_abs),
1358
+ self.container_image,
1359
+ *command,
1360
+ ]
1361
+ try:
1362
+ env = session_env(self.api_key, self.key_env, session_home)
1363
+ # the repo bind is read-only: uv builds the project venv and its
1364
+ # cache in the per-run home instead (fresh per run; nothing shared
1365
+ # across sessions)
1366
+ env["UV_PROJECT_ENVIRONMENT"] = str(session_home / "venv")
1367
+ env["UV_CACHE_DIR"] = str(session_home / "uv-cache")
1368
+ env["UV_LINK_MODE"] = "copy"
1369
+ if self.container_image:
1370
+ # --cleanenv drops the host env inside the container EXCEPT
1371
+ # APPTAINERENV_* — the key travels via env, never argv
1372
+ for k in (self.key_env, "UV_PROJECT_ENVIRONMENT", "UV_CACHE_DIR", "UV_LINK_MODE"):
1373
+ env[f"APPTAINERENV_{k}"] = env[k]
1374
+ # cwd is the per-run home, NOT the workspace: --save_sample writes
1375
+ # its trajectory JSON to cwd, and artifacts must never land in the
1376
+ # clone (they would enter the diff).
1377
+ process = subprocess.Popen(
1378
+ command,
1379
+ cwd=session_home,
1380
+ env=env,
1381
+ stdin=subprocess.DEVNULL,
1382
+ stdout=subprocess.PIPE,
1383
+ stderr=subprocess.STDOUT,
1384
+ text=True,
1385
+ start_new_session=True,
1386
+ )
1387
+ except OSError as exc:
1388
+ log.warning("could not spawn hermes: %s", exc)
1389
+ return _error_result("spawn-error", detail=str(exc)[:200])
1390
+ try:
1391
+ stdout, _ = process.communicate(timeout=self.timeout_s)
1392
+ except subprocess.TimeoutExpired:
1393
+ with contextlib.suppress(ProcessLookupError, PermissionError):
1394
+ os.killpg(process.pid, signal.SIGKILL)
1395
+ try:
1396
+ stdout, _ = process.communicate(timeout=10)
1397
+ except subprocess.TimeoutExpired:
1398
+ process.kill()
1399
+ stdout = ""
1400
+ with contextlib.suppress(subprocess.TimeoutExpired):
1401
+ stdout, _ = process.communicate(timeout=5)
1402
+ path = _write_private(
1403
+ workspace.parent, transcript_stem, ".log", redact(stdout or "", (self.api_key,))
1404
+ )
1405
+ log.warning("hermes session timed out after %ss in %s", self.timeout_s, workspace)
1406
+ _collect_hermes_sample(session_home) # drop trajectories (may embed the brief) too
1407
+ with contextlib.suppress(OSError):
1408
+ brief_path.unlink() # the brief holds PR content; clean up on timeout too
1409
+ return _error_result(
1410
+ "timeout",
1411
+ path,
1412
+ detail=f"session hit its {self.timeout_s}s walltime and was killed",
1413
+ )
1414
+ stdout = redact(stdout, (self.api_key,))
1415
+ transcript_path = _write_private(workspace.parent, transcript_stem, ".log", stdout)
1416
+ sample = _collect_hermes_sample(session_home)
1417
+ with contextlib.suppress(OSError):
1418
+ brief_path.unlink() # the brief holds PR content; don't leave it at rest
1419
+ result = _parse_hermes_result(stdout, sample, process.returncode, transcript_path)
1420
+ # Record this turn so a later resume can restore the context, and hand
1421
+ # back the session id it lives under. Only on a real reply — a failed
1422
+ # turn leaves the prior transcript intact (nothing useful to append).
1423
+ # REDACT before persisting: final_text comes from the sample, not the
1424
+ # already-redacted stdout, so an agent reply that echoed its session key
1425
+ # must not be written to the resume transcript at rest.
1426
+ if not result.is_error:
1427
+ secrets = (self.api_key,)
1428
+ prior_turns.append({"role": "user", "text": redact(brief_text, secrets)})
1429
+ prior_turns.append({"role": "assistant", "text": redact(result.final_text, secrets)})
1430
+ _save_resume_transcript(session_home, session_id, prior_turns)
1431
+ return replace(result, session_id=session_id)
1432
+
1433
+
1434
+ @dataclass
1435
+ class FakeHarness:
1436
+ """Deterministic in-process harness for tests and dry runs."""
1437
+
1438
+ result: SessionResult
1439
+ script: Any = None # optional callable(brief_text, workspace) for side effects
1440
+ calls: list[tuple[str, str, str | None]] = field(default_factory=list)
1441
+ supports_resume: bool = True # a field so tests can exercise the no-resume path
1442
+
1443
+ def run(
1444
+ self, brief_text: str, workspace: Path, resume_session_id: str | None = None
1445
+ ) -> SessionResult:
1446
+ self.calls.append((brief_text, str(workspace), resume_session_id))
1447
+ if self.script is not None:
1448
+ self.script(brief_text, workspace)
1449
+ return self.result