Skip to main content

cli_agents/adapters/codex/
mod.rs

1mod 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        // Write temp config if MCP servers or system_prompt_file are set.
34        // Hold the TempDir so it lives until the child process exits.
35        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    // Resume a previous session if requested
97    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                    // Default Codex behavior — no flag needed
123                }
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    // Permission bypass for non-interactive use (opt-in).
152    // Skip if an explicit approval_policy is set — the two flags conflict.
153    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    // Programmatic callers often set cwd to a non-git directory.
161    if opts.skip_permissions {
162        args.push("--skip-git-repo-check".into());
163    }
164
165    // User-supplied flags last, so callers can override earlier defaults.
166    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// ── Codex TOML config types ──
174
175#[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
197/// Write temporary Codex config files for MCP servers and system prompts.
198///
199/// Codex reads MCP configuration from `config.toml` and system prompts from
200/// an `instructions` field in the same file. We write a temporary config and
201/// point Codex to it via `CODEX_HOME`.
202///
203/// Returns the env vars to set and the temp dir handle (must be kept alive
204/// until the child process exits).
205async 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
265/// Symlink top-level entries of `src` into `dst`, skipping `config.toml`
266/// (we write our own merged version). Symlinks avoid copying potentially
267/// large session/history directories on every run.
268fn 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
322/// Resolve the effective system prompt: `system_prompt_file` takes precedence
323/// over `system_prompt`.
324async 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        // Should be: exec resume <session_id> <task> --json
382        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        // mcp_servers replaced wholesale — pre-existing entries are dropped
532        // in favor of the caller-supplied set, which is the documented behavior.
533        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        // Reading through the symlink reaches the original file.
582        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        // Write a prompt file
592        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        // Callers should be able to override what this crate emits — same
646        // contract as the Claude adapter. User-supplied flags land last.
647        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        // The crate emits --skip-git-repo-check once for skip_permissions,
661        // then the user's extra copy. The user copy must be strictly later.
662        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        // Last occurrence is the user-supplied one.
673        assert!(positions[1] > positions[0]);
674    }
675}