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 Harness, HarnessRunResult
[docs]
class CodexHarness(Harness):
"""Codex CLI, authenticated by a mounted ``auth.json`` credential.
Codex authenticates via ChatGPT/OpenAI, so it must run an OpenAI model; ``model``
defaults to ``gpt-5.5`` rather than the Anthropic base default.
Parameters
----------
model : str
Model id the agent runs; must be an OpenAI model. Default ``"gpt-5.5"``.
effort : str
Reasoning-effort level. Default ``"high"``.
auth_file : Path, optional
The Codex ``auth.json`` to mount. ``None`` (default) uses ``~/.codex/auth.json``
(from ``codex login``); resolution fails if the file is absent.
Examples
--------
>>> from open_atp.harness import CodexHarness
>>> harness = CodexHarness()
>>> harness.name
'codex'
>>> harness.model
'gpt-5.5'
"""
name = "codex"
skills_dest = ".agents/skills"
#: Holds the staged minimal ``.codex`` so it outlives :meth:`_home_dirs` until the
#: backend pushes/bind-mounts it; cleaned up when the harness is collected.
_codex_home: tempfile.TemporaryDirectory[str] | None = None
def __init__(
self,
*,
model: str = "gpt-5.5",
effort: str = "high",
auth_file: Path | None = None,
) -> None:
super().__init__(model=model, effort=effort)
self._auth_file = auth_file
def _home_dirs(self) -> list[tuple[Path, str]]:
# 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 = self._auth_file or 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 [(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