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    HarnessReadiness, InstallCallback, InstallEvent, RunCallback, RunHandle, RunMode, RunRequest,
26    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            },
65        }
66    }
67
68    fn readiness(&self) -> HarnessReadiness {
69        let Some(version) = probe_version("codex") else {
70            return HarnessReadiness {
71                harness_id: CODEX_HARNESS_ID.to_owned(),
72                ready: false,
73                installed: false,
74                version: None,
75                auth_configured: false,
76                error: Some("Codex (`codex`) is not installed or not on PATH.".to_owned()),
77                details: Value::Null,
78            };
79        };
80        // Installed — distinguish signed-in from not so the picker can
81        // offer "Sign in" instead of failing the first run. Either the CLI's
82        // own login OR an `OPENAI_API_KEY` in the environment counts: the env
83        // key is how you run headless (a container / CI), where `codex login`
84        // can't open a browser. `codex login status` only sees the OAuth
85        // state, so we OR in the env key ourselves.
86        let signed_in = probe_codex_signed_in()
87            || crate::harness::api_key_value_usable(std::env::var("OPENAI_API_KEY").ok());
88        HarnessReadiness {
89            harness_id: CODEX_HARNESS_ID.to_owned(),
90            ready: signed_in,
91            installed: true,
92            version: Some(version),
93            auth_configured: signed_in,
94            error: if signed_in {
95                None
96            } else {
97                Some(
98                    "Codex is installed but not signed in. Click Sign in to connect your ChatGPT/OpenAI account, or set OPENAI_API_KEY."
99                        .to_owned(),
100                )
101            },
102            details: Value::Null,
103        }
104    }
105
106    fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
107        (*on_event)(InstallEvent::Step {
108            text: "Installing Codex via npm…".to_owned(),
109        });
110        let output = Command::new("npm")
111            .args(["install", "-g", "@openai/codex"])
112            .env("PATH", crate::augmented_node_path())
113            .output()
114            .map_err(|e| HarnessError::install(format!("failed to run npm: {e}")))?;
115        for line in String::from_utf8_lossy(&output.stdout).lines() {
116            (*on_event)(InstallEvent::Stdout {
117                text: line.to_owned(),
118            });
119        }
120        for line in String::from_utf8_lossy(&output.stderr).lines() {
121            (*on_event)(InstallEvent::Stderr {
122                text: line.to_owned(),
123            });
124        }
125        (*on_event)(InstallEvent::Done {
126            exit_code: output.status.code(),
127            ok: output.status.success(),
128        });
129        Ok(())
130    }
131
132    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
133        let RunRequest { run_id, prompt, cwd, mode, tuning } = request;
134        let args = build_codex_args(prompt, mode, &tuning);
135        let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
136
137        // No env injected — Codex uses its own auth. PATH augmentation
138        // in spawn_streaming ensures `node` is found for a
139        // Finder-launched .app.
140        //
141        // Codex needs a *stateful* parser (one per run): it emits several
142        // complete `agent_message` items per turn — short preambles before
143        // tool calls and a final answer — that must not be concatenated into
144        // the answer, and its stderr is tracing noise to drop (see
145        // [`CodexStreamParser`]). The callback runs on cli-stream's reader
146        // threads, so the parser is held behind an `Arc<Mutex>` — the same
147        // shape as bob's.
148        let parser = Arc::new(Mutex::new(CodexStreamParser::new()));
149        let handle = spawn_streaming(
150            PathBuf::from("codex"),
151            args,
152            Vec::new(),
153            cwd,
154            run_id,
155            move |event| {
156                // Recover a poisoned lock rather than panic on a reader
157                // thread — parsing is total, so the parser is never
158                // mid-corruption.
159                let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
160                for normalized in parser.on_process_event(event) {
161                    (*on_event)(normalized);
162                }
163            },
164        )
165        .map_err(HarnessError::spawn)?;
166        Ok(Box::new(handle))
167    }
168
169    fn credential(&self) -> CredentialSpec {
170        CredentialSpec {
171            label: "Codex login (managed by the codex CLI)".to_owned(),
172            keychain_service: "openai".to_owned(),
173            keychain_account: "OPENAI_API_KEY".to_owned(),
174            required: false,
175        }
176    }
177
178    fn login(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
179        // `codex login` runs the CLI's OAuth flow (opens the browser).
180        crate::run_login_command("codex", &["login"], on_event)
181    }
182}
183
184fn probe_version(program: &str) -> Option<String> {
185    // Augment PATH so a packaged `.app` (minimal launchd PATH) can find a
186    // CLI installed via nvm / Homebrew / official installer — otherwise an
187    // installed CLI is mis-reported as "not installed".
188    let output = Command::new(program)
189        .arg("--version")
190        .env("PATH", crate::augmented_node_path())
191        .output()
192        .ok()?;
193    if !output.status.success() {
194        return None;
195    }
196    let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
197    if text.is_empty() {
198        None
199    } else {
200        Some(text)
201    }
202}
203
204/// Probe Codex's auth: `codex login status` exits 0 when signed in.
205/// Lets [`CodexHarness::readiness`] distinguish installed from signed-in
206/// (so the picker can offer "Sign in").
207fn probe_codex_signed_in() -> bool {
208    Command::new("codex")
209        .args(["login", "status"])
210        .env("PATH", crate::augmented_node_path())
211        .output()
212        .map(|o| o.status.success())
213        .unwrap_or(false)
214}
215
216/// Build the argv for a `codex exec --json` headless run. Kept pure
217/// (no spawn) so the flag mapping is unit-tested. `tuning.model` →
218/// `--model`; `tuning.effort` → `-c model_reasoning_effort="..."`
219/// (codex's config override, value parsed as TOML); Codex has no
220/// turn-cap flag, so `tuning.max_turns` is intentionally ignored.
221/// Options precede the positional prompt, as `codex exec` expects.
222fn build_codex_args(prompt: String, mode: RunMode, tuning: &RunTuning) -> Vec<String> {
223    // `--skip-git-repo-check`: `codex exec` otherwise refuses to run unless
224    // the cwd is a git repo ("Not inside a trusted directory and
225    // --skip-git-repo-check was not specified.", exit 1). A harness runs in
226    // whatever working directory the consumer hands it — often not a git repo
227    // (notes, drafts, a fresh folder) — so that interactive guardrail is
228    // wrong here. This skips only the is-this-a-repo gate; the execution
229    // sandbox (mode → `--full-auto`) is unaffected.
230    let mut args = vec![
231        "exec".to_owned(),
232        "--json".to_owned(),
233        "--skip-git-repo-check".to_owned(),
234    ];
235    if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
236        args.push("--model".to_owned());
237        args.push(model.to_owned());
238    }
239    if let Some(effort) = tuning.effort {
240        args.push("-c".to_owned());
241        args.push(format!("model_reasoning_effort=\"{}\"", effort.as_cli_value()));
242    }
243    if matches!(mode, RunMode::Edit) {
244        // Low-friction sandboxed auto-execution so Codex can apply
245        // edits without interactive approval. (Exact sandbox flags
246        // vary by codex version; --full-auto is the stable one.)
247        args.push("--full-auto".to_owned());
248    }
249    args.push(prompt);
250    args
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::ReasoningEffort;
257
258    #[test]
259    fn codex_info_and_credential() {
260        let h = CodexHarness::new();
261        assert_eq!(h.info().id, CODEX_HARNESS_ID);
262        assert!(h.info().requires_install);
263        assert!(!h.credential().required);
264    }
265
266    /// Value of the arg immediately following `flag`, if present.
267    fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
268        args.iter()
269            .position(|a| a == flag)
270            .and_then(|i| args.get(i + 1))
271            .map(String::as_str)
272    }
273
274    #[test]
275    fn codex_args_default_omit_model_and_effort() {
276        let args = build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default());
277        assert_eq!(args[0], "exec");
278        assert!(args.contains(&"--json".to_owned()));
279        // Always present: a harness's cwd is often not a git repo, and
280        // without this `codex exec` exits 1 ("Not inside a trusted
281        // directory …"). Independent of run mode.
282        assert!(args.contains(&"--skip-git-repo-check".to_owned()));
283        assert!(!args.iter().any(|a| a == "--model"));
284        assert!(!args.iter().any(|a| a == "-c"));
285        assert!(!args.iter().any(|a| a == "--full-auto"));
286        // Prompt is the trailing positional arg.
287        assert_eq!(args.last().map(String::as_str), Some("hi"));
288    }
289
290    #[test]
291    fn codex_args_carry_model_and_effort_and_ignore_max_turns() {
292        let tuning = RunTuning {
293            model: Some("gpt-5-codex".to_owned()),
294            effort: Some(ReasoningEffort::High),
295            max_turns: Some(5),
296        };
297        let args = build_codex_args("hi".to_owned(), RunMode::Edit, &tuning);
298        assert_eq!(flag_value(&args, "--model"), Some("gpt-5-codex"));
299        assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"high\""));
300        assert!(args.contains(&"--full-auto".to_owned()));
301        // Codex has no turn-cap flag — max_turns must not leak.
302        assert!(!args.iter().any(|a| a == "--max-turns"));
303        // Options precede the prompt; the prompt stays last.
304        assert_eq!(args.last().map(String::as_str), Some("hi"));
305    }
306}