Skip to main content

harness/codex/
mod.rs

1//! OpenAI Codex (`codex`) as a [`Harness`].
2//!
3//! Same process-spawn shape as the bob and Claude adapters — a
4//! different binary, flags, and stdout parser. We invoke
5//! `codex exec --json` and parse its JSONL into the shared
6//! normalized [`crate::RunEvent`] stream.
7//!
8//! Auth: like Claude Code, Codex manages its own credentials (its
9//! `codex login` / ChatGPT auth or its own `OPENAI_API_KEY` in the
10//! environment), so Compose does not store or inject a key —
11//! `credential().required` is `false`.
12//!
13//! The stdout wire format and its decode — including the stateful
14//! [`CodexStreamParser`] that resolves codex's preamble-vs-answer
15//! ambiguity — live in [`parser`].
16
17use std::path::PathBuf;
18use std::process::Command;
19use std::sync::{Arc, Mutex};
20
21use serde_json::Value;
22
23use crate::{
24    spawn_streaming, CredentialSpec, Harness, HarnessCapabilities, HarnessError, HarnessInfo,
25    HarnessModel, HarnessReadiness, InstallCallback, InstallEvent, RunCallback, RunHandle, RunMode,
26    RunRequest, RunTuning,
27};
28
29mod parser;
30pub use parser::{parse_codex_line, CodexStreamParser};
31
32/// Registry id for the Codex harness.
33pub const CODEX_HARNESS_ID: &str = "codex";
34
35/// OpenAI Codex CLI as a [`Harness`].
36#[derive(Debug, Default, Clone)]
37pub struct CodexHarness;
38
39impl CodexHarness {
40    pub fn new() -> Self {
41        Self
42    }
43}
44
45impl Harness for CodexHarness {
46    fn info(&self) -> HarnessInfo {
47        HarnessInfo {
48            id: CODEX_HARNESS_ID.to_owned(),
49            display_name: "Codex".to_owned(),
50            description: "OpenAI's Codex agent CLI. Uses your existing Codex login.".to_owned(),
51            requires_install: true,
52            capabilities: HarnessCapabilities {
53                // Codex owns its own login and edits files directly.
54                // Model names change often, so allow free-text entry
55                // rather than a curated list; it exposes reasoning
56                // effort but no turn cap.
57                credential_required: false,
58                previews_edits: false,
59                models: Vec::new(),
60                allows_custom_model: true,
61                supports_effort: true,
62                supports_max_turns: false,
63                supports_login: true,
64                supports_custom_instructions: false,
65            },
66        }
67    }
68
69    fn list_models(&self) -> Result<Vec<HarnessModel>, HarnessError> {
70        // Codex declares no static models (ids churn → free-text entry); fill the
71        // picker from models.dev's `openai` lineup when the `models-dev` feature is
72        // on (empty otherwise → the user types an id).
73        Ok(crate::models_dev::provider_models("openai"))
74    }
75
76    fn readiness(&self) -> HarnessReadiness {
77        let Some(version) = probe_version("codex") else {
78            return HarnessReadiness {
79                harness_id: CODEX_HARNESS_ID.to_owned(),
80                ready: false,
81                installed: false,
82                version: None,
83                auth_configured: false,
84                error: Some("Codex (`codex`) is not installed or not on PATH.".to_owned()),
85                details: Value::Null,
86            };
87        };
88        // Installed — distinguish signed-in from not so the picker can
89        // offer "Sign in" instead of failing the first run. Either the CLI's
90        // own login OR an `OPENAI_API_KEY` in the environment counts: the env
91        // key is how you run headless (a container / CI), where `codex login`
92        // can't open a browser. `codex login status` only sees the OAuth
93        // state, so we OR in the env key ourselves.
94        let signed_in = probe_codex_signed_in()
95            || crate::harness::api_key_value_usable(std::env::var("OPENAI_API_KEY").ok());
96        HarnessReadiness {
97            harness_id: CODEX_HARNESS_ID.to_owned(),
98            ready: signed_in,
99            installed: true,
100            version: Some(version),
101            auth_configured: signed_in,
102            error: if signed_in {
103                None
104            } else {
105                Some(
106                    "Codex is installed but not signed in. Click Sign in to connect your ChatGPT/OpenAI account, or set OPENAI_API_KEY."
107                        .to_owned(),
108                )
109            },
110            details: codex_resolved_details(),
111        }
112    }
113
114    fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
115        (*on_event)(InstallEvent::Step {
116            text: "Installing Codex via npm…".to_owned(),
117        });
118        let output = Command::new("npm")
119            .args(["install", "-g", "@openai/codex"])
120            .env("PATH", crate::augmented_node_path())
121            .output()
122            .map_err(|e| HarnessError::install(format!("failed to run npm: {e}")))?;
123        for line in String::from_utf8_lossy(&output.stdout).lines() {
124            (*on_event)(InstallEvent::Stdout {
125                text: line.to_owned(),
126            });
127        }
128        for line in String::from_utf8_lossy(&output.stderr).lines() {
129            (*on_event)(InstallEvent::Stderr {
130                text: line.to_owned(),
131            });
132        }
133        (*on_event)(InstallEvent::Done {
134            exit_code: output.status.code(),
135            ok: output.status.success(),
136        });
137        Ok(())
138    }
139
140    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
141        // `attachments` ignored: codex exec is a text CLI (no image input here).
142        let RunRequest { run_id, prompt, cwd, mode, tuning, resume, attachments: _ } = request;
143        let args = build_codex_args(prompt, mode, &tuning, resume.as_deref());
144        let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
145
146        // No env injected — Codex uses its own auth. PATH augmentation
147        // in spawn_streaming ensures `node` is found for a
148        // Finder-launched .app.
149        //
150        // Codex needs a *stateful* parser (one per run): it emits several
151        // complete `agent_message` items per turn — short preambles before
152        // tool calls and a final answer — that must not be concatenated into
153        // the answer, and its stderr is tracing noise to drop (see
154        // [`CodexStreamParser`]). The callback runs on cli-stream's reader
155        // threads, so the parser is held behind an `Arc<Mutex>` — the same
156        // shape as bob's.
157        let parser = Arc::new(Mutex::new(CodexStreamParser::new()));
158        let program = tuning.binary_path.clone().unwrap_or_else(|| PathBuf::from("codex"));
159        let handle = spawn_streaming(
160            program,
161            args,
162            Vec::new(),
163            cwd,
164            run_id,
165            move |event| {
166                // Recover a poisoned lock rather than panic on a reader
167                // thread — parsing is total, so the parser is never
168                // mid-corruption.
169                let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
170                for normalized in parser.on_process_event(event) {
171                    (*on_event)(normalized);
172                }
173            },
174        )
175        .map_err(HarnessError::spawn)?;
176        Ok(Box::new(handle))
177    }
178
179    fn credential(&self) -> CredentialSpec {
180        CredentialSpec {
181            label: "Codex login (managed by the codex CLI)".to_owned(),
182            keychain_service: "openai".to_owned(),
183            keychain_account: "OPENAI_API_KEY".to_owned(),
184            required: false,
185        }
186    }
187
188    fn login(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
189        // `codex login` runs the CLI's OAuth flow (opens the browser).
190        crate::run_login_command("codex", &["login"], on_event)
191    }
192}
193
194/// Resolve the `codex` binary and classify its install kind for the readiness
195/// `details`, mirroring the Claude adapter — so the Runtimes UI can surface
196/// "npm — can go stale / Update to native" instead of a bare, ambiguous
197/// "Update". Reuses the shared resolve/classify in `crate::claude::resolve`.
198fn codex_resolved_details() -> Value {
199    let path = crate::augmented_node_path();
200    let Some(resolved) = crate::claude::resolve::resolve_on_path("codex", &path) else {
201        return Value::Null;
202    };
203    let mut details = serde_json::Map::new();
204    details.insert(
205        "resolved_path".to_owned(),
206        Value::String(resolved.to_string_lossy().into_owned()),
207    );
208    if let Ok(home) = std::env::var("HOME") {
209        let kind = crate::claude::resolve::classify(&resolved, std::path::Path::new(&home), None);
210        details.insert(
211            "install_kind".to_owned(),
212            Value::String(kind.as_str().to_owned()),
213        );
214    }
215    Value::Object(details)
216}
217
218fn probe_version(program: &str) -> Option<String> {
219    // Augment PATH so a packaged `.app` (minimal launchd PATH) can find a
220    // CLI installed via nvm / Homebrew / official installer — otherwise an
221    // installed CLI is mis-reported as "not installed".
222    let output = Command::new(program)
223        .arg("--version")
224        .env("PATH", crate::augmented_node_path())
225        .output()
226        .ok()?;
227    if !output.status.success() {
228        return None;
229    }
230    let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
231    if text.is_empty() {
232        None
233    } else {
234        Some(text)
235    }
236}
237
238/// Probe Codex's auth: `codex login status` exits 0 when signed in.
239/// Lets [`CodexHarness::readiness`] distinguish installed from signed-in
240/// (so the picker can offer "Sign in").
241fn probe_codex_signed_in() -> bool {
242    Command::new("codex")
243        .args(["login", "status"])
244        .env("PATH", crate::augmented_node_path())
245        .output()
246        .map(|o| o.status.success())
247        .unwrap_or(false)
248}
249
250/// Build the argv for a `codex exec --json` headless run. Kept pure
251/// (no spawn) so the flag mapping is unit-tested. `tuning.model` →
252/// `--model`; `tuning.effort` → `-c model_reasoning_effort="..."`
253/// (codex's config override, value parsed as TOML), defaulting to `low`
254/// when unset so codex's built-in tools don't reject its `minimal`
255/// default; Codex has no turn-cap flag, so `tuning.max_turns` is
256/// intentionally ignored. Options precede the positional prompt, as
257/// `codex exec` expects.
258fn build_codex_args(
259    prompt: String,
260    mode: RunMode,
261    tuning: &RunTuning,
262    resume: Option<&str>,
263) -> Vec<String> {
264    // `exec` always; `exec resume <id>` to continue a prior session instead of
265    // replaying history in the prompt. The session id is a positional *after*
266    // the options and *before* the prompt (`codex exec resume [OPTIONS]
267    // [SESSION_ID] [PROMPT]`), so it's appended at the tail below.
268    let mut args = vec!["exec".to_owned()];
269    if resume.is_some() {
270        args.push("resume".to_owned());
271    }
272    // `--skip-git-repo-check`: `codex exec` otherwise refuses to run unless
273    // the cwd is a git repo ("Not inside a trusted directory and
274    // --skip-git-repo-check was not specified.", exit 1). A harness runs in
275    // whatever working directory the consumer hands it — often not a git repo
276    // (notes, drafts, a fresh folder) — so that interactive guardrail is
277    // wrong here. This skips only the is-this-a-repo gate; the execution
278    // sandbox (mode → `--full-auto`) is unaffected. Both flags are valid on
279    // `exec resume` too.
280    args.push("--json".to_owned());
281    args.push("--skip-git-repo-check".to_owned());
282    if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
283        args.push("--model".to_owned());
284        args.push(model.to_owned());
285    }
286    // Codex's own default reasoning effort is `minimal`, which its built-in
287    // `image_gen`/`web_search` tools reject ("cannot be used with
288    // reasoning.effort 'minimal'", a 400 that breaks a default run). So when
289    // the user picks no effort, send `low` rather than leaving codex on
290    // `minimal`. Only `minimal` when explicitly chosen.
291    let effort = tuning.effort.unwrap_or(crate::ReasoningEffort::Low);
292    args.push("-c".to_owned());
293    args.push(format!("model_reasoning_effort=\"{}\"", effort.as_cli_value()));
294    if matches!(mode, RunMode::Edit) {
295        // Low-friction sandboxed auto-execution so Codex can apply
296        // edits without interactive approval. (Exact sandbox flags
297        // vary by codex version; --full-auto is the stable one.)
298        args.push("--full-auto".to_owned());
299    }
300    // Host passthrough/overrides — before the trailing positionals.
301    args.extend(tuning.extra_args.iter().cloned());
302    // Positionals last: the session id (resume only) precedes the prompt.
303    if let Some(session_id) = resume {
304        args.push(session_id.to_owned());
305    }
306    args.push(prompt);
307    args
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::ReasoningEffort;
314
315    #[test]
316    fn codex_info_and_credential() {
317        let h = CodexHarness::new();
318        assert_eq!(h.info().id, CODEX_HARNESS_ID);
319        assert!(h.info().requires_install);
320        assert!(!h.credential().required);
321    }
322
323    /// Value of the arg immediately following `flag`, if present.
324    fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
325        args.iter()
326            .position(|a| a == flag)
327            .and_then(|i| args.get(i + 1))
328            .map(String::as_str)
329    }
330
331    #[test]
332    fn codex_args_default_omit_model_but_force_low_effort() {
333        let args = build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), None);
334        assert_eq!(args[0], "exec");
335        assert!(!args.contains(&"resume".to_owned()));
336        assert!(args.contains(&"--json".to_owned()));
337        // Always present: a harness's cwd is often not a git repo, and
338        // without this `codex exec` exits 1 ("Not inside a trusted
339        // directory …"). Independent of run mode.
340        assert!(args.contains(&"--skip-git-repo-check".to_owned()));
341        assert!(!args.iter().any(|a| a == "--model"));
342        // No explicit effort → `low`, never codex's `minimal` default (which
343        // its built-in image_gen/web_search tools reject with a 400).
344        assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"low\""));
345        assert!(!args.iter().any(|a| a.contains("minimal")));
346        assert!(!args.iter().any(|a| a == "--full-auto"));
347        // Prompt is the trailing positional arg.
348        assert_eq!(args.last().map(String::as_str), Some("hi"));
349    }
350
351    #[test]
352    fn codex_args_explicit_minimal_effort_is_honored() {
353        let tuning =
354            RunTuning { effort: Some(ReasoningEffort::Minimal), ..RunTuning::default() };
355        let args = build_codex_args("hi".to_owned(), RunMode::Ask, &tuning, None);
356        assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"minimal\""));
357    }
358
359    #[test]
360    fn codex_args_carry_model_and_effort_and_ignore_max_turns() {
361        let tuning = RunTuning {
362            model: Some("gpt-5-codex".to_owned()),
363            effort: Some(ReasoningEffort::High),
364            max_turns: Some(5),
365            ..RunTuning::default()
366        };
367        let args = build_codex_args("hi".to_owned(), RunMode::Edit, &tuning, None);
368        assert_eq!(flag_value(&args, "--model"), Some("gpt-5-codex"));
369        assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"high\""));
370        assert!(args.contains(&"--full-auto".to_owned()));
371        // Codex has no turn-cap flag — max_turns must not leak.
372        assert!(!args.iter().any(|a| a == "--max-turns"));
373        // Options precede the prompt; the prompt stays last.
374        assert_eq!(args.last().map(String::as_str), Some("hi"));
375    }
376
377    #[test]
378    fn codex_resume_uses_the_resume_subcommand_with_id_before_prompt() {
379        let args =
380            build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), Some("sess-9"));
381        // `exec resume` subcommand, JSON stream + git-skip still present.
382        assert_eq!(args[0], "exec");
383        assert_eq!(args[1], "resume");
384        assert!(args.contains(&"--json".to_owned()));
385        assert!(args.contains(&"--skip-git-repo-check".to_owned()));
386        // Positionals: the session id immediately precedes the prompt (tail).
387        let last_two = &args[args.len() - 2..];
388        assert_eq!(last_two, &["sess-9".to_owned(), "hi".to_owned()]);
389    }
390}