cli_agents/adapters/codex/
mod.rs1mod parse;
2
3use crate::adapters::CliAdapter;
4use crate::discovery::discover_binary;
5use crate::error::{Error, Result};
6use crate::events::StreamEvent;
7use crate::types::{CliName, RunOptions, RunResult};
8use crate::DEFAULT_MAX_OUTPUT_BYTES;
9use serde::Serialize;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use tokio_util::sync::CancellationToken;
13use tracing::warn;
14
15pub struct CodexAdapter;
16
17impl CliAdapter for CodexAdapter {
18 fn name(&self) -> CliName {
19 CliName::Codex
20 }
21
22 async fn run(
23 &self,
24 opts: &RunOptions,
25 emit: &(dyn Fn(StreamEvent) + Send + Sync),
26 cancel: CancellationToken,
27 ) -> Result<RunResult> {
28 let binary = match &opts.executable_path {
29 Some(p) => p.clone(),
30 None => discover_binary(CliName::Codex).await.ok_or(Error::NoCli)?,
31 };
32
33 let (config_env, _tmp_dir) = write_configs(opts).await?;
36
37 let args = build_args(opts);
38 let mut extra_env = opts.env.clone().unwrap_or_default();
39 extra_env.extend(config_env);
40 let max_bytes = opts.max_output_bytes.unwrap_or(DEFAULT_MAX_OUTPUT_BYTES);
41
42 let mut state = parse::ParseState::default();
43 let mut text_tracker: HashMap<String, String> = HashMap::new();
44
45 let outcome = crate::adapters::spawn_and_stream(
46 crate::adapters::SpawnParams {
47 cli_label: "codex",
48 binary: &binary,
49 args: &args,
50 extra_env: &extra_env,
51 strip_env: &[],
52 cwd: opts.cwd.as_deref().unwrap_or("."),
53 max_bytes,
54 cancel: &cancel,
55 },
56 |line| parse::parse_line(line, &mut state, &mut text_tracker, emit),
57 )
58 .await?;
59
60 match outcome {
61 crate::adapters::SpawnOutcome::Cancelled => Ok(RunResult {
62 success: false,
63 text: Some("Cancelled.".into()),
64 ..Default::default()
65 }),
66 crate::adapters::SpawnOutcome::Done { exit_code, stderr } => {
67 let success = !state.failed && exit_code == 0;
68 let text = if !success && state.result_text.is_none() {
69 crate::adapters::extract_error_message(stderr.as_deref())
70 } else {
71 state.result_text
72 };
73 Ok(RunResult {
74 success,
75 text,
76 exit_code: Some(exit_code),
77 stats: state.stats,
78 session_id: state.session_id,
79 stderr,
80 cost_usd: None,
81 })
82 }
83 }
84 }
85}
86
87fn build_args(opts: &RunOptions) -> Vec<String> {
88 let mut args = vec!["exec".into()];
89
90 if let Some(session_id) = &opts.resume_session_id {
92 args.push("resume".into());
93 args.push(session_id.clone());
94 }
95
96 args.push(opts.task.clone());
97 args.push("--json".into());
98
99 if let Some(model) = &opts.model {
100 args.push("--model".into());
101 args.push(model.clone());
102 }
103
104 if let Some(cwd) = &opts.cwd {
105 args.push("-C".into());
106 args.push(cwd.clone());
107 }
108
109 let codex_opts = opts.providers.as_ref().and_then(|p| p.codex.as_ref());
110
111 if let Some(co) = codex_opts {
112 if let Some(policy) = &co.approval_policy {
113 match policy.as_str() {
114 "full-auto" => args.push("--full-auto".into()),
115 "suggest" | "auto-edit" => {
116 }
118 other => {
119 warn!(policy = other, "unknown Codex approval policy, ignoring");
120 }
121 }
122 }
123 if let Some(sandbox) = &co.sandbox_mode {
124 args.push("--sandbox".into());
125 args.push(sandbox.clone());
126 }
127 if let Some(dirs) = &co.additional_directories {
128 for dir in dirs {
129 args.push("-C".into());
130 args.push(dir.clone());
131 }
132 }
133 if let Some(images) = &co.images {
134 for img in images {
135 args.push("--image".into());
136 args.push(img.clone());
137 }
138 }
139 if let Some(schema) = &co.output_schema {
140 args.push("--output-schema".into());
141 args.push(schema.clone());
142 }
143 }
144
145 let has_policy = codex_opts
148 .and_then(|c| c.approval_policy.as_deref())
149 .is_some_and(|p| !p.is_empty());
150 if opts.skip_permissions && !has_policy {
151 args.push("--dangerously-bypass-approvals-and-sandbox".into());
152 }
153
154 if opts.skip_permissions {
156 args.push("--skip-git-repo-check".into());
157 }
158
159 args
160}
161
162#[derive(Serialize)]
165struct CodexConfig {
166 #[serde(skip_serializing_if = "Option::is_none")]
167 instructions: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 mcp_servers: Option<HashMap<String, CodexMcpServer>>,
170}
171
172#[derive(Serialize)]
173struct CodexMcpServer {
174 #[serde(skip_serializing_if = "Option::is_none")]
175 command: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 args: Option<Vec<String>>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 env: Option<HashMap<String, String>>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 cwd: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 tool_timeout_sec: Option<u64>,
184}
185
186async fn write_configs(
195 opts: &RunOptions,
196) -> Result<(HashMap<String, String>, Option<tempfile::TempDir>)> {
197 let has_mcp = opts.mcp_servers.as_ref().is_some_and(|s| !s.is_empty());
198 let system_prompt = resolve_system_prompt(opts).await?;
199
200 if !has_mcp && system_prompt.is_none() {
201 return Ok((HashMap::new(), None));
202 }
203
204 let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
205 let codex_home = resolve_codex_home();
206 if let Some(home) = &codex_home {
207 link_codex_home_contents(home, tmp_dir.path())?;
208 }
209
210 let config = CodexConfig {
211 instructions: system_prompt,
212 mcp_servers: opts.mcp_servers.as_ref().map(|servers| {
213 servers
214 .iter()
215 .map(|(name, s)| {
216 (
217 name.clone(),
218 CodexMcpServer {
219 command: s.command.clone(),
220 args: s.args.clone(),
221 env: s.env.clone(),
222 cwd: s.cwd.clone(),
223 tool_timeout_sec: s.timeout,
224 },
225 )
226 })
227 .collect()
228 }),
229 };
230
231 let existing_config = codex_home.as_ref().map(|h| h.join("config.toml"));
232 let config_table = merge_config(existing_config.as_deref(), config)?;
233 let toml_str = toml::to_string_pretty(&config_table)
234 .map_err(|e| Error::Other(format!("TOML serialization: {e}")))?;
235 let config_path = tmp_dir.path().join("config.toml");
236 tokio::fs::write(&config_path, toml_str)
237 .await
238 .map_err(Error::Io)?;
239
240 let mut env = HashMap::new();
241 env.insert(
242 "CODEX_HOME".into(),
243 tmp_dir.path().to_string_lossy().into_owned(),
244 );
245 Ok((env, Some(tmp_dir)))
246}
247
248fn resolve_codex_home() -> Option<PathBuf> {
249 std::env::var_os("CODEX_HOME")
250 .map(PathBuf::from)
251 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex")))
252}
253
254fn link_codex_home_contents(src: &Path, dst: &Path) -> Result<()> {
258 if !src.exists() {
259 return Ok(());
260 }
261
262 for entry in std::fs::read_dir(src).map_err(Error::Io)? {
263 let entry = entry.map_err(Error::Io)?;
264 if entry.file_name() == "config.toml" {
265 continue;
266 }
267 let target = dst.join(entry.file_name());
268 symlink_entry(&entry.path(), &target)?;
269 }
270
271 Ok(())
272}
273
274#[cfg(unix)]
275fn symlink_entry(src: &Path, dst: &Path) -> Result<()> {
276 std::os::unix::fs::symlink(src, dst).map_err(Error::Io)
277}
278
279#[cfg(windows)]
280fn symlink_entry(src: &Path, dst: &Path) -> Result<()> {
281 if src.is_dir() {
282 std::os::windows::fs::symlink_dir(src, dst).map_err(Error::Io)
283 } else {
284 std::os::windows::fs::symlink_file(src, dst).map_err(Error::Io)
285 }
286}
287
288fn merge_config(existing_config: Option<&Path>, config: CodexConfig) -> Result<toml::Table> {
289 let mut table = match existing_config {
290 Some(path) if path.exists() => {
291 let existing = std::fs::read_to_string(path).map_err(Error::Io)?;
292 toml::from_str::<toml::Table>(&existing)
293 .map_err(|e| Error::Other(format!("TOML parse: {e}")))?
294 }
295 _ => toml::Table::new(),
296 };
297
298 if let Some(instructions) = config.instructions {
299 table.insert("instructions".into(), toml::Value::String(instructions));
300 }
301
302 if let Some(mcp_servers) = config.mcp_servers {
303 let value = toml::Value::try_from(mcp_servers)
304 .map_err(|e| Error::Other(format!("TOML conversion: {e}")))?;
305 table.insert("mcp_servers".into(), value);
306 }
307
308 Ok(table)
309}
310
311async fn resolve_system_prompt(opts: &RunOptions) -> Result<Option<String>> {
314 if let Some(path) = &opts.system_prompt_file {
315 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
316 Error::Process(format!("failed to read system prompt file {path}: {e}"))
317 })?;
318 Ok(Some(content))
319 } else {
320 Ok(opts.system_prompt.clone())
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn build_args_minimal() {
330 let opts = RunOptions {
331 task: "hello".into(),
332 ..Default::default()
333 };
334 let args = build_args(&opts);
335 assert!(args.contains(&"exec".to_string()));
336 assert!(args.contains(&"hello".to_string()));
337 assert!(args.contains(&"--json".to_string()));
338 }
339
340 #[test]
341 fn build_args_no_permission_bypass_by_default() {
342 let opts = RunOptions {
343 task: "hello".into(),
344 ..Default::default()
345 };
346 let args = build_args(&opts);
347 assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
348 }
349
350 #[test]
351 fn build_args_permission_bypass_when_opted_in() {
352 let opts = RunOptions {
353 task: "hello".into(),
354 skip_permissions: true,
355 ..Default::default()
356 };
357 let args = build_args(&opts);
358 assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
359 assert!(args.contains(&"--skip-git-repo-check".to_string()));
360 }
361
362 #[test]
363 fn build_args_resume_session() {
364 let opts = RunOptions {
365 task: "continue working".into(),
366 resume_session_id: Some("tid-abc123".into()),
367 ..Default::default()
368 };
369 let args = build_args(&opts);
370 let resume_idx = args.iter().position(|a| a == "resume").unwrap();
372 assert_eq!(args[resume_idx + 1], "tid-abc123");
373 }
374
375 #[test]
376 fn build_args_full_auto() {
377 let opts = RunOptions {
378 task: "fix bug".into(),
379 model: Some("o3".into()),
380 providers: Some(crate::types::ProviderOptions {
381 codex: Some(crate::types::CodexOptions {
382 approval_policy: Some("full-auto".into()),
383 sandbox_mode: Some("workspace-write".into()),
384 ..Default::default()
385 }),
386 ..Default::default()
387 }),
388 ..Default::default()
389 };
390 let args = build_args(&opts);
391 assert!(args.contains(&"--full-auto".to_string()));
392 assert!(args.contains(&"--sandbox".to_string()));
393 assert!(args.contains(&"--model".to_string()));
394 assert!(args.contains(&"o3".to_string()));
395 }
396
397 #[test]
398 fn build_args_full_auto_with_skip_permissions_no_conflict() {
399 let opts = RunOptions {
400 task: "fix bug".into(),
401 skip_permissions: true,
402 providers: Some(crate::types::ProviderOptions {
403 codex: Some(crate::types::CodexOptions {
404 approval_policy: Some("full-auto".into()),
405 ..Default::default()
406 }),
407 ..Default::default()
408 }),
409 ..Default::default()
410 };
411 let args = build_args(&opts);
412 assert!(args.contains(&"--full-auto".to_string()));
413 assert!(
414 !args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()),
415 "should not pass both --full-auto and --dangerously-bypass-approvals-and-sandbox"
416 );
417 assert!(args.contains(&"--skip-git-repo-check".to_string()));
418 }
419
420 #[tokio::test]
421 async fn write_configs_creates_mcp_config() {
422 let mut servers = HashMap::new();
423 servers.insert(
424 "test".into(),
425 crate::types::McpServer {
426 command: Some("test-server".into()),
427 args: Some(vec!["--flag".into()]),
428 ..Default::default()
429 },
430 );
431
432 let opts = RunOptions {
433 task: "hello".into(),
434 mcp_servers: Some(servers),
435 ..Default::default()
436 };
437
438 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
439 assert!(env.contains_key("CODEX_HOME"));
440 let tmp = tmp_dir.unwrap();
441
442 let config_path = tmp.path().join("config.toml");
443 let content = std::fs::read_to_string(&config_path).unwrap();
444 assert!(content.contains("[mcp_servers.test]"));
445 assert!(content.contains("test-server"));
446 }
447
448 #[tokio::test]
449 async fn write_configs_with_system_prompt() {
450 let opts = RunOptions {
451 task: "hello".into(),
452 system_prompt: Some("You are helpful.".into()),
453 ..Default::default()
454 };
455
456 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
457 assert!(env.contains_key("CODEX_HOME"));
458 let tmp = tmp_dir.unwrap();
459
460 let config_path = tmp.path().join("config.toml");
461 let content = std::fs::read_to_string(&config_path).unwrap();
462 assert!(content.contains("instructions"));
463 assert!(content.contains("You are helpful."));
464 }
465
466 #[tokio::test]
467 async fn write_configs_noop_when_empty() {
468 let opts = RunOptions {
469 task: "hello".into(),
470 ..Default::default()
471 };
472
473 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
474 assert!(env.is_empty());
475 assert!(tmp_dir.is_none());
476 }
477
478 #[test]
479 fn merge_config_preserves_existing_keys() {
480 let tmp = tempfile::tempdir().unwrap();
481 let path = tmp.path().join("config.toml");
482 std::fs::write(
483 &path,
484 r#"model = "o3"
485
486[mcp_servers.preexisting]
487command = "old-server"
488
489[other_table]
490foo = "bar"
491"#,
492 )
493 .unwrap();
494
495 let mut servers = HashMap::new();
496 servers.insert(
497 "test".into(),
498 CodexMcpServer {
499 command: Some("test-server".into()),
500 args: None,
501 env: None,
502 cwd: None,
503 tool_timeout_sec: None,
504 },
505 );
506
507 let config = CodexConfig {
508 instructions: Some("hello".into()),
509 mcp_servers: Some(servers),
510 };
511
512 let merged = merge_config(Some(&path), config).unwrap();
513
514 assert_eq!(merged.get("model").and_then(|v| v.as_str()), Some("o3"));
515 assert!(merged.get("other_table").is_some());
516 assert_eq!(
517 merged.get("instructions").and_then(|v| v.as_str()),
518 Some("hello")
519 );
520 let mcp = merged.get("mcp_servers").and_then(|v| v.as_table()).unwrap();
523 assert!(mcp.contains_key("test"));
524 assert!(!mcp.contains_key("preexisting"));
525 }
526
527 #[test]
528 fn merge_config_no_existing_file() {
529 let merged = merge_config(
530 None,
531 CodexConfig {
532 instructions: Some("hi".into()),
533 mcp_servers: None,
534 },
535 )
536 .unwrap();
537 assert_eq!(
538 merged.get("instructions").and_then(|v| v.as_str()),
539 Some("hi")
540 );
541 }
542
543 #[cfg(unix)]
544 #[test]
545 fn link_codex_home_skips_config_and_symlinks_rest() {
546 let src = tempfile::tempdir().unwrap();
547 std::fs::write(src.path().join("config.toml"), "model = \"old\"").unwrap();
548 std::fs::write(src.path().join("auth.json"), "{\"token\":\"x\"}").unwrap();
549 std::fs::create_dir(src.path().join("sessions")).unwrap();
550 std::fs::write(src.path().join("sessions").join("a.jsonl"), "{}").unwrap();
551
552 let dst = tempfile::tempdir().unwrap();
553 link_codex_home_contents(src.path(), dst.path()).unwrap();
554
555 assert!(!dst.path().join("config.toml").exists());
556 let auth = dst.path().join("auth.json");
557 assert!(auth.exists());
558 assert!(auth.symlink_metadata().unwrap().file_type().is_symlink());
559 let sessions = dst.path().join("sessions");
560 assert!(sessions.symlink_metadata().unwrap().file_type().is_symlink());
561 let contents =
563 std::fs::read_to_string(dst.path().join("sessions").join("a.jsonl")).unwrap();
564 assert_eq!(contents, "{}");
565 }
566
567 #[tokio::test]
568 async fn write_configs_system_prompt_file_takes_precedence() {
569 let fixture = tempfile::tempdir().unwrap();
570
571 let prompt_file = fixture.path().join("prompt.md");
573 std::fs::write(&prompt_file, "File prompt content").unwrap();
574
575 let opts = RunOptions {
576 task: "hello".into(),
577 system_prompt: Some("Inline prompt".into()),
578 system_prompt_file: Some(prompt_file.to_string_lossy().into_owned()),
579 ..Default::default()
580 };
581
582 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
583 assert!(env.contains_key("CODEX_HOME"));
584 let tmp = tmp_dir.unwrap();
585
586 let config_path = tmp.path().join("config.toml");
587 let content = std::fs::read_to_string(&config_path).unwrap();
588 assert!(content.contains("File prompt content"));
589 assert!(!content.contains("Inline prompt"));
590 }
591}