1use 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
31pub const CODEX_HARNESS_ID: &str = "codex";
33
34#[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 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 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 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 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 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 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 crate::run_login_command("codex", &["login"], on_event)
167 }
168}
169
170fn 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 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
214fn 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
226fn build_codex_args(
235 prompt: String,
236 mode: RunMode,
237 tuning: &RunTuning,
238 resume: Option<&str>,
239) -> Vec<String> {
240 let mut args = vec!["exec".to_owned()];
245 if resume.is_some() {
246 args.push("resume".to_owned());
247 }
248 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 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 args.push("--full-auto".to_owned());
275 }
276 args.extend(tuning.extra_args.iter().cloned());
278 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 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 assert!(args.contains(&"--skip-git-repo-check".to_owned()));
318 assert!(!args.iter().any(|a| a == "--model"));
319 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 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 assert!(!args.iter().any(|a| a == "--max-turns"));
350 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 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 let last_two = &args[args.len() - 2..];
365 assert_eq!(last_two, &["sess-9".to_owned(), "hi".to_owned()]);
366 }
367}