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