Source code for open_atp.harness.codex
"""Codex CLI harness."""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from open_atp.harness._paths import _SCRIPTS
from open_atp.harness.base import AuthSpec, Harness, HarnessRunResult
[docs]
class CodexHarness(Harness):
"""Codex CLI, authenticated by a mounted ``auth.json`` credential."""
name = "codex"
#: Holds the staged minimal ``.codex`` so it outlives :meth:`auth_spec` until the
#: backend pushes/bind-mounts it; cleaned up when the harness is collected.
_codex_home: tempfile.TemporaryDirectory[str] | None = None
def configure_wd(self, wd: Path, prompt: str) -> None:
super().configure_wd(wd, prompt)
# Codex registers the MCP server via -c overrides in the launch script;
# only the skills need copying. https://developers.openai.com/codex/skills
self._copy_skills(wd, ".agents/skills")
[docs]
def auth_spec(self) -> AuthSpec:
# Mount ONLY the auth credential, never the whole ~/.codex: the host's
# config.toml registers personal MCP servers (e.g. a localhost Zotero server)
# that don't exist in the sandbox, and codex aborts when one is unreachable.
# Stage a minimal .codex holding just auth.json -- the launch script supplies
# the lean-lsp MCP via -c overrides, so no host config is needed. Staged once
# and cached so both _auth() calls (mounts, then env) return the same dir and
# it survives until the backend mounts it.
auth = Path.home() / ".codex" / "auth.json"
if not auth.is_file():
raise RuntimeError(
"codex harness requires ~/.codex/auth.json from `codex login`"
)
if self._codex_home is None:
self._codex_home = tempfile.TemporaryDirectory(prefix="codex-home-")
# copy2 preserves auth.json's 0600 mode, which codex requires.
shutil.copy2(auth, Path(self._codex_home.name) / "auth.json")
return AuthSpec(home_dirs=[(Path(self._codex_home.name), ".codex")])
def _agent_command(self) -> str:
return self._render((_SCRIPTS / "codex_agent.sh").read_text())
def _parse_lines(self, lines: list[str]) -> HarnessRunResult:
"""Parse ``codex exec --json`` output."""
result = HarnessRunResult()
for line in lines:
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") != "turn.completed":
continue
usage = event.get("usage") or {}
it = (
usage.get("input_tokens")
or usage.get("inputTokens")
or usage.get("prompt_tokens")
or 0
)
ot = (
usage.get("output_tokens")
or usage.get("outputTokens")
or usage.get("completion_tokens")
or 0
)
if isinstance(it, int):
result.input_tokens += it
if isinstance(ot, int):
result.output_tokens += ot
sr = event.get("stop_reason") or event.get("finish_reason")
if isinstance(sr, str):
result.stop_reason = sr
# Codex does not surface USD; left as None so the prover fills from tokens.
return result