cli_agents/adapters/codex/
mod.rs1mod parse;
2
3use crate::DEFAULT_MAX_OUTPUT_BYTES;
4use crate::adapters::CliAdapter;
5use crate::discovery::discover_binary;
6use crate::error::{Error, Result};
7use crate::events::StreamEvent;
8use crate::types::{CliName, RunOptions, RunResult};
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 {
67 exit_code,
68 signal,
69 stderr,
70 } => {
71 let success = !state.failed && exit_code == Some(0);
72 let text = if !success && state.result_text.is_none() {
73 crate::adapters::extract_error_message(stderr.as_deref())
74 .or_else(|| crate::adapters::describe_signal(signal))
75 } else {
76 state.result_text
77 };
78 Ok(RunResult {
79 success,
80 text,
81 exit_code,
82 signal,
83 stats: state.stats,
84 session_id: state.session_id,
85 stderr,
86 cost_usd: None,
87 })
88 }
89 }
90 }
91}
92
93fn build_args(opts: &RunOptions) -> Vec<String> {
94 let mut args = vec!["exec".into()];
95
96 if let Some(session_id) = &opts.resume_session_id {
98 args.push("resume".into());
99 args.push(session_id.clone());
100 }
101
102 args.push(opts.task.clone());
103 args.push("--json".into());
104
105 if let Some(model) = &opts.model {
106 args.push("--model".into());
107 args.push(model.clone());
108 }
109
110 if let Some(cwd) = &opts.cwd {
111 args.push("-C".into());
112 args.push(cwd.clone());
113 }
114
115 let codex_opts = opts.providers.as_ref().and_then(|p| p.codex.as_ref());
116
117 if let Some(co) = codex_opts {
118 if let Some(policy) = &co.approval_policy {
119 match policy.as_str() {
120 "full-auto" => args.push("--full-auto".into()),
121 "suggest" | "auto-edit" => {
122 }
124 other => {
125 warn!(policy = other, "unknown Codex approval policy, ignoring");
126 }
127 }
128 }
129 if let Some(sandbox) = &co.sandbox_mode {
130 args.push("--sandbox".into());
131 args.push(sandbox.clone());
132 }
133 if let Some(dirs) = &co.additional_directories {
134 for dir in dirs {
135 args.push("-C".into());
136 args.push(dir.clone());
137 }
138 }
139 if let Some(images) = &co.images {
140 for img in images {
141 args.push("--image".into());
142 args.push(img.clone());
143 }
144 }
145 if let Some(schema) = &co.output_schema {
146 args.push("--output-schema".into());
147 args.push(schema.clone());
148 }
149 }
150
151 let has_policy = codex_opts
154 .and_then(|c| c.approval_policy.as_deref())
155 .is_some_and(|p| !p.is_empty());
156 if opts.skip_permissions && !has_policy {
157 args.push("--dangerously-bypass-approvals-and-sandbox".into());
158 }
159
160 if opts.skip_permissions {
162 args.push("--skip-git-repo-check".into());
163 }
164
165 if let Some(extra) = codex_opts.and_then(|c| c.extra_args.as_ref()) {
167 args.extend(extra.iter().cloned());
168 }
169
170 args
171}
172
173#[derive(Serialize)]
176struct CodexConfig {
177 #[serde(skip_serializing_if = "Option::is_none")]
178 instructions: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 mcp_servers: Option<HashMap<String, CodexMcpServer>>,
181}
182
183#[derive(Serialize)]
184struct CodexMcpServer {
185 #[serde(skip_serializing_if = "Option::is_none")]
186 command: Option<String>,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 args: Option<Vec<String>>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 env: Option<HashMap<String, String>>,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 cwd: Option<String>,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 tool_timeout_sec: Option<u64>,
195}
196
197async fn write_configs(
206 opts: &RunOptions,
207) -> Result<(HashMap<String, String>, Option<tempfile::TempDir>)> {
208 let has_mcp = opts.mcp_servers.as_ref().is_some_and(|s| !s.is_empty());
209 let system_prompt = resolve_system_prompt(opts).await?;
210
211 if !has_mcp && system_prompt.is_none() {
212 return Ok((HashMap::new(), None));
213 }
214
215 let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
216 let codex_home = resolve_codex_home();
217 if let Some(home) = &codex_home {
218 link_codex_home_contents(home, tmp_dir.path())?;
219 }
220
221 let config = CodexConfig {
222 instructions: system_prompt,
223 mcp_servers: opts.mcp_servers.as_ref().map(|servers| {
224 servers
225 .iter()
226 .map(|(name, s)| {
227 (
228 name.clone(),
229 CodexMcpServer {
230 command: s.command.clone(),
231 args: s.args.clone(),
232 env: s.env.clone(),
233 cwd: s.cwd.clone(),
234 tool_timeout_sec: s.timeout,
235 },
236 )
237 })
238 .collect()
239 }),
240 };
241
242 let existing_config = codex_home.as_ref().map(|h| h.join("config.toml"));
243 let config_table = merge_config(existing_config.as_deref(), config)?;
244 let toml_str = toml::to_string_pretty(&config_table)
245 .map_err(|e| Error::Other(format!("TOML serialization: {e}")))?;
246 let config_path = tmp_dir.path().join("config.toml");
247 tokio::fs::write(&config_path, toml_str)
248 .await
249 .map_err(Error::Io)?;
250
251 let mut env = HashMap::new();
252 env.insert(
253 "CODEX_HOME".into(),
254 tmp_dir.path().to_string_lossy().into_owned(),
255 );
256 Ok((env, Some(tmp_dir)))
257}
258
259fn resolve_codex_home() -> Option<PathBuf> {
260 std::env::var_os("CODEX_HOME")
261 .map(PathBuf::from)
262 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex")))
263}
264
265fn link_codex_home_contents(src: &Path, dst: &Path) -> Result<()> {
269 if !src.exists() {
270 return Ok(());
271 }
272
273 for entry in std::fs::read_dir(src).map_err(Error::Io)? {
274 let entry = entry.map_err(Error::Io)?;
275 if entry.file_name() == "config.toml" {
276 continue;
277 }
278 let target = dst.join(entry.file_name());
279 symlink_entry(&entry.path(), &target)?;
280 }
281
282 Ok(())
283}
284
285#[cfg(unix)]
286fn symlink_entry(src: &Path, dst: &Path) -> Result<()> {
287 std::os::unix::fs::symlink(src, dst).map_err(Error::Io)
288}
289
290#[cfg(windows)]
291fn symlink_entry(src: &Path, dst: &Path) -> Result<()> {
292 if src.is_dir() {
293 std::os::windows::fs::symlink_dir(src, dst).map_err(Error::Io)
294 } else {
295 std::os::windows::fs::symlink_file(src, dst).map_err(Error::Io)
296 }
297}
298
299fn merge_config(existing_config: Option<&Path>, config: CodexConfig) -> Result<toml::Table> {
300 let mut table = match existing_config {
301 Some(path) if path.exists() => {
302 let existing = std::fs::read_to_string(path).map_err(Error::Io)?;
303 toml::from_str::<toml::Table>(&existing)
304 .map_err(|e| Error::Other(format!("TOML parse: {e}")))?
305 }
306 _ => toml::Table::new(),
307 };
308
309 if let Some(instructions) = config.instructions {
310 table.insert("instructions".into(), toml::Value::String(instructions));
311 }
312
313 if let Some(mcp_servers) = config.mcp_servers {
314 let value = toml::Value::try_from(mcp_servers)
315 .map_err(|e| Error::Other(format!("TOML conversion: {e}")))?;
316 table.insert("mcp_servers".into(), value);
317 }
318
319 Ok(table)
320}
321
322async fn resolve_system_prompt(opts: &RunOptions) -> Result<Option<String>> {
325 if let Some(path) = &opts.system_prompt_file {
326 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
327 Error::Process(format!("failed to read system prompt file {path}: {e}"))
328 })?;
329 Ok(Some(content))
330 } else {
331 Ok(opts.system_prompt.clone())
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn build_args_minimal() {
341 let opts = RunOptions {
342 task: "hello".into(),
343 ..Default::default()
344 };
345 let args = build_args(&opts);
346 assert!(args.contains(&"exec".to_string()));
347 assert!(args.contains(&"hello".to_string()));
348 assert!(args.contains(&"--json".to_string()));
349 }
350
351 #[test]
352 fn build_args_no_permission_bypass_by_default() {
353 let opts = RunOptions {
354 task: "hello".into(),
355 ..Default::default()
356 };
357 let args = build_args(&opts);
358 assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
359 }
360
361 #[test]
362 fn build_args_permission_bypass_when_opted_in() {
363 let opts = RunOptions {
364 task: "hello".into(),
365 skip_permissions: true,
366 ..Default::default()
367 };
368 let args = build_args(&opts);
369 assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
370 assert!(args.contains(&"--skip-git-repo-check".to_string()));
371 }
372
373 #[test]
374 fn build_args_resume_session() {
375 let opts = RunOptions {
376 task: "continue working".into(),
377 resume_session_id: Some("tid-abc123".into()),
378 ..Default::default()
379 };
380 let args = build_args(&opts);
381 let resume_idx = args.iter().position(|a| a == "resume").unwrap();
383 assert_eq!(args[resume_idx + 1], "tid-abc123");
384 }
385
386 #[test]
387 fn build_args_full_auto() {
388 let opts = RunOptions {
389 task: "fix bug".into(),
390 model: Some("o3".into()),
391 providers: Some(crate::types::ProviderOptions {
392 codex: Some(crate::types::CodexOptions {
393 approval_policy: Some("full-auto".into()),
394 sandbox_mode: Some("workspace-write".into()),
395 ..Default::default()
396 }),
397 ..Default::default()
398 }),
399 ..Default::default()
400 };
401 let args = build_args(&opts);
402 assert!(args.contains(&"--full-auto".to_string()));
403 assert!(args.contains(&"--sandbox".to_string()));
404 assert!(args.contains(&"--model".to_string()));
405 assert!(args.contains(&"o3".to_string()));
406 }
407
408 #[test]
409 fn build_args_full_auto_with_skip_permissions_no_conflict() {
410 let opts = RunOptions {
411 task: "fix bug".into(),
412 skip_permissions: true,
413 providers: Some(crate::types::ProviderOptions {
414 codex: Some(crate::types::CodexOptions {
415 approval_policy: Some("full-auto".into()),
416 ..Default::default()
417 }),
418 ..Default::default()
419 }),
420 ..Default::default()
421 };
422 let args = build_args(&opts);
423 assert!(args.contains(&"--full-auto".to_string()));
424 assert!(
425 !args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()),
426 "should not pass both --full-auto and --dangerously-bypass-approvals-and-sandbox"
427 );
428 assert!(args.contains(&"--skip-git-repo-check".to_string()));
429 }
430
431 #[tokio::test]
432 async fn write_configs_creates_mcp_config() {
433 let mut servers = HashMap::new();
434 servers.insert(
435 "test".into(),
436 crate::types::McpServer {
437 command: Some("test-server".into()),
438 args: Some(vec!["--flag".into()]),
439 ..Default::default()
440 },
441 );
442
443 let opts = RunOptions {
444 task: "hello".into(),
445 mcp_servers: Some(servers),
446 ..Default::default()
447 };
448
449 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
450 assert!(env.contains_key("CODEX_HOME"));
451 let tmp = tmp_dir.unwrap();
452
453 let config_path = tmp.path().join("config.toml");
454 let content = std::fs::read_to_string(&config_path).unwrap();
455 assert!(content.contains("[mcp_servers.test]"));
456 assert!(content.contains("test-server"));
457 }
458
459 #[tokio::test]
460 async fn write_configs_with_system_prompt() {
461 let opts = RunOptions {
462 task: "hello".into(),
463 system_prompt: Some("You are helpful.".into()),
464 ..Default::default()
465 };
466
467 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
468 assert!(env.contains_key("CODEX_HOME"));
469 let tmp = tmp_dir.unwrap();
470
471 let config_path = tmp.path().join("config.toml");
472 let content = std::fs::read_to_string(&config_path).unwrap();
473 assert!(content.contains("instructions"));
474 assert!(content.contains("You are helpful."));
475 }
476
477 #[tokio::test]
478 async fn write_configs_noop_when_empty() {
479 let opts = RunOptions {
480 task: "hello".into(),
481 ..Default::default()
482 };
483
484 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
485 assert!(env.is_empty());
486 assert!(tmp_dir.is_none());
487 }
488
489 #[test]
490 fn merge_config_preserves_existing_keys() {
491 let tmp = tempfile::tempdir().unwrap();
492 let path = tmp.path().join("config.toml");
493 std::fs::write(
494 &path,
495 r#"model = "o3"
496
497[mcp_servers.preexisting]
498command = "old-server"
499
500[other_table]
501foo = "bar"
502"#,
503 )
504 .unwrap();
505
506 let mut servers = HashMap::new();
507 servers.insert(
508 "test".into(),
509 CodexMcpServer {
510 command: Some("test-server".into()),
511 args: None,
512 env: None,
513 cwd: None,
514 tool_timeout_sec: None,
515 },
516 );
517
518 let config = CodexConfig {
519 instructions: Some("hello".into()),
520 mcp_servers: Some(servers),
521 };
522
523 let merged = merge_config(Some(&path), config).unwrap();
524
525 assert_eq!(merged.get("model").and_then(|v| v.as_str()), Some("o3"));
526 assert!(merged.get("other_table").is_some());
527 assert_eq!(
528 merged.get("instructions").and_then(|v| v.as_str()),
529 Some("hello")
530 );
531 let mcp = merged
534 .get("mcp_servers")
535 .and_then(|v| v.as_table())
536 .unwrap();
537 assert!(mcp.contains_key("test"));
538 assert!(!mcp.contains_key("preexisting"));
539 }
540
541 #[test]
542 fn merge_config_no_existing_file() {
543 let merged = merge_config(
544 None,
545 CodexConfig {
546 instructions: Some("hi".into()),
547 mcp_servers: None,
548 },
549 )
550 .unwrap();
551 assert_eq!(
552 merged.get("instructions").and_then(|v| v.as_str()),
553 Some("hi")
554 );
555 }
556
557 #[cfg(unix)]
558 #[test]
559 fn link_codex_home_skips_config_and_symlinks_rest() {
560 let src = tempfile::tempdir().unwrap();
561 std::fs::write(src.path().join("config.toml"), "model = \"old\"").unwrap();
562 std::fs::write(src.path().join("auth.json"), "{\"token\":\"x\"}").unwrap();
563 std::fs::create_dir(src.path().join("sessions")).unwrap();
564 std::fs::write(src.path().join("sessions").join("a.jsonl"), "{}").unwrap();
565
566 let dst = tempfile::tempdir().unwrap();
567 link_codex_home_contents(src.path(), dst.path()).unwrap();
568
569 assert!(!dst.path().join("config.toml").exists());
570 let auth = dst.path().join("auth.json");
571 assert!(auth.exists());
572 assert!(auth.symlink_metadata().unwrap().file_type().is_symlink());
573 let sessions = dst.path().join("sessions");
574 assert!(
575 sessions
576 .symlink_metadata()
577 .unwrap()
578 .file_type()
579 .is_symlink()
580 );
581 let contents =
583 std::fs::read_to_string(dst.path().join("sessions").join("a.jsonl")).unwrap();
584 assert_eq!(contents, "{}");
585 }
586
587 #[tokio::test]
588 async fn write_configs_system_prompt_file_takes_precedence() {
589 let fixture = tempfile::tempdir().unwrap();
590
591 let prompt_file = fixture.path().join("prompt.md");
593 std::fs::write(&prompt_file, "File prompt content").unwrap();
594
595 let opts = RunOptions {
596 task: "hello".into(),
597 system_prompt: Some("Inline prompt".into()),
598 system_prompt_file: Some(prompt_file.to_string_lossy().into_owned()),
599 ..Default::default()
600 };
601
602 let (env, tmp_dir) = write_configs(&opts).await.unwrap();
603 assert!(env.contains_key("CODEX_HOME"));
604 let tmp = tmp_dir.unwrap();
605
606 let config_path = tmp.path().join("config.toml");
607 let content = std::fs::read_to_string(&config_path).unwrap();
608 assert!(content.contains("File prompt content"));
609 assert!(!content.contains("Inline prompt"));
610 }
611
612 #[test]
613 fn build_args_extra_args_omitted_by_default() {
614 let opts = RunOptions {
615 task: "hello".into(),
616 ..Default::default()
617 };
618 let args = build_args(&opts);
619 assert!(!args.iter().any(|a| a == "--proto"));
620 }
621
622 #[test]
623 fn build_args_extra_args_appended_verbatim() {
624 let opts = RunOptions {
625 task: "hello".into(),
626 providers: Some(crate::types::ProviderOptions {
627 codex: Some(crate::types::CodexOptions {
628 extra_args: Some(vec!["--proto".into(), "json".into()]),
629 ..Default::default()
630 }),
631 ..Default::default()
632 }),
633 ..Default::default()
634 };
635 let args = build_args(&opts);
636 let idx = args
637 .iter()
638 .position(|a| a == "--proto")
639 .expect("extra_args flag emitted");
640 assert_eq!(args[idx + 1], "json");
641 }
642
643 #[test]
644 fn build_args_extra_args_come_after_crate_defaults() {
645 let opts = RunOptions {
648 task: "hello".into(),
649 skip_permissions: true,
650 providers: Some(crate::types::ProviderOptions {
651 codex: Some(crate::types::CodexOptions {
652 extra_args: Some(vec!["--skip-git-repo-check".into()]),
653 ..Default::default()
654 }),
655 ..Default::default()
656 }),
657 ..Default::default()
658 };
659 let args = build_args(&opts);
660 let positions: Vec<usize> = args
663 .iter()
664 .enumerate()
665 .filter_map(|(i, a)| (a == "--skip-git-repo-check").then_some(i))
666 .collect();
667 assert_eq!(
668 positions.len(),
669 2,
670 "both crate-emitted and user-supplied copies expected, got {args:?}"
671 );
672 assert!(positions[1] > positions[0]);
674 }
675}