1use 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
32pub const CODEX_HARNESS_ID: &str = "codex";
34
35#[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 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 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 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 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 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 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 crate::run_login_command("codex", &["login"], on_event)
191 }
192}
193
194fn 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 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
238fn 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
250fn build_codex_args(
259 prompt: String,
260 mode: RunMode,
261 tuning: &RunTuning,
262 resume: Option<&str>,
263) -> Vec<String> {
264 let mut args = vec!["exec".to_owned()];
269 if resume.is_some() {
270 args.push("resume".to_owned());
271 }
272 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 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 args.push("--full-auto".to_owned());
299 }
300 args.extend(tuning.extra_args.iter().cloned());
302 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 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 assert!(args.contains(&"--skip-git-repo-check".to_owned()));
341 assert!(!args.iter().any(|a| a == "--model"));
342 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 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 assert!(!args.iter().any(|a| a == "--max-turns"));
373 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 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 let last_two = &args[args.len() - 2..];
388 assert_eq!(last_two, &["sess-9".to_owned(), "hi".to_owned()]);
389 }
390}