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    probe_version, Command, ResolveCli, CredentialSpec, Harness, Features, Error,
24    Info, ModelChoice, Readiness, InstallCallback, InstallHint, RunCallback,
25    RunHandle, RunMode, 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/// The program spawned when the host doesn't name one.
35pub const DEFAULT_CODEX_COMMAND: &str = "codex";
36
37/// OpenAI Codex CLI as a [`Harness`].
38#[derive(Debug, Clone)]
39pub struct CodexHarness {
40    command: String,
41}
42
43impl Default for CodexHarness {
44    // Not derived: a derived `Default` would leave `command` empty and every
45    // spawn would fail on a name nobody chose.
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51/// What this adapter is, as opposed to what is layered onto it — the same
52/// split as [`AcpHarnessConfig`](crate::AcpHarnessConfig) and
53/// [`OpenHarnessConfig`](crate::OpenHarnessConfig).
54#[derive(Clone, Debug)]
55pub struct CodexHarnessConfig {
56    /// Program to spawn. A bare name is resolved on PATH; a path is used as
57    /// given. A rename upstream, a fork, a wrapper script or a test stub costs
58    /// a field here rather than a release.
59    pub command: String,
60}
61
62impl Default for CodexHarnessConfig {
63    fn default() -> Self {
64        Self { command: DEFAULT_CODEX_COMMAND.to_owned() }
65    }
66}
67
68impl CodexHarness {
69    /// Drives `codex` from PATH.
70    pub fn new() -> Self {
71        Self::custom(CodexHarnessConfig::default())
72    }
73
74    /// Drives a binary the host names.
75    pub fn custom(config: CodexHarnessConfig) -> Self {
76        Self { command: config.command }
77    }
78}
79
80impl Harness for CodexHarness {
81    fn info(&self) -> Info {
82        Info {
83            id: CODEX_HARNESS_ID.to_owned(),
84            display_name: "Codex".to_owned(),
85            description: "OpenAI's Codex agent CLI. Uses your existing Codex login.".to_owned(),
86            install_hint: Some(
87                InstallHint::url("https://developers.openai.com/codex")
88                    .with_command("npm install -g @openai/codex"),
89            ),
90        }
91    }
92
93    fn features(&self) -> Features {
94        Features {
95            // Codex owns its own login and edits files directly. Model
96            // names change often, so it takes free-text entry rather than a
97            // curated list, and it exposes reasoning effort. What it does
98            // not support — a turn cap, previews, a stored credential — is
99            // left to `Default`.
100            custom_model: true,
101            effort: true,
102            login: true,
103            ..Default::default()
104        }
105    }
106
107    fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
108        // Codex declares no static models (ids churn → free-text entry); fill the
109        // picker from models.dev's `openai` lineup when the `models-dev` feature is
110        // on (empty otherwise → the user types an id).
111        Ok(crate::models_dev::provider_models("openai"))
112    }
113
114    fn readiness(&self) -> Readiness {
115        let Some(version) = probe_version(&self.command) else {
116            return Readiness {
117                harness_id: CODEX_HARNESS_ID.to_owned(),
118                ready: false,
119                installed: false,
120                version: None,
121                auth_configured: false,
122                error: Some("Codex (`codex`) is not installed or not on PATH.".to_owned()),
123                details: Value::Null,
124            };
125        };
126        // Installed — distinguish signed-in from not so the picker can
127        // offer "Sign in" instead of failing the first run. Either the CLI's
128        // own login OR an `OPENAI_API_KEY` in the environment counts: the env
129        // key is how you run headless (a container / CI), where `codex login`
130        // can't open a browser. `codex login status` only sees the OAuth
131        // state, so we OR in the env key ourselves.
132        let signed_in = probe_codex_signed_in(&self.command)
133            || crate::harness::api_key_value_usable(std::env::var("OPENAI_API_KEY").ok());
134        Readiness {
135            harness_id: CODEX_HARNESS_ID.to_owned(),
136            ready: signed_in,
137            installed: true,
138            version: Some(version),
139            auth_configured: signed_in,
140            error: if signed_in {
141                None
142            } else {
143                Some(
144                    "Codex is installed but not signed in. Click Sign in to connect your ChatGPT/OpenAI account, or set OPENAI_API_KEY."
145                        .to_owned(),
146                )
147            },
148            details: codex_resolved_details(&self.command),
149        }
150    }
151
152    fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error> {
153        // `attachments` ignored: codex exec is a text CLI (no image input here).
154        let RunRequest { run_id, prompt, cwd, mode, tuning, resume, attachments: _ } = request;
155        let args = build_codex_args(prompt, mode, &tuning, resume.as_deref());
156        let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
157
158        // No env injected — Codex uses its own auth. PATH augmentation
159        // in spawn_streaming ensures `node` is found for a
160        // Finder-launched .app.
161        //
162        // Codex needs a *stateful* parser (one per run): it emits several
163        // complete `agent_message` items per turn — short preambles before
164        // tool calls and a final answer — that must not be concatenated into
165        // the answer, and its stderr is tracing noise to drop (see
166        // [`CodexStreamParser`]). The callback runs on cli-stream's reader
167        // threads, so the parser is held behind an `Arc<Mutex>` — the same
168        // shape as bob's.
169        let parser = Arc::new(Mutex::new(CodexStreamParser::new()));
170        let program = tuning.binary_path.clone().unwrap_or_else(|| PathBuf::from(&self.command));
171        let handle = Command::new(program)
172            .cwd(cwd)
173            .run_id(run_id)
174            .args(args)
175            .resolve_cli()
176            .stream(move |event| {
177                // Recover a poisoned lock rather than panic on a reader
178                // thread — parsing is total, so the parser is never
179                // mid-corruption.
180                let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
181                for normalized in parser.on_process_event(event) {
182                    (*on_event)(normalized);
183                }
184            },
185        )
186        .map_err(Error::spawn)?;
187        Ok(Box::new(handle))
188    }
189
190    fn credential(&self) -> CredentialSpec {
191        CredentialSpec {
192            label: "Codex login (managed by the codex CLI)".to_owned(),
193            keychain_service: "openai".to_owned(),
194            keychain_account: "OPENAI_API_KEY".to_owned(),
195            required: false,
196        }
197    }
198
199    fn login(&self, on_event: InstallCallback) -> Result<(), Error> {
200        // `codex login` runs the CLI's OAuth flow (opens the browser).
201        crate::run_login_command(&self.command, &["login"], on_event)
202    }
203}
204
205/// Resolve the `codex` binary and classify its install kind for the readiness
206/// `details`, mirroring the Claude adapter — so the Runtimes UI can surface
207/// "npm — can go stale / Update to native" instead of a bare, ambiguous
208/// "Update". Reuses the shared resolve/classify in `crate::claude::resolve`.
209fn codex_resolved_details(command: &str) -> Value {
210    let path = crate::augmented_node_path();
211    let Some(resolved) = crate::claude::resolve::resolve_on_path(command, &path) else {
212        return Value::Null;
213    };
214    let mut details = serde_json::Map::new();
215    details.insert(
216        "resolved_path".to_owned(),
217        Value::String(resolved.to_string_lossy().into_owned()),
218    );
219    if let Ok(home) = std::env::var("HOME") {
220        let kind = crate::claude::resolve::classify(&resolved, std::path::Path::new(&home), None);
221        details.insert(
222            "install_kind".to_owned(),
223            Value::String(kind.as_str().to_owned()),
224        );
225    }
226    Value::Object(details)
227}
228
229/// Probe Codex's auth: `codex login status` exits 0 when signed in.
230/// Lets [`CodexHarness::readiness`] distinguish installed from signed-in
231/// (so the picker can offer "Sign in").
232fn probe_codex_signed_in(command: &str) -> bool {
233    crate::hidden_command(command)
234        .args(["login", "status"])
235        .env("PATH", crate::augmented_node_path())
236        .output()
237        .map(|o| o.status.success())
238        .unwrap_or(false)
239}
240
241/// Build the argv for a `codex exec --json` headless run. Kept pure
242/// (no spawn) so the flag mapping is unit-tested. `tuning.model` →
243/// `--model`; `tuning.effort` → `-c model_reasoning_effort="..."`
244/// (codex's config override, value parsed as TOML), defaulting to `low`
245/// when unset so codex's built-in tools don't reject its `minimal`
246/// default; Codex has no turn-cap flag, so `tuning.max_turns` is
247/// intentionally ignored. Options precede the positional prompt, as
248/// `codex exec` expects.
249fn build_codex_args(
250    prompt: String,
251    mode: RunMode,
252    tuning: &RunTuning,
253    resume: Option<&str>,
254) -> Vec<String> {
255    // `exec` always; `exec resume <id>` to continue a prior session instead of
256    // replaying history in the prompt. The session id is a positional *after*
257    // the options and *before* the prompt (`codex exec resume [OPTIONS]
258    // [SESSION_ID] [PROMPT]`), so it's appended at the tail below.
259    let mut args = vec!["exec".to_owned()];
260    if resume.is_some() {
261        args.push("resume".to_owned());
262    }
263    // `--skip-git-repo-check`: `codex exec` otherwise refuses to run unless
264    // the cwd is a git repo ("Not inside a trusted directory and
265    // --skip-git-repo-check was not specified.", exit 1). A harness runs in
266    // whatever working directory the consumer hands it — often not a git repo
267    // (notes, drafts, a fresh folder) — so that interactive guardrail is
268    // wrong here. This skips only the is-this-a-repo gate; the execution
269    // sandbox (mode → `--full-auto`) is unaffected. Both flags are valid on
270    // `exec resume` too.
271    args.push("--json".to_owned());
272    args.push("--skip-git-repo-check".to_owned());
273    if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
274        args.push("--model".to_owned());
275        args.push(model.to_owned());
276    }
277    // Codex's own default reasoning effort is `minimal`, which its built-in
278    // `image_gen`/`web_search` tools reject ("cannot be used with
279    // reasoning.effort 'minimal'", a 400 that breaks a default run). So when
280    // the user picks no effort, send `low` rather than leaving codex on
281    // `minimal`. Only `minimal` when explicitly chosen.
282    let effort = tuning.effort.unwrap_or(crate::ReasoningEffort::Low);
283    args.push("-c".to_owned());
284    args.push(format!("model_reasoning_effort=\"{}\"", effort.as_cli_value()));
285    if matches!(mode, RunMode::Edit) {
286        // Low-friction sandboxed auto-execution so Codex can apply
287        // edits without interactive approval. (Exact sandbox flags
288        // vary by codex version; --full-auto is the stable one.)
289        args.push("--full-auto".to_owned());
290    }
291    // Host passthrough/overrides — before the trailing positionals.
292    args.extend(tuning.extra_args.iter().cloned());
293    // Positionals last: the session id (resume only) precedes the prompt.
294    if let Some(session_id) = resume {
295        args.push(session_id.to_owned());
296    }
297    args.push(prompt);
298    args
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::events::RunEvent;
305
306    /// A stand-in `codex` that answers the three ways the adapter invokes it:
307    /// `--version`, `login status`, and a real `exec` run. The `exec` case
308    /// records the argv it was handed, so a test can assert what the CLI
309    /// actually received rather than what the pure builder returned.
310    #[cfg(unix)]
311    fn fake_codex(tag: &str, signed_in: bool, emits: &str) -> (std::path::PathBuf, std::path::PathBuf) {
312        use std::os::unix::fs::PermissionsExt;
313        let dir = std::env::temp_dir().join(format!("codex-{tag}-{}", std::process::id()));
314        std::fs::create_dir_all(&dir).unwrap();
315        let argv = dir.join("argv");
316        let cli = dir.join("codex");
317        let login_exit = i32::from(!signed_in);
318        std::fs::write(
319            &cli,
320            format!(
321                "#!/bin/sh\n\
322                 case \"$1\" in\n\
323                 --version) echo 'codex-cli 9.9.9'; exit 0 ;;\n\
324                 login) exit {login_exit} ;;\n\
325                 exec) : > '{argv}'; for a in \"$@\"; do printf '%s\\n' \"$a\" >> '{argv}'; done\n\
326                 {emits}\n\
327                 exit 0 ;;\n\
328                 esac\n\
329                 exit 1\n",
330                argv = argv.display(),
331            ),
332        )
333        .unwrap();
334        std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
335        (cli, argv)
336    }
337
338    #[cfg(unix)]
339    fn drive(cli: &std::path::Path, request: RunRequest) -> Vec<RunEvent> {
340        use std::sync::{Arc, Mutex};
341        let seen: Arc<Mutex<Vec<RunEvent>>> = Arc::default();
342        let sink = Arc::clone(&seen);
343        let harness = CodexHarness::custom(CodexHarnessConfig {
344            command: cli.display().to_string(),
345        });
346        let handle = harness
347            .start(request, Arc::new(move |event| sink.lock().unwrap().push(event)))
348            .expect("the stand-in should spawn");
349
350        // Bounded: a fixture that never exits must fail the test, not hang it.
351        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
352        loop {
353            let done = seen
354                .lock()
355                .unwrap()
356                .iter()
357                .any(|event| matches!(event, RunEvent::Exited { .. }));
358            if done {
359                break;
360            }
361            assert!(std::time::Instant::now() < deadline, "the run never exited");
362            std::thread::sleep(std::time::Duration::from_millis(20));
363        }
364        let _ = handle.cancel();
365        let events = seen.lock().unwrap().clone();
366        events
367    }
368
369    #[cfg(unix)]
370    #[test]
371    fn a_run_reaches_the_cli_as_codex_exec_and_comes_back_as_events() {
372        // The argv assertions elsewhere test `build_codex_args` in isolation.
373        // This is the only test that proves the argv the *process* receives is
374        // that one — spawn, stream, parse and normalize included.
375        let message = r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"done"}}"#;
376        let (cli, argv) = fake_codex("run", true, &format!("printf '%s\\n' '{message}'"));
377
378        let events = drive(
379            &cli,
380            RunRequest {
381                run_id: "r1".to_owned(),
382                prompt: "say something".to_owned(),
383                cwd: Some(std::env::temp_dir()),
384                tuning: RunTuning { model: Some("o4-mini".to_owned()), ..RunTuning::default() },
385                ..RunRequest::default()
386            },
387        );
388
389        let passed: Vec<String> =
390            std::fs::read_to_string(&argv).unwrap().lines().map(str::to_owned).collect();
391        assert_eq!(passed.first().map(String::as_str), Some("exec"));
392        assert!(passed.iter().any(|a| a == "--json"), "argv was {passed:?}");
393        assert!(passed.iter().any(|a| a == "--skip-git-repo-check"), "argv was {passed:?}");
394        assert!(passed.windows(2).any(|w| w[0] == "--model" && w[1] == "o4-mini"));
395        assert_eq!(
396            passed.last().map(String::as_str),
397            Some("say something"),
398            "the prompt is the trailing positional",
399        );
400
401        assert!(
402            events.iter().any(|e| matches!(e, RunEvent::Text { delta, .. } if delta == "done")),
403            "the CLI's message should arrive as text: {events:?}",
404        );
405        assert!(events.iter().any(|e| matches!(
406            e,
407            RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }
408        )));
409    }
410
411    #[cfg(unix)]
412    #[test]
413    fn a_nonzero_exit_is_reported_rather_than_read_as_a_finished_run() {
414        // `codex exec` failing (bad flag, refused sandbox) must not look like a
415        // run that simply produced nothing.
416        let (cli, _) = fake_codex("fail", true, "exit 3; :");
417        let events = drive(&cli, RunRequest { prompt: "hi".to_owned(), ..RunRequest::default() });
418        assert!(
419            events.iter().any(|e| matches!(e, RunEvent::Exited { exit_code: Some(3), .. })),
420            "{events:?}",
421        );
422    }
423
424    #[cfg(unix)]
425    #[test]
426    fn readiness_reports_the_version_and_whether_login_status_succeeded() {
427        // Both halves come from spawning the CLI, so neither is covered by the
428        // pure argv tests. Signed-out must still read as installed — the UI
429        // offers "Sign in" only when it knows the binary is there.
430        let (yes, _) = fake_codex("in", true, ":");
431        let ready = CodexHarness::custom(CodexHarnessConfig { command: yes.display().to_string() })
432            .readiness();
433        assert!(ready.installed && ready.ready && ready.auth_configured);
434        assert_eq!(ready.version.as_deref(), Some("codex-cli 9.9.9"));
435        assert!(ready.error.is_none());
436
437        let (no, _) = fake_codex("out", false, ":");
438        let ready = CodexHarness::custom(CodexHarnessConfig { command: no.display().to_string() })
439            .readiness();
440        assert!(ready.installed, "the binary is present either way");
441        assert!(!ready.ready && !ready.auth_configured);
442        assert!(ready.error.is_some(), "a signed-out CLI must say what to do");
443    }
444
445    #[test]
446    fn a_renamed_binary_is_what_gets_probed() {
447        let renamed = CodexHarness::custom(CodexHarnessConfig {
448            command: "definitely-not-a-real-binary-xyz".into(),
449        });
450        assert!(!renamed.readiness().installed, "an unresolvable command cannot report installed");
451        assert_eq!(CodexHarness::new().command, DEFAULT_CODEX_COMMAND);
452        assert_eq!(CodexHarness::default().command, DEFAULT_CODEX_COMMAND);
453    }
454    use crate::ReasoningEffort;
455
456    /// A throwaway CLI standing in for `codex login status`.
457    #[cfg(unix)]
458    fn fake_cli(tag: &str, script: &str) -> std::path::PathBuf {
459        use std::os::unix::fs::PermissionsExt;
460        let dir = std::env::temp_dir().join(format!("hl-codex-{tag}-{}", std::process::id()));
461        std::fs::create_dir_all(&dir).unwrap();
462        let path = dir.join("cli");
463        std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
464        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
465        path
466    }
467
468    #[cfg(unix)]
469    #[test]
470    fn sign_in_is_read_from_the_exit_code_of_login_status() {
471        // Codex answers in prose rather than JSON, so the exit code is the
472        // whole signal. Getting it backwards sends a signed-in user to a Sign
473        // in button, or lets a signed-out one start a run that cannot work.
474        let signed_in = fake_cli("in", "echo 'Logged in'; exit 0");
475        assert!(probe_codex_signed_in(signed_in.to_str().unwrap()));
476
477        let signed_out = fake_cli("out", "echo 'Not logged in'; exit 1");
478        assert!(!probe_codex_signed_in(signed_out.to_str().unwrap()));
479
480        assert!(!probe_codex_signed_in("definitely-not-a-real-binary-xyz"), "an absent CLI is not signed in");
481    }
482
483    #[test]
484    fn codex_info_and_credential() {
485        let h = CodexHarness::new();
486        assert_eq!(h.info().id, CODEX_HARNESS_ID);
487        let hint = h.info().install_hint.expect("Codex is a CLI the user installs");
488        assert_eq!(hint.command.as_deref(), Some("npm install -g @openai/codex"));
489        assert!(!h.credential().required);
490    }
491
492    /// Value of the arg immediately following `flag`, if present.
493    fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
494        args.iter()
495            .position(|a| a == flag)
496            .and_then(|i| args.get(i + 1))
497            .map(String::as_str)
498    }
499
500    #[test]
501    fn codex_args_default_omit_model_but_force_low_effort() {
502        let args = build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), None);
503        assert_eq!(args[0], "exec");
504        assert!(!args.contains(&"resume".to_owned()));
505        assert!(args.contains(&"--json".to_owned()));
506        // Always present: a harness's cwd is often not a git repo, and
507        // without this `codex exec` exits 1 ("Not inside a trusted
508        // directory …"). Independent of run mode.
509        assert!(args.contains(&"--skip-git-repo-check".to_owned()));
510        assert!(!args.iter().any(|a| a == "--model"));
511        // No explicit effort → `low`, never codex's `minimal` default (which
512        // its built-in image_gen/web_search tools reject with a 400).
513        assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"low\""));
514        assert!(!args.iter().any(|a| a.contains("minimal")));
515        assert!(!args.iter().any(|a| a == "--full-auto"));
516        // Prompt is the trailing positional arg.
517        assert_eq!(args.last().map(String::as_str), Some("hi"));
518    }
519
520    #[test]
521    fn codex_args_explicit_minimal_effort_is_honored() {
522        let tuning =
523            RunTuning { effort: Some(ReasoningEffort::Minimal), ..RunTuning::default() };
524        let args = build_codex_args("hi".to_owned(), RunMode::Ask, &tuning, None);
525        assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"minimal\""));
526    }
527
528    #[test]
529    fn codex_args_carry_model_and_effort_and_ignore_max_turns() {
530        let tuning = RunTuning {
531            model: Some("gpt-5-codex".to_owned()),
532            effort: Some(ReasoningEffort::High),
533            max_turns: Some(5),
534            ..RunTuning::default()
535        };
536        let args = build_codex_args("hi".to_owned(), RunMode::Edit, &tuning, None);
537        assert_eq!(flag_value(&args, "--model"), Some("gpt-5-codex"));
538        assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"high\""));
539        assert!(args.contains(&"--full-auto".to_owned()));
540        // Codex has no turn-cap flag — max_turns must not leak.
541        assert!(!args.iter().any(|a| a == "--max-turns"));
542        // Options precede the prompt; the prompt stays last.
543        assert_eq!(args.last().map(String::as_str), Some("hi"));
544    }
545
546    #[test]
547    fn codex_resume_uses_the_resume_subcommand_with_id_before_prompt() {
548        let args =
549            build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), Some("sess-9"));
550        // `exec resume` subcommand, JSON stream + git-skip still present.
551        assert_eq!(args[0], "exec");
552        assert_eq!(args[1], "resume");
553        assert!(args.contains(&"--json".to_owned()));
554        assert!(args.contains(&"--skip-git-repo-check".to_owned()));
555        // Positionals: the session id immediately precedes the prompt (tail).
556        let last_two = &args[args.len() - 2..];
557        assert_eq!(last_two, &["sess-9".to_owned(), "hi".to_owned()]);
558    }
559}