Skip to main content

kranz_engine/
backend_codex.rs

1//! Codex agent backend: drives `codex exec --json` headless.
2//!
3//! Ground truth is `crates/engine/tests/fixtures/codex_exec_scrutiny.jsonl`,
4//! a recorded `codex exec --json` transcript. This module is single-shot
5//! only: unlike `backend_claude`, there is no `--resume` and no
6//! streaming-input mode, so [`CodexSession::send_user_message`] and a
7//! `resume`d [`SessionSpec`] are both rejected at the seam rather than
8//! translated into codex flags.
9//!
10//! Several `SessionSpec` fields are claude-isms with no codex equivalent and
11//! are deliberately ignored when building argv: `json_schema`,
12//! `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
13//! `disallowed_tools`, `tools`, `settings_json`, `effort`.
14
15use crate::backend::{
16    AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
17};
18#[cfg(unix)]
19use crate::backend_claude::kill_group;
20#[cfg(windows)]
21use crate::backend_claude::win_job;
22use crate::cost;
23use crate::error::{EngineError, Result};
24use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
25use crate::types::TokenUsage;
26use serde_json::{json, Value};
27use std::collections::VecDeque;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::process::Stdio;
31use std::sync::{Arc, Mutex};
32use tokio::process::{Child, ChildStdout};
33use tokio::task::JoinHandle;
34
35/// Max characters kept in tool-use / tool-result summaries.
36const SUMMARY_MAX_CHARS: usize = 200;
37/// Max characters of captured stderr included in failure messages.
38const STDERR_TAIL_CHARS: usize = 500;
39
40/// The ambient var a codex session may authenticate with (injected
41/// explicitly, never via ambient inheritance).
42const CODEX_AUTH_ENV: &str = "OPENAI_API_KEY";
43
44/// The minimal `.codex` state seeded into a session's scratch HOME so file-
45/// based auth survives `agent-env-clear`: the CLI reads `auth.json` for
46/// credentials and `config.toml` for the operator's model/provider defaults.
47/// An unseeded scratch HOME 401s on the first request (observed live on
48/// m-eee81f orch-13). `sessions/` and other per-session state are deliberately
49/// excluded.
50const CODEX_SEED_ENTRIES: &[&str] = &["auth.json", "config.toml"];
51
52/// The cleared environment one `codex` session spawns with (ticket
53/// `agent-env-clear`), mirroring [`crate::backend_claude`]'s seeding
54/// contract: a spec carrying a relocated scratch `HOME` (worker relocation)
55/// is used verbatim; otherwise a fresh per-session scratch HOME is seeded
56/// with [`CODEX_SEED_ENTRIES`] so file-based auth and model/provider config
57/// survive. Seeding failure degrades to an empty scratch home — the session
58/// then fails auth loudly rather than silently inheriting the operator's real
59/// HOME. `OPENAI_API_KEY` is injected explicitly when set (logged name-only).
60fn codex_child_env(spec: &SessionSpec) -> std::collections::HashMap<String, String> {
61    if spec.env.contains_key("HOME") {
62        return crate::agent_env::agent_session_env(
63            &spec.env,
64            &spec.session_id,
65            Some(CODEX_AUTH_ENV),
66        );
67    }
68    let real_home = std::env::var_os("HOME").map(PathBuf::from);
69    let scratch_root = crate::backend_claude::scratch_home_root(&spec.session_id);
70    match seed_codex_scratch_home(&scratch_root, real_home.as_deref()) {
71        Ok(home) => {
72            tracing::info!(
73                session_id = %spec.session_id,
74                decision = "scratch-seeded",
75                "session spec carried no relocated HOME; spawning into a seeded scratch \
76                 HOME (.codex minimal auth/config set)"
77            );
78            crate::agent_env::session_env_with_home(
79                &spec.env,
80                &spec.session_id,
81                Some(CODEX_AUTH_ENV),
82                &home,
83            )
84        }
85        Err(e) => {
86            tracing::warn!(
87                session_id = %spec.session_id,
88                error = %e,
89                "codex scratch HOME seeding failed; session spawns into an empty scratch \
90                 HOME and will fail auth loudly if OPENAI_API_KEY is not injected"
91            );
92            crate::agent_env::agent_session_env(&spec.env, &spec.session_id, Some(CODEX_AUTH_ENV))
93        }
94    }
95}
96
97/// Seed `<scratch_root>/home/.codex` with [`CODEX_SEED_ENTRIES`], copied
98/// opaquely (bytes only, no parsing/logging of contents) from the real home's
99/// `.codex` when present; a missing source yields an empty-but-present
100/// `.codex`. Returns the home dir the child should get as `HOME`.
101fn seed_codex_scratch_home(
102    scratch_root: &Path,
103    real_home: Option<&Path>,
104) -> std::io::Result<PathBuf> {
105    let home = scratch_root.join("home");
106    let codex_dir = home.join(".codex");
107    std::fs::create_dir_all(&codex_dir)?;
108    // Owner-only on every scratch dir down to the seeded credential
109    // (2026-09-01 adversarial audit, H13): `scratch_root` lives under
110    // `std::env::temp_dir()`, which on Linux and CI runners is the SHARED
111    // `/tmp`, and `create_dir_all` leaves 0755 parents there. The uuid in
112    // the path buys nothing, because `/tmp` is listable.
113    restrict_to_owner(&[scratch_root, &home, &codex_dir])?;
114    if let Some(real_home) = real_home {
115        let source = real_home.join(".codex");
116        for entry in CODEX_SEED_ENTRIES {
117            let src = source.join(entry);
118            let dst = codex_dir.join(entry);
119            if src.is_file() {
120                // Create and stream instead of CopyFile: the scratch copy must
121                // inherit the destination directory's ACL rather than any
122                // protected descriptor attached to operator credentials.
123                // This also avoids CopyFile's intermittent ERROR_PATH_NOT_FOUND
124                // on hosted Windows runners after AppContainer ACL exercises.
125                //
126                // The stream is the one seeding site that does NOT carry the
127                // source mode over (every other backend uses `fs::copy`,
128                // which does), so `auth.json` — the Codex CLI's OAuth tokens
129                // and API key — landed 0666 & ~umask, typically 0644, in
130                // shared `/tmp` (2026-09-01 adversarial audit, H13). Create
131                // it 0600 and re-assert the mode after: `.mode()` applies
132                // only at creation, so a pre-existing file (impossible under
133                // `create_new`, but the invariant is the file's, not the
134                // call's) would otherwise keep whatever it had.
135                let mut source = std::fs::File::open(&src)?;
136                let mut options = std::fs::OpenOptions::new();
137                options.write(true).create_new(true);
138                #[cfg(unix)]
139                {
140                    use std::os::unix::fs::OpenOptionsExt as _;
141                    options.mode(0o600);
142                }
143                let mut target = options.open(&dst)?;
144                std::io::copy(&mut source, &mut target)?;
145                target.flush()?;
146                #[cfg(unix)]
147                {
148                    use std::os::unix::fs::PermissionsExt as _;
149                    std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600))?;
150                }
151            }
152        }
153    }
154    Ok(home)
155}
156
157/// Narrow each scratch directory to owner-only (`0700`) on unix. A no-op
158/// elsewhere: Windows scratch dirs inherit the operator profile's ACL, which
159/// is already the owner-only posture this achieves. A failure is surfaced,
160/// not swallowed — seeding into a world-readable dir is the defect.
161#[cfg(unix)]
162fn restrict_to_owner(dirs: &[&Path]) -> std::io::Result<()> {
163    use std::os::unix::fs::PermissionsExt as _;
164    for dir in dirs {
165        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
166    }
167    Ok(())
168}
169
170#[cfg(not(unix))]
171fn restrict_to_owner(_dirs: &[&Path]) -> std::io::Result<()> {
172    Ok(())
173}
174
175// ---------------------------------------------------------------------------
176// Binary discovery
177// ---------------------------------------------------------------------------
178
179/// Locate a working `codex` binary.
180///
181/// Order: `configured` → `KRANZ_CODEX_BIN` env var → `codex` on PATH →
182/// well-known install locations. Each candidate is validated by running it
183/// with `--version`; the first one that succeeds wins. Errors list every
184/// attempt so the user can see what was tried.
185///
186/// `KRANZ_CODEX_BIN`, when set and non-empty, is an *exclusive* override: only
187/// that path is probed, and a failure is returned immediately rather than
188/// falling through to PATH or the well-known fallback locations. Naming the
189/// binary explicitly and having it not work is an error, not a reason to
190/// search elsewhere.
191pub fn discover_codex_binary(configured: Option<&str>) -> Result<PathBuf> {
192    if let Some(env_bin) = std::env::var_os("KRANZ_CODEX_BIN") {
193        if !env_bin.is_empty() {
194            let candidate = PathBuf::from(env_bin);
195            return match probe_version(&candidate) {
196                Ok(_version) => Ok(candidate),
197                Err(why) => Err(EngineError::Config(format!(
198                    "KRANZ_CODEX_BIN points at {} which did not work: {why}",
199                    candidate.display()
200                ))),
201            };
202        }
203    }
204
205    let mut candidates: Vec<PathBuf> = Vec::new();
206    if let Some(configured) = configured {
207        candidates.push(PathBuf::from(configured));
208    }
209    // Bare names resolve through PATH (std::process handles .cmd/.exe lookup
210    // rules per-platform).
211    candidates.push(PathBuf::from("codex"));
212    #[cfg(windows)]
213    {
214        candidates.push(PathBuf::from("codex.cmd"));
215        candidates.push(PathBuf::from("codex.exe"));
216    }
217    candidates.extend(fallback_candidates());
218
219    // Dedupe, preserving priority order.
220    let mut deduped: Vec<PathBuf> = Vec::new();
221    for candidate in candidates {
222        if !deduped.contains(&candidate) {
223            deduped.push(candidate);
224        }
225    }
226
227    let mut attempts: Vec<String> = Vec::new();
228    for candidate in deduped {
229        match probe_version(&candidate) {
230            Ok(_version) => return Ok(candidate),
231            Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
232        }
233    }
234    Err(EngineError::Config(format!(
235        "no working codex binary found; tried: {}. Install Codex CLI \
236         (npm install -g @openai/codex) or point kranz at it via the \
237         validatorScrutiny.codexBinary config field or the KRANZ_CODEX_BIN \
238         environment variable.",
239        attempts.join(", ")
240    )))
241}
242
243/// Well-known install locations checked after PATH.
244#[cfg(not(windows))]
245fn fallback_candidates() -> Vec<PathBuf> {
246    let home = std::env::var_os("HOME").map(PathBuf::from);
247    let mut out = Vec::new();
248    if let Some(home) = &home {
249        out.push(home.join(".npm-global").join("bin").join("codex"));
250    }
251    out.push(PathBuf::from("/opt/homebrew/bin/codex"));
252    out.push(PathBuf::from("/usr/local/bin/codex"));
253    if let Some(home) = &home {
254        out.push(home.join(".local").join("bin").join("codex"));
255    }
256    out
257}
258
259/// Well-known install locations checked after PATH (Windows).
260#[cfg(windows)]
261fn fallback_candidates() -> Vec<PathBuf> {
262    let mut out = Vec::new();
263    if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
264        for dir in [
265            profile.join("AppData").join("Roaming").join("npm"),
266            profile.join(".npm-global").join("bin"),
267            profile.join(".local").join("bin"),
268        ] {
269            for name in ["codex.cmd", "codex.exe", "codex"] {
270                out.push(dir.join(name));
271            }
272        }
273    }
274    out
275}
276
277/// Deadline for a `--version` probe. Generous for a healthy CLI, but bounds
278/// a hung shim on PATH so binary discovery (`kranz ready`, session spawn)
279/// can never block forever on a candidate.
280const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
281
282/// Validate a candidate by running `<candidate> --version`, draining both
283/// output pipes concurrently while enforcing [`VERSION_PROBE_TIMEOUT`].
284fn probe_version(binary: &Path) -> std::result::Result<String, String> {
285    crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
286}
287
288// ---------------------------------------------------------------------------
289// Argument construction
290// ---------------------------------------------------------------------------
291
292/// The prompt text codex actually receives: `append_system_prompt` (if any)
293/// concatenated ahead of the prompt text — codex has no
294/// `--append-system-prompt` flag, so the engine folds it into the single
295/// positional PROMPT argument instead.
296fn effective_prompt(spec: &SessionSpec) -> String {
297    let prompt_text = match &spec.prompt {
298        PromptMode::SingleShot(text) => text.as_str(),
299        PromptMode::Streaming(text) => text.as_str(),
300    };
301    match &spec.append_system_prompt {
302        Some(system) if !system.is_empty() => format!("{system}\n\n{prompt_text}"),
303        _ => prompt_text.to_string(),
304    }
305}
306
307/// Build a TOML basic string literal (double-quoted with escapes) for a
308/// path that may contain spaces, backslashes, or single quotes.
309fn toml_basic_string(s: &str) -> String {
310    let mut out = String::with_capacity(s.len() + 2);
311    out.push('"');
312    for c in s.chars() {
313        match c {
314            '\\' => out.push_str("\\\\"),
315            '"' => out.push_str("\\\""),
316            '\n' => out.push_str("\\n"),
317            '\r' => out.push_str("\\r"),
318            '\t' => out.push_str("\\t"),
319            c => out.push(c),
320        }
321    }
322    out.push('"');
323    out
324}
325
326/// Build the argv (excluding the binary itself) for one session.
327///
328/// Public so tests can assert the exact CLI wire format without spawning.
329/// Deliberately ignores every claude-only `SessionSpec` field: `json_schema`,
330/// `max_budget_usd`, `resume`, `permission_mode`, `allowed_tools` /
331/// `disallowed_tools`, `tools`, `settings_json`, `effort`.
332pub fn build_args(spec: &SessionSpec) -> Vec<String> {
333    let sandbox = if spec.writable {
334        "workspace-write"
335    } else {
336        "read-only"
337    };
338    let mut args = vec![
339        "exec".into(),
340        "--json".into(),
341        "--sandbox".into(),
342        sandbox.into(),
343    ];
344    // Temp-dir worktrees (macOS /var/folders → /private/var) are outside
345    // Codex's default workspace-write roots. Pin the session cwd explicitly
346    // so workers can land deliverables (fix-codex-sandbox-writable-roots-worktree).
347    if spec.writable {
348        // TOML basic string (double-quoted) — literal `'…'` has no escapes
349        // and breaks on paths containing `'`.
350        let root = toml_basic_string(&spec.cwd.display().to_string());
351        args.push("-c".into());
352        args.push(format!("sandbox_workspace_write.writable_roots=[{root}]"));
353    }
354    args.push("--model".into());
355    args.push(spec.model.clone());
356    args.push(effective_prompt(spec));
357    args
358}
359
360// ---------------------------------------------------------------------------
361// codex exec --json line parsing
362// ---------------------------------------------------------------------------
363
364/// Parse one stdout line into zero or more [`AgentEvent`]s. `model` is the
365/// configured model, used both as the `Init` fallback (codex's
366/// `thread.started` carries no model field in observed output) and as the
367/// pricing key when a terminal event has no CLI-reported dollar cost.
368///
369/// Unparseable lines become [`AgentEvent::Other`] with
370/// `raw = {"unparsed": <line>}` so nothing is ever dropped from transcripts.
371pub fn parse_codex_line(line: &str, model: &str) -> Vec<AgentEvent> {
372    match serde_json::from_str::<Value>(line) {
373        Ok(value) => parse_codex_value(value, model),
374        Err(_) => vec![AgentEvent::Other {
375            raw: json!({ "unparsed": line }),
376        }],
377    }
378}
379
380/// Map one parsed `codex exec --json` value to events (see module docs /
381/// fixture).
382pub fn parse_codex_value(value: Value, model: &str) -> Vec<AgentEvent> {
383    let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
384    match line_type {
385        "thread.started" => vec![AgentEvent::Init {
386            session_id: str_field(&value, "thread_id"),
387            model: value
388                .get("model")
389                .and_then(Value::as_str)
390                .unwrap_or(model)
391                .to_string(),
392            raw: value,
393        }],
394        "item.started" if item_type(&value) == "command_execution" => {
395            let command = value
396                .pointer("/item/command")
397                .and_then(Value::as_str)
398                .unwrap_or("");
399            vec![AgentEvent::ToolUse {
400                tool: "command_execution".to_string(),
401                summary: truncate_chars(command, SUMMARY_MAX_CHARS),
402                raw: value,
403            }]
404        }
405        "item.completed" if item_type(&value) == "command_execution" => {
406            let output = value
407                .pointer("/item/aggregated_output")
408                .and_then(Value::as_str)
409                .unwrap_or("");
410            // A sandbox refusal reports a null exit_code alongside
411            // status == "failed" (see docs/scoping/codex-backend.md); a
412            // command that merely exits non-zero has a real exit_code and is
413            // a normal failure, not a denial.
414            let exit_code_is_null = value
415                .pointer("/item/exit_code")
416                .map(Value::is_null)
417                .unwrap_or(true);
418            let status = value
419                .pointer("/item/status")
420                .and_then(Value::as_str)
421                .unwrap_or("");
422            let denied = exit_code_is_null && status == "failed";
423            vec![AgentEvent::ToolResult {
424                tool: Some("command_execution".to_string()),
425                denied,
426                summary: truncate_chars(output, SUMMARY_MAX_CHARS),
427                raw: value,
428            }]
429        }
430        "item.completed" if item_type(&value) == "agent_message" => {
431            let text = value
432                .pointer("/item/text")
433                .and_then(Value::as_str)
434                .unwrap_or("");
435            if text.is_empty() {
436                vec![AgentEvent::Other { raw: value }]
437            } else {
438                vec![AgentEvent::Text {
439                    text: text.to_string(),
440                    raw: value,
441                }]
442            }
443        }
444        "turn.completed" => vec![parse_terminal(value, model)],
445        _ => vec![AgentEvent::Other { raw: value }],
446    }
447}
448
449fn item_type(value: &Value) -> &str {
450    value
451        .pointer("/item/type")
452        .and_then(Value::as_str)
453        .unwrap_or("")
454}
455
456fn str_field(value: &Value, key: &str) -> String {
457    value
458        .get(key)
459        .and_then(Value::as_str)
460        .unwrap_or_default()
461        .to_string()
462}
463
464// ---------------------------------------------------------------------------
465// Stateful stream parsing (stitches agent_message text into the terminal
466// Result — see module docs / docs/scoping/codex-backend.md)
467// ---------------------------------------------------------------------------
468
469/// Stateful wrapper around [`parse_codex_line`] that remembers the most
470/// recent `agent_message` [`AgentEvent::Text`] and stitches it into the
471/// terminal [`AgentEvent::Result`] when a `turn.completed` line arrives.
472///
473/// `turn.completed` carries no text of its own; the final `agent_message` of
474/// the turn is the codex analogue of Claude's terminal result text (the
475/// validator report JSON), so "last agent_message wins".
476#[derive(Debug, Default)]
477pub struct CodexStreamParser {
478    last_text: Option<String>,
479}
480
481impl CodexStreamParser {
482    pub fn new() -> Self {
483        CodexStreamParser::default()
484    }
485
486    /// Parse one stdout line, filling in any remembered `agent_message` text
487    /// on a terminal `Result` event.
488    pub fn push(&mut self, line: &str, model: &str) -> Vec<AgentEvent> {
489        parse_codex_line(line, model)
490            .into_iter()
491            .map(|event| self.observe(event))
492            .collect()
493    }
494
495    fn observe(&mut self, event: AgentEvent) -> AgentEvent {
496        match event {
497            AgentEvent::Text { text, raw } => {
498                self.last_text = Some(text.clone());
499                AgentEvent::Text { text, raw }
500            }
501            AgentEvent::Result {
502                text,
503                is_error,
504                usage,
505                cost_usd,
506                num_turns,
507                raw,
508            } if text.is_empty() => AgentEvent::Result {
509                text: self.last_text.take().unwrap_or_default(),
510                is_error,
511                usage,
512                cost_usd,
513                num_turns,
514                raw,
515            },
516            other => other,
517        }
518    }
519}
520
521fn parse_terminal(value: Value, model: &str) -> AgentEvent {
522    let usage_field = |key: &str| {
523        value
524            .pointer(&format!("/usage/{key}"))
525            .and_then(Value::as_u64)
526            .unwrap_or(0)
527    };
528    // Reasoning tokens are output tokens for billing purposes; codex reports
529    // them as a separate `reasoning_output_tokens` field alongside
530    // `output_tokens`.
531    let cache_read = usage_field("cached_input_tokens");
532    let cache_write = usage_field("cache_write_input_tokens");
533    let usage = TokenUsage {
534        // Codex reports both cache lanes as subsets of input_tokens. Keep all
535        // TokenUsage lanes disjoint so the pricing fallback never bills a
536        // cached token once at the full rate and again at its cache rate.
537        input: usage_field("input_tokens")
538            .saturating_sub(cache_read)
539            .saturating_sub(cache_write),
540        output: usage_field("output_tokens") + usage_field("reasoning_output_tokens"),
541        cache_read,
542        cache_write,
543    };
544    let cost_usd = value
545        .get("total_cost_usd")
546        .and_then(Value::as_f64)
547        .or_else(|| value.get("cost_usd").and_then(Value::as_f64))
548        .or_else(|| Some(cost::usage_cost_usd(&usage, model)));
549    AgentEvent::Result {
550        text: String::new(),
551        is_error: value
552            .get("is_error")
553            .and_then(Value::as_bool)
554            .unwrap_or(false),
555        usage,
556        cost_usd,
557        num_turns: Some(1),
558        raw: value,
559    }
560}
561
562/// Keep at most `max` characters (not bytes — never splits a code point).
563fn truncate_chars(text: &str, max: usize) -> String {
564    if text.chars().count() <= max {
565        text.to_string()
566    } else {
567        text.chars().take(max).collect()
568    }
569}
570
571/// Last `max` characters of `text` (for stderr tails in error messages).
572fn last_chars(text: &str, max: usize) -> String {
573    let chars: Vec<char> = text.chars().collect();
574    let start = chars.len().saturating_sub(max);
575    chars[start..].iter().collect()
576}
577
578// ---------------------------------------------------------------------------
579// Backend
580// ---------------------------------------------------------------------------
581
582/// The [`AgentBackend`] for `codex exec --json`: single-shot with sandbox
583/// mode selected from the session role.
584#[derive(Debug, Clone)]
585pub struct CodexBackend {
586    binary: PathBuf,
587}
588
589impl CodexBackend {
590    /// Use an explicit binary path (no validation performed).
591    pub fn new(binary: impl Into<PathBuf>) -> Self {
592        CodexBackend {
593            binary: binary.into(),
594        }
595    }
596
597    /// Discover the binary via [`discover_codex_binary`].
598    pub fn discover(configured: Option<&str>) -> Result<Self> {
599        Ok(CodexBackend {
600            binary: discover_codex_binary(configured)?,
601        })
602    }
603
604    /// The binary this backend spawns.
605    pub fn binary(&self) -> &Path {
606        &self.binary
607    }
608}
609
610#[async_trait::async_trait]
611impl AgentBackend for CodexBackend {
612    async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
613        if spec.resume.is_some() {
614            return Err(EngineError::Backend(
615                "codex backend is single-shot only; resume is unsupported".to_string(),
616            ));
617        }
618        let model = spec.model.clone();
619        let args = build_args(&spec);
620
621        let mut command = tokio::process::Command::new(&self.binary);
622        command
623            .args(&args)
624            .current_dir(&spec.cwd)
625            // agent-env-clear: CLEARED env from the minimal allowlist; the
626            // one ambient var a codex session may authenticate with is
627            // injected explicitly, never the whole ambient set.
628            .env_clear()
629            .envs(codex_child_env(&spec))
630            .stdin(Stdio::null())
631            .stdout(Stdio::piped())
632            .stderr(Stdio::piped())
633            .kill_on_drop(true);
634        // Unix: make the child the leader of a fresh process group so aborts
635        // can kill the whole tree, mirroring `backend_claude::ClaudeBackend`.
636        #[cfg(unix)]
637        command.process_group(0);
638
639        let mut child = command.spawn().map_err(|e| {
640            EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
641        })?;
642
643        // Windows: kill-on-close Job Object, mirroring `backend_claude`.
644        #[cfg(windows)]
645        let job = match child.raw_handle() {
646            Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
647                Ok(job) => Some(job),
648                Err(e) => {
649                    tracing::warn!(error = %e, "failed to create Job Object for codex child; \
650                        tree-kill on abort will be unavailable");
651                    None
652                }
653            },
654            None => None,
655        };
656
657        let stdout = child
658            .stdout
659            .take()
660            .ok_or_else(|| EngineError::Backend("codex child has no stdout pipe".to_string()))?;
661        let stderr = child
662            .stderr
663            .take()
664            .ok_or_else(|| EngineError::Backend("codex child has no stderr pipe".to_string()))?;
665
666        // Capture stderr concurrently so a chatty child never blocks on a
667        // full pipe and failure messages can include the tail. The stream is
668        // drained to EOF but only a bounded tail is retained — a noisy or
669        // malicious CLI must not exhaust host memory (stream_bounds).
670        let stderr_buf = Arc::new(Mutex::new(String::new()));
671        let stderr_task = {
672            let buf = Arc::clone(&stderr_buf);
673            tokio::spawn(async move {
674                let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
675                *buf.lock().expect("stderr buffer lock") = tail;
676            })
677        };
678
679        Ok(Box::new(CodexSession {
680            session_id: spec.session_id.clone(),
681            model,
682            child,
683            #[cfg(windows)]
684            job,
685            lines: BoundedLines::new(stdout),
686            stderr_buf,
687            stderr_task: Some(stderr_task),
688            queue: VecDeque::new(),
689            stream_parser: CodexStreamParser::new(),
690            saw_result: false,
691            saw_success_result: false,
692            exit: None,
693        }))
694    }
695}
696
697// ---------------------------------------------------------------------------
698// Session
699// ---------------------------------------------------------------------------
700
701/// A live `codex exec --json` session (the [`AgentSession`] impl).
702///
703/// Single-shot only: [`send_user_message`](AgentSession::send_user_message)
704/// always errors, and there is no streaming stdin to hold open.
705pub struct CodexSession {
706    session_id: String,
707    model: String,
708    child: Child,
709    #[cfg(windows)]
710    job: Option<win_job::JobHandle>,
711    lines: BoundedLines<ChildStdout>,
712    stderr_buf: Arc<Mutex<String>>,
713    stderr_task: Option<JoinHandle<()>>,
714    /// Multi-block lines queue several events; popped one per `next_event`.
715    queue: VecDeque<AgentEvent>,
716    stream_parser: CodexStreamParser,
717    saw_result: bool,
718    saw_success_result: bool,
719    exit: Option<SessionExit>,
720}
721
722#[cfg(unix)]
723impl Drop for CodexSession {
724    fn drop(&mut self) {
725        crate::backend_claude::kill_unreaped_group(&self.child);
726    }
727}
728
729impl CodexSession {
730    fn observe(&mut self, event: &AgentEvent) {
731        match event {
732            AgentEvent::Init { session_id, .. } => {
733                self.session_id = session_id.clone();
734            }
735            AgentEvent::Result { is_error, .. } => {
736                self.saw_result = true;
737                if !is_error {
738                    self.saw_success_result = true;
739                }
740            }
741            _ => {}
742        }
743    }
744
745    /// Kill the child and reap it, best-effort; also joins the stderr capture
746    /// task. Mirrors `backend_claude::ClaudeSession::kill_child` exactly:
747    /// unix process-group SIGKILL (with a post-reap sweep for stragglers that
748    /// raced a mid-fork), windows kill-on-close Job Object.
749    async fn kill_child(&mut self) {
750        #[cfg(unix)]
751        {
752            let pgid = self
753                .child
754                .id()
755                .and_then(|pid| i32::try_from(pid).ok())
756                .filter(|pid| *pid > 0);
757            let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
758            if !group_killed {
759                let _ = self.child.start_kill();
760            }
761            let _ = self.child.wait().await;
762            if group_killed {
763                if let Some(pgid) = pgid {
764                    let _ = kill_group(pgid);
765                }
766            }
767        }
768        #[cfg(windows)]
769        {
770            match &self.job {
771                Some(job) => job.kill(),
772                None => {
773                    let _ = self.child.start_kill();
774                }
775            }
776            let _ = self.child.wait().await;
777        }
778        #[cfg(all(not(unix), not(windows)))]
779        {
780            let _ = self.child.start_kill();
781            let _ = self.child.wait().await;
782        }
783        if let Some(task) = self.stderr_task.take() {
784            let _ = task.await;
785        }
786    }
787
788    async fn finish_at_eof(&mut self) {
789        let status = self.child.wait().await;
790        if let Some(task) = self.stderr_task.take() {
791            let _ = task.await;
792        }
793        let exit = match status {
794            Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
795            Ok(status) => SessionExit::Failed(format!(
796                "codex exited with {status}{}; stderr tail: {}",
797                if self.saw_result {
798                    ""
799                } else {
800                    " without emitting a terminal event"
801                },
802                self.stderr_tail(),
803            )),
804            Err(e) => SessionExit::Failed(format!(
805                "failed to reap codex process: {e}; stderr tail: {}",
806                self.stderr_tail(),
807            )),
808        };
809        self.exit = Some(exit);
810    }
811
812    fn stderr_tail(&self) -> String {
813        let captured = self
814            .stderr_buf
815            .lock()
816            .map(|guard| guard.clone())
817            .unwrap_or_default();
818        last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
819    }
820}
821
822#[async_trait::async_trait]
823impl AgentSession for CodexSession {
824    fn session_id(&self) -> String {
825        self.session_id.clone()
826    }
827
828    async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
829        loop {
830            if let Some(event) = self.queue.pop_front() {
831                return Ok(Some(event));
832            }
833            if self.exit.is_some() {
834                return Ok(None);
835            }
836            let line = match self.lines.next_line().await {
837                Ok(Some(line)) => line,
838                Ok(None) => {
839                    self.finish_at_eof().await;
840                    return Ok(None);
841                }
842                Err(e) => {
843                    self.kill_child().await;
844                    self.exit = Some(SessionExit::Failed(format!(
845                        "error reading codex stdout: {e}; stderr tail: {}",
846                        self.stderr_tail(),
847                    )));
848                    return Ok(None);
849                }
850            };
851            if line.trim().is_empty() {
852                continue;
853            }
854            let events = self.stream_parser.push(&line, &self.model);
855            for event in &events {
856                self.observe(event);
857            }
858            self.queue.extend(events);
859        }
860    }
861
862    async fn send_user_message(&mut self, _text: &str) -> Result<()> {
863        Err(EngineError::Backend(
864            "codex backend is single-shot only; send_user_message is unsupported".to_string(),
865        ))
866    }
867
868    async fn abort(&mut self) -> Result<()> {
869        let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
870        self.kill_child().await;
871        if self.saw_success_result && already_exited {
872            self.exit = Some(SessionExit::Completed);
873        } else {
874            self.exit = Some(SessionExit::Aborted);
875        }
876        Ok(())
877    }
878
879    fn exit_status(&self) -> Option<SessionExit> {
880        self.exit.clone()
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use crate::cost::DEFAULT_CODEX_MODEL;
888
889    #[test]
890    #[cfg(unix)]
891    fn probe_version_kills_a_hung_binary_within_the_deadline() {
892        use std::os::unix::fs::PermissionsExt;
893        let dir = tempfile::tempdir().unwrap();
894        let stub = dir.path().join("hung-codex");
895        std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
896        std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
897
898        let start = std::time::Instant::now();
899        let result = probe_version(&stub);
900
901        let error = result.expect_err("a hung probe must be reported as broken");
902        assert!(error.contains("did not exit"), "{error}");
903        assert!(
904            start.elapsed() < std::time::Duration::from_secs(10),
905            "probe returned within the deadline, not after the stub's sleep"
906        );
907    }
908
909    fn fixture_lines_named(name: &str) -> Vec<String> {
910        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
911            .join("tests")
912            .join("fixtures")
913            .join(name);
914        std::fs::read_to_string(path)
915            .expect("read fixture")
916            .lines()
917            .filter(|line| !line.trim().is_empty())
918            .map(|line| line.to_string())
919            .collect()
920    }
921
922    fn fixture_lines() -> Vec<String> {
923        fixture_lines_named("codex_exec_scrutiny.jsonl")
924    }
925
926    #[test]
927    fn backend_codex_parse_fixture() {
928        let mut events: Vec<AgentEvent> = Vec::new();
929        for line in fixture_lines() {
930            events.extend(parse_codex_line(&line, DEFAULT_CODEX_MODEL));
931        }
932
933        assert!(
934            events.iter().any(
935                |e| matches!(e, AgentEvent::Init { session_id, .. } if !session_id.is_empty())
936            ),
937            "expected an Init event with a non-empty session id"
938        );
939        assert!(
940            events
941                .iter()
942                .any(|e| matches!(e, AgentEvent::Text { text, .. } if !text.is_empty())),
943            "expected at least one Text event"
944        );
945        assert!(
946            events.iter().any(
947                |e| matches!(e, AgentEvent::ToolUse { tool, .. } if tool == "command_execution")
948            ),
949            "expected a ToolUse event with tool == \"command_execution\""
950        );
951        assert!(
952            events.iter().any(
953                |e| matches!(e, AgentEvent::ToolResult { tool, .. } if tool.as_deref() == Some("command_execution"))
954            ),
955            "expected a ToolResult event with tool == Some(\"command_execution\")"
956        );
957
958        let terminal = events
959            .iter()
960            .find_map(|e| match e {
961                AgentEvent::Result {
962                    usage,
963                    cost_usd,
964                    num_turns,
965                    ..
966                } => Some((usage, cost_usd, num_turns)),
967                _ => None,
968            })
969            .expect("expected a terminal Result event");
970        let (usage, cost_usd, num_turns) = terminal;
971        assert!(
972            usage.input > 0 || usage.output > 0 || usage.cache_read > 0,
973            "expected non-zero usage on the terminal Result"
974        );
975        assert!(cost_usd.is_some(), "expected cost_usd to be Some");
976        assert_eq!(
977            *num_turns,
978            Some(1),
979            "expected the terminal Result's num_turns to be Some(1)"
980        );
981    }
982
983    #[test]
984    fn command_execution_denied_derives_from_structured_fields_not_output_text() {
985        let completed = json!({
986            "type": "item.completed",
987            "item": {
988                "type": "command_execution",
989                "command": "grep foo bar.txt",
990                "aggregated_output": "",
991                "exit_code": 0,
992                "status": "completed"
993            }
994        });
995        let events = parse_codex_value(completed, DEFAULT_CODEX_MODEL);
996        match &events[0] {
997            AgentEvent::ToolResult { denied, .. } => {
998                assert!(
999                    !denied,
1000                    "a real exit_code with status completed must not be denied"
1001                )
1002            }
1003            other => panic!("expected ToolResult, got {other:?}"),
1004        }
1005
1006        let refused = json!({
1007            "type": "item.completed",
1008            "item": {
1009                "type": "command_execution",
1010                "command": "rm -rf /",
1011                "aggregated_output": "",
1012                "exit_code": null,
1013                "status": "failed"
1014            }
1015        });
1016        let events = parse_codex_value(refused, DEFAULT_CODEX_MODEL);
1017        match &events[0] {
1018            AgentEvent::ToolResult { denied, .. } => {
1019                assert!(
1020                    *denied,
1021                    "a null exit_code with status failed must be denied"
1022                )
1023            }
1024            other => panic!("expected ToolResult, got {other:?}"),
1025        }
1026    }
1027
1028    #[test]
1029    fn backend_codex_stream_parser_stitches_terminal_text() {
1030        let mut parser = CodexStreamParser::new();
1031        let mut events: Vec<AgentEvent> = Vec::new();
1032        for line in fixture_lines() {
1033            events.extend(parser.push(&line, DEFAULT_CODEX_MODEL));
1034        }
1035
1036        let terminal_text = events
1037            .iter()
1038            .find_map(|e| match e {
1039                AgentEvent::Result { text, .. } => Some(text.clone()),
1040                _ => None,
1041            })
1042            .expect("expected a terminal Result event");
1043        assert!(
1044            !terminal_text.is_empty(),
1045            "expected the terminal Result text to be stitched from the last agent_message"
1046        );
1047
1048        let report = crate::runner::parse_validator_report(&terminal_text)
1049            .expect("terminal text should parse as a ValidatorReport");
1050        assert!(
1051            !report.findings.is_empty(),
1052            "expected the fixture's ValidatorReport to have findings"
1053        );
1054    }
1055
1056    #[test]
1057    fn backend_codex_parses_gpt_5_6_sol_probe_fixture() {
1058        let mut parser = CodexStreamParser::new();
1059        let events = fixture_lines_named("codex_exec_gpt_5_6_sol_probe.jsonl")
1060            .into_iter()
1061            .flat_map(|line| parser.push(&line, DEFAULT_CODEX_MODEL))
1062            .collect::<Vec<_>>();
1063
1064        assert!(events.iter().any(|event| {
1065            matches!(event, AgentEvent::Init { model, .. } if model == "gpt-5.6-sol")
1066        }));
1067        assert!(events.iter().any(|event| {
1068            matches!(event, AgentEvent::Text { text, .. } if text == "KRANZ_PROBE_OK")
1069        }));
1070
1071        let (usage, cost) = events
1072            .iter()
1073            .find_map(|event| match event {
1074                AgentEvent::Result {
1075                    usage,
1076                    cost_usd: Some(cost),
1077                    ..
1078                } => Some((usage, cost)),
1079                _ => None,
1080            })
1081            .expect("Sol probe must produce a priced terminal event");
1082        assert_eq!(usage.input, 4_811);
1083        assert_eq!(usage.cache_read, 9_984);
1084        assert_eq!(usage.output, 10);
1085
1086        let expected =
1087            4_811.0 / 1_000_000.0 * 4.0 + 9_984.0 / 1_000_000.0 * 0.4 + 10.0 / 1_000_000.0 * 20.0;
1088        assert!(
1089            (*cost - expected).abs() < 1e-9,
1090            "got {cost}, expected {expected}"
1091        );
1092    }
1093
1094    #[test]
1095    fn backend_codex_keeps_cache_read_write_and_uncached_input_disjoint() {
1096        let event = parse_terminal(
1097            json!({
1098                "type": "turn.completed",
1099                "usage": {
1100                    "input_tokens": 100,
1101                    "cached_input_tokens": 30,
1102                    "cache_write_input_tokens": 20,
1103                    "output_tokens": 4,
1104                    "reasoning_output_tokens": 2
1105                }
1106            }),
1107            DEFAULT_CODEX_MODEL,
1108        );
1109        match event {
1110            AgentEvent::Result { usage, .. } => {
1111                assert_eq!(usage.input, 50);
1112                assert_eq!(usage.cache_read, 30);
1113                assert_eq!(usage.cache_write, 20);
1114                assert_eq!(usage.output, 6);
1115            }
1116            other => panic!("expected terminal result, got {other:?}"),
1117        }
1118    }
1119
1120    #[test]
1121    fn seed_codex_scratch_home_copies_the_minimal_auth_config_set() {
1122        let real_home = tempfile::tempdir().unwrap();
1123        let codex = real_home.path().join(".codex");
1124        std::fs::create_dir_all(&codex).unwrap();
1125        std::fs::write(codex.join("auth.json"), "{}").unwrap();
1126        std::fs::write(codex.join("config.toml"), "model = \"gpt-5\"").unwrap();
1127        // Per-session state is never seeded.
1128        std::fs::create_dir_all(codex.join("sessions")).unwrap();
1129        std::fs::write(codex.join("sessions").join("s1.jsonl"), "{}").unwrap();
1130        let scratch = tempfile::tempdir().unwrap();
1131
1132        let home = seed_codex_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
1133
1134        let seeded = home.join(".codex");
1135        assert!(seeded.join("auth.json").is_file());
1136        assert!(seeded.join("config.toml").is_file());
1137        assert!(
1138            !seeded.join("sessions").exists(),
1139            "per-session transcripts are never seeded"
1140        );
1141    }
1142
1143    /// H13 (2026-09-01 adversarial audit): the seed streams bytes through
1144    /// `OpenOptions` rather than `fs::copy`, so it did NOT carry the
1145    /// source's `0600` over — `auth.json` (the Codex CLI's OAuth tokens and
1146    /// API key) landed `0666 & ~umask`, typically 0644, under `0755` parents
1147    /// in `std::env::temp_dir()`. On Linux and CI runners that is the shared
1148    /// `/tmp`, and the uuid in the path buys nothing because `/tmp` is
1149    /// listable.
1150    #[cfg(unix)]
1151    #[test]
1152    fn seed_codex_scratch_home_writes_owner_only_credentials_and_dirs() {
1153        use std::os::unix::fs::PermissionsExt as _;
1154
1155        let real_home = tempfile::tempdir().unwrap();
1156        let codex = real_home.path().join(".codex");
1157        std::fs::create_dir_all(&codex).unwrap();
1158        std::fs::write(codex.join("auth.json"), "{\"token\":\"secret\"}").unwrap();
1159        std::fs::write(codex.join("config.toml"), "model = \"gpt-5\"").unwrap();
1160        let scratch = tempfile::tempdir().unwrap();
1161        let scratch_root = scratch.path().join("kranz-worker-home-abc");
1162        std::fs::create_dir_all(&scratch_root).unwrap();
1163
1164        let home = seed_codex_scratch_home(&scratch_root, Some(real_home.path())).unwrap();
1165
1166        let mode =
1167            |path: &std::path::Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
1168        for entry in ["auth.json", "config.toml"] {
1169            assert_eq!(
1170                mode(&home.join(".codex").join(entry)),
1171                0o600,
1172                "{entry} must be owner-only"
1173            );
1174        }
1175        // Every parent down to the credential, or the 0600 leaf is still
1176        // reachable by name from a listable shared /tmp.
1177        for dir in [&scratch_root, &home, &home.join(".codex")] {
1178            assert_eq!(mode(dir), 0o700, "{} must be owner-only", dir.display());
1179        }
1180    }
1181
1182    #[test]
1183    fn seed_codex_scratch_home_without_a_source_yields_an_empty_seed() {
1184        let real_home = tempfile::tempdir().unwrap();
1185        let scratch = tempfile::tempdir().unwrap();
1186
1187        let home = seed_codex_scratch_home(scratch.path(), Some(real_home.path())).unwrap();
1188
1189        let seeded = home.join(".codex");
1190        assert!(seeded.is_dir());
1191        assert_eq!(std::fs::read_dir(&seeded).unwrap().count(), 0);
1192    }
1193
1194    #[test]
1195    fn build_args_ignores_claude_only_fields() {
1196        let spec = SessionSpec {
1197            cwd: PathBuf::from("."),
1198            prompt: PromptMode::SingleShot("do the thing".to_string()),
1199            append_system_prompt: Some("be terse".to_string()),
1200            model: "gpt-5-codex".to_string(),
1201            effort: "high".to_string(),
1202            session_id: "sess-1".to_string(),
1203            resume: None,
1204            permission_mode: Some("acceptEdits".to_string()),
1205            allowed_tools: vec!["Bash(npm test*)".to_string()],
1206            disallowed_tools: vec!["Bash(git push*)".to_string()],
1207            tools: vec!["Bash".to_string()],
1208            writable: false,
1209            settings_json: Some(json!({"hooks": {}})),
1210            json_schema: Some(json!({"type": "object"})),
1211            max_budget_usd: Some(5.0),
1212            max_turns: Some(10),
1213            env: Default::default(),
1214            sandbox: None,
1215            hook_status: None,
1216        };
1217        let args = build_args(&spec);
1218        assert_eq!(
1219            args,
1220            vec![
1221                "exec".to_string(),
1222                "--json".to_string(),
1223                "--sandbox".to_string(),
1224                "read-only".to_string(),
1225                "--model".to_string(),
1226                "gpt-5-codex".to_string(),
1227                "be terse\n\ndo the thing".to_string(),
1228            ]
1229        );
1230    }
1231
1232    #[test]
1233    fn build_args_uses_workspace_write_for_writable_sessions() {
1234        let spec = SessionSpec {
1235            cwd: PathBuf::from("."),
1236            prompt: PromptMode::SingleShot("do the thing".to_string()),
1237            append_system_prompt: None,
1238            model: "gpt-5-codex".to_string(),
1239            effort: "high".to_string(),
1240            session_id: "sess-1".to_string(),
1241            resume: None,
1242            permission_mode: None,
1243            allowed_tools: vec![],
1244            disallowed_tools: vec![],
1245            tools: vec![],
1246            writable: true,
1247            settings_json: None,
1248            json_schema: None,
1249            max_budget_usd: None,
1250            max_turns: None,
1251            env: Default::default(),
1252            sandbox: None,
1253            hook_status: None,
1254        };
1255        let args = build_args(&spec);
1256        assert_eq!(
1257            args,
1258            vec![
1259                "exec".to_string(),
1260                "--json".to_string(),
1261                "--sandbox".to_string(),
1262                "workspace-write".to_string(),
1263                "-c".to_string(),
1264                "sandbox_workspace_write.writable_roots=[\".\"]".to_string(),
1265                "--model".to_string(),
1266                "gpt-5-codex".to_string(),
1267                "do the thing".to_string(),
1268            ]
1269        );
1270    }
1271}