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