use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use crate::{
probe_version, Command, ResolveCli, CredentialSpec, Harness, Features, Error,
Info, ModelChoice, Readiness, InstallCallback, InstallHint, RunCallback,
RunHandle, RunMode, RunRequest, RunTuning,
};
mod parser;
pub use parser::{parse_codex_line, CodexStreamParser};
pub const CODEX_HARNESS_ID: &str = "codex";
pub const DEFAULT_CODEX_COMMAND: &str = "codex";
#[derive(Debug, Clone)]
pub struct CodexHarness {
command: String,
}
impl Default for CodexHarness {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct CodexHarnessConfig {
pub command: String,
}
impl Default for CodexHarnessConfig {
fn default() -> Self {
Self { command: DEFAULT_CODEX_COMMAND.to_owned() }
}
}
impl CodexHarness {
pub fn new() -> Self {
Self::custom(CodexHarnessConfig::default())
}
pub fn custom(config: CodexHarnessConfig) -> Self {
Self { command: config.command }
}
}
impl Harness for CodexHarness {
fn info(&self) -> Info {
Info {
id: CODEX_HARNESS_ID.to_owned(),
display_name: "Codex".to_owned(),
description: "OpenAI's Codex agent CLI. Uses your existing Codex login.".to_owned(),
install_hint: Some(
InstallHint::url("https://developers.openai.com/codex")
.with_command("npm install -g @openai/codex"),
),
}
}
fn features(&self) -> Features {
Features {
custom_model: true,
effort: true,
login: true,
..Default::default()
}
}
fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
Ok(crate::models_dev::provider_models("openai"))
}
fn readiness(&self) -> Readiness {
let Some(version) = probe_version(&self.command) else {
return Readiness {
harness_id: CODEX_HARNESS_ID.to_owned(),
ready: false,
installed: false,
version: None,
auth_configured: false,
error: Some("Codex (`codex`) is not installed or not on PATH.".to_owned()),
details: Value::Null,
};
};
let signed_in = probe_codex_signed_in(&self.command)
|| crate::harness::api_key_value_usable(std::env::var("OPENAI_API_KEY").ok());
Readiness {
harness_id: CODEX_HARNESS_ID.to_owned(),
ready: signed_in,
installed: true,
version: Some(version),
auth_configured: signed_in,
error: if signed_in {
None
} else {
Some(
"Codex is installed but not signed in. Click Sign in to connect your ChatGPT/OpenAI account, or set OPENAI_API_KEY."
.to_owned(),
)
},
details: codex_resolved_details(&self.command),
}
}
fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error> {
let RunRequest { run_id, prompt, cwd, mode, tuning, resume, attachments: _ } = request;
let args = build_codex_args(prompt, mode, &tuning, resume.as_deref());
let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let parser = Arc::new(Mutex::new(CodexStreamParser::new()));
let program = tuning.binary_path.clone().unwrap_or_else(|| PathBuf::from(&self.command));
let handle = Command::new(program)
.cwd(cwd)
.run_id(run_id)
.args(args)
.resolve_cli()
.stream(move |event| {
let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
for normalized in parser.on_process_event(event) {
(*on_event)(normalized);
}
},
)
.map_err(Error::spawn)?;
Ok(Box::new(handle))
}
fn credential(&self) -> CredentialSpec {
CredentialSpec {
label: "Codex login (managed by the codex CLI)".to_owned(),
keychain_service: "openai".to_owned(),
keychain_account: "OPENAI_API_KEY".to_owned(),
required: false,
}
}
fn login(&self, on_event: InstallCallback) -> Result<(), Error> {
crate::run_login_command(&self.command, &["login"], on_event)
}
}
fn codex_resolved_details(command: &str) -> Value {
let path = crate::augmented_node_path();
let Some(resolved) = crate::claude::resolve::resolve_on_path(command, &path) else {
return Value::Null;
};
let mut details = serde_json::Map::new();
details.insert(
"resolved_path".to_owned(),
Value::String(resolved.to_string_lossy().into_owned()),
);
if let Ok(home) = std::env::var("HOME") {
let kind = crate::claude::resolve::classify(&resolved, std::path::Path::new(&home), None);
details.insert(
"install_kind".to_owned(),
Value::String(kind.as_str().to_owned()),
);
}
Value::Object(details)
}
fn probe_codex_signed_in(command: &str) -> bool {
crate::hidden_command(command)
.args(["login", "status"])
.env("PATH", crate::augmented_node_path())
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn build_codex_args(
prompt: String,
mode: RunMode,
tuning: &RunTuning,
resume: Option<&str>,
) -> Vec<String> {
let mut args = vec!["exec".to_owned()];
if resume.is_some() {
args.push("resume".to_owned());
}
args.push("--json".to_owned());
args.push("--skip-git-repo-check".to_owned());
if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
args.push("--model".to_owned());
args.push(model.to_owned());
}
let effort = tuning.effort.unwrap_or(crate::ReasoningEffort::Low);
args.push("-c".to_owned());
args.push(format!("model_reasoning_effort=\"{}\"", effort.as_cli_value()));
if matches!(mode, RunMode::Edit) {
args.push("--full-auto".to_owned());
}
args.extend(tuning.extra_args.iter().cloned());
if let Some(session_id) = resume {
args.push(session_id.to_owned());
}
args.push(prompt);
args
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::RunEvent;
#[cfg(unix)]
fn fake_codex(tag: &str, signed_in: bool, emits: &str) -> (std::path::PathBuf, std::path::PathBuf) {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("codex-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let argv = dir.join("argv");
let cli = dir.join("codex");
let login_exit = i32::from(!signed_in);
std::fs::write(
&cli,
format!(
"#!/bin/sh\n\
case \"$1\" in\n\
--version) echo 'codex-cli 9.9.9'; exit 0 ;;\n\
login) exit {login_exit} ;;\n\
exec) : > '{argv}'; for a in \"$@\"; do printf '%s\\n' \"$a\" >> '{argv}'; done\n\
{emits}\n\
exit 0 ;;\n\
esac\n\
exit 1\n",
argv = argv.display(),
),
)
.unwrap();
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
(cli, argv)
}
#[cfg(unix)]
fn drive(cli: &std::path::Path, request: RunRequest) -> Vec<RunEvent> {
use std::sync::{Arc, Mutex};
let seen: Arc<Mutex<Vec<RunEvent>>> = Arc::default();
let sink = Arc::clone(&seen);
let harness = CodexHarness::custom(CodexHarnessConfig {
command: cli.display().to_string(),
});
let handle = harness
.start(request, Arc::new(move |event| sink.lock().unwrap().push(event)))
.expect("the stand-in should spawn");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let done = seen
.lock()
.unwrap()
.iter()
.any(|event| matches!(event, RunEvent::Exited { .. }));
if done {
break;
}
assert!(std::time::Instant::now() < deadline, "the run never exited");
std::thread::sleep(std::time::Duration::from_millis(20));
}
let _ = handle.cancel();
let events = seen.lock().unwrap().clone();
events
}
#[cfg(unix)]
#[test]
fn a_run_reaches_the_cli_as_codex_exec_and_comes_back_as_events() {
let message = r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"done"}}"#;
let (cli, argv) = fake_codex("run", true, &format!("printf '%s\\n' '{message}'"));
let events = drive(
&cli,
RunRequest {
run_id: "r1".to_owned(),
prompt: "say something".to_owned(),
cwd: Some(std::env::temp_dir()),
tuning: RunTuning { model: Some("o4-mini".to_owned()), ..RunTuning::default() },
..RunRequest::default()
},
);
let passed: Vec<String> =
std::fs::read_to_string(&argv).unwrap().lines().map(str::to_owned).collect();
assert_eq!(passed.first().map(String::as_str), Some("exec"));
assert!(passed.iter().any(|a| a == "--json"), "argv was {passed:?}");
assert!(passed.iter().any(|a| a == "--skip-git-repo-check"), "argv was {passed:?}");
assert!(passed.windows(2).any(|w| w[0] == "--model" && w[1] == "o4-mini"));
assert_eq!(
passed.last().map(String::as_str),
Some("say something"),
"the prompt is the trailing positional",
);
assert!(
events.iter().any(|e| matches!(e, RunEvent::Text { delta, .. } if delta == "done")),
"the CLI's message should arrive as text: {events:?}",
);
assert!(events.iter().any(|e| matches!(
e,
RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }
)));
}
#[cfg(unix)]
#[test]
fn a_nonzero_exit_is_reported_rather_than_read_as_a_finished_run() {
let (cli, _) = fake_codex("fail", true, "exit 3; :");
let events = drive(&cli, RunRequest { prompt: "hi".to_owned(), ..RunRequest::default() });
assert!(
events.iter().any(|e| matches!(e, RunEvent::Exited { exit_code: Some(3), .. })),
"{events:?}",
);
}
#[cfg(unix)]
#[test]
fn readiness_reports_the_version_and_whether_login_status_succeeded() {
let (yes, _) = fake_codex("in", true, ":");
let ready = CodexHarness::custom(CodexHarnessConfig { command: yes.display().to_string() })
.readiness();
assert!(ready.installed && ready.ready && ready.auth_configured);
assert_eq!(ready.version.as_deref(), Some("codex-cli 9.9.9"));
assert!(ready.error.is_none());
let (no, _) = fake_codex("out", false, ":");
let ready = CodexHarness::custom(CodexHarnessConfig { command: no.display().to_string() })
.readiness();
assert!(ready.installed, "the binary is present either way");
assert!(!ready.ready && !ready.auth_configured);
assert!(ready.error.is_some(), "a signed-out CLI must say what to do");
}
#[test]
fn a_renamed_binary_is_what_gets_probed() {
let renamed = CodexHarness::custom(CodexHarnessConfig {
command: "definitely-not-a-real-binary-xyz".into(),
});
assert!(!renamed.readiness().installed, "an unresolvable command cannot report installed");
assert_eq!(CodexHarness::new().command, DEFAULT_CODEX_COMMAND);
assert_eq!(CodexHarness::default().command, DEFAULT_CODEX_COMMAND);
}
use crate::ReasoningEffort;
#[cfg(unix)]
fn fake_cli(tag: &str, script: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("hl-codex-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cli");
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(unix)]
#[test]
fn sign_in_is_read_from_the_exit_code_of_login_status() {
let signed_in = fake_cli("in", "echo 'Logged in'; exit 0");
assert!(probe_codex_signed_in(signed_in.to_str().unwrap()));
let signed_out = fake_cli("out", "echo 'Not logged in'; exit 1");
assert!(!probe_codex_signed_in(signed_out.to_str().unwrap()));
assert!(!probe_codex_signed_in("definitely-not-a-real-binary-xyz"), "an absent CLI is not signed in");
}
#[test]
fn codex_info_and_credential() {
let h = CodexHarness::new();
assert_eq!(h.info().id, CODEX_HARNESS_ID);
let hint = h.info().install_hint.expect("Codex is a CLI the user installs");
assert_eq!(hint.command.as_deref(), Some("npm install -g @openai/codex"));
assert!(!h.credential().required);
}
fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.map(String::as_str)
}
#[test]
fn codex_args_default_omit_model_but_force_low_effort() {
let args = build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), None);
assert_eq!(args[0], "exec");
assert!(!args.contains(&"resume".to_owned()));
assert!(args.contains(&"--json".to_owned()));
assert!(args.contains(&"--skip-git-repo-check".to_owned()));
assert!(!args.iter().any(|a| a == "--model"));
assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"low\""));
assert!(!args.iter().any(|a| a.contains("minimal")));
assert!(!args.iter().any(|a| a == "--full-auto"));
assert_eq!(args.last().map(String::as_str), Some("hi"));
}
#[test]
fn codex_args_explicit_minimal_effort_is_honored() {
let tuning =
RunTuning { effort: Some(ReasoningEffort::Minimal), ..RunTuning::default() };
let args = build_codex_args("hi".to_owned(), RunMode::Ask, &tuning, None);
assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"minimal\""));
}
#[test]
fn codex_args_carry_model_and_effort_and_ignore_max_turns() {
let tuning = RunTuning {
model: Some("gpt-5-codex".to_owned()),
effort: Some(ReasoningEffort::High),
max_turns: Some(5),
..RunTuning::default()
};
let args = build_codex_args("hi".to_owned(), RunMode::Edit, &tuning, None);
assert_eq!(flag_value(&args, "--model"), Some("gpt-5-codex"));
assert_eq!(flag_value(&args, "-c"), Some("model_reasoning_effort=\"high\""));
assert!(args.contains(&"--full-auto".to_owned()));
assert!(!args.iter().any(|a| a == "--max-turns"));
assert_eq!(args.last().map(String::as_str), Some("hi"));
}
#[test]
fn codex_resume_uses_the_resume_subcommand_with_id_before_prompt() {
let args =
build_codex_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), Some("sess-9"));
assert_eq!(args[0], "exec");
assert_eq!(args[1], "resume");
assert!(args.contains(&"--json".to_owned()));
assert!(args.contains(&"--skip-git-repo-check".to_owned()));
let last_two = &args[args.len() - 2..];
assert_eq!(last_two, &["sess-9".to_owned(), "hi".to_owned()]);
}
}