Skip to main content

cli_agents/adapters/codex/
mod.rs

1mod 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        // 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 { 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    // Resume a previous session if requested
91    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                    // Default Codex behavior — no flag needed
117                }
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    // Permission bypass for non-interactive use (opt-in).
146    // Skip if an explicit approval_policy is set — the two flags conflict.
147    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    // Programmatic callers often set cwd to a non-git directory.
155    if opts.skip_permissions {
156        args.push("--skip-git-repo-check".into());
157    }
158
159    args
160}
161
162// ── Codex TOML config types ──
163
164#[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
186/// Write temporary Codex config files for MCP servers and system prompts.
187///
188/// Codex reads MCP configuration from `config.toml` and system prompts from
189/// an `instructions` field in the same file. We write a temporary config and
190/// point Codex to it via `CODEX_HOME`.
191///
192/// Returns the env vars to set and the temp dir handle (must be kept alive
193/// until the child process exits).
194async 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
254/// Symlink top-level entries of `src` into `dst`, skipping `config.toml`
255/// (we write our own merged version). Symlinks avoid copying potentially
256/// large session/history directories on every run.
257fn 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
311/// Resolve the effective system prompt: `system_prompt_file` takes precedence
312/// over `system_prompt`.
313async 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        // Should be: exec resume <session_id> <task> --json
371        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        // mcp_servers replaced wholesale — pre-existing entries are dropped
521        // in favor of the caller-supplied set, which is the documented behavior.
522        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        // Reading through the symlink reaches the original file.
562        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        // Write a prompt file
572        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}