1use 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
31pub const CODEX_HARNESS_ID: &str = "codex";
33
34pub const DEFAULT_CODEX_COMMAND: &str = "codex";
36
37#[derive(Debug, Clone)]
39pub struct CodexHarness {
40 command: String,
41}
42
43impl Default for CodexHarness {
44 fn default() -> Self {
47 Self::new()
48 }
49}
50
51#[derive(Clone, Debug)]
55pub struct CodexHarnessConfig {
56 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 pub fn new() -> Self {
71 Self::custom(CodexHarnessConfig::default())
72 }
73
74 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 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 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 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 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 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 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 crate::run_login_command(&self.command, &["login"], on_event)
202 }
203}
204
205fn 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
229fn 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
241fn build_codex_args(
250 prompt: String,
251 mode: RunMode,
252 tuning: &RunTuning,
253 resume: Option<&str>,
254) -> Vec<String> {
255 let mut args = vec!["exec".to_owned()];
260 if resume.is_some() {
261 args.push("resume".to_owned());
262 }
263 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 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 args.push("--full-auto".to_owned());
290 }
291 args.extend(tuning.extra_args.iter().cloned());
293 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 #[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 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 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 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 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 #[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 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 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 assert!(args.contains(&"--skip-git-repo-check".to_owned()));
510 assert!(!args.iter().any(|a| a == "--model"));
511 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 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 assert!(!args.iter().any(|a| a == "--max-turns"));
542 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 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 let last_two = &args[args.len() - 2..];
557 assert_eq!(last_two, &["sess-9".to_owned(), "hi".to_owned()]);
558 }
559}