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                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    // Resume a previous session if requested
99    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                    // Default Codex behavior — no flag needed
125                }
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    // Permission bypass for non-interactive use (opt-in).
154    // Skip if an explicit approval_policy is set — the two flags conflict.
155    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    // Programmatic callers often set cwd to a non-git directory.
163    if opts.skip_permissions {
164        args.push("--skip-git-repo-check".into());
165    }
166
167    // User-supplied flags last, so callers can override earlier defaults.
168    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// ── Codex TOML config types ──
176
177#[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
199/// Write temporary Codex config files for MCP servers and system prompts.
200///
201/// Codex reads MCP configuration from `config.toml` and system prompts from
202/// an `instructions` field in the same file. We write a temporary config and
203/// point Codex to it via `CODEX_HOME`.
204///
205/// Returns the env vars to set and the temp dir handle (must be kept alive
206/// until the child process exits).
207async 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
267/// Symlink top-level entries of `src` into `dst`, skipping `config.toml`
268/// (we write our own merged version). Symlinks avoid copying potentially
269/// large session/history directories on every run.
270fn 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
324/// Resolve the effective system prompt: `system_prompt_file` takes precedence
325/// over `system_prompt`.
326async 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        // Should be: exec resume <session_id> <task> --json
384        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        // mcp_servers replaced wholesale — pre-existing entries are dropped
534        // in favor of the caller-supplied set, which is the documented behavior.
535        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        // Reading through the symlink reaches the original file.
584        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        // Write a prompt file
594        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        // Callers should be able to override what this crate emits — same
648        // contract as the Claude adapter. User-supplied flags land last.
649        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        // The crate emits --skip-git-repo-check once for skip_permissions,
663        // then the user's extra copy. The user copy must be strictly later.
664        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        // Last occurrence is the user-supplied one.
675        assert!(positions[1] > positions[0]);
676    }
677}