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 tokio_util::sync::CancellationToken;
12use tracing::warn;
13
14pub struct CodexAdapter;
15
16impl CliAdapter for CodexAdapter {
17    fn name(&self) -> CliName {
18        CliName::Codex
19    }
20
21    async fn run(
22        &self,
23        opts: &RunOptions,
24        emit: &(dyn Fn(StreamEvent) + Send + Sync),
25        cancel: CancellationToken,
26    ) -> Result<RunResult> {
27        let binary = match &opts.executable_path {
28            Some(p) => p.clone(),
29            None => discover_binary(CliName::Codex).await.ok_or(Error::NoCli)?,
30        };
31
32        // Write temp config if MCP servers or system_prompt_file are set.
33        // Hold the TempDir so it lives until the child process exits.
34        let (config_env, _tmp_dir) = write_configs(opts).await?;
35
36        let args = build_args(opts);
37        let mut extra_env = opts.env.clone().unwrap_or_default();
38        extra_env.extend(config_env);
39        let max_bytes = opts.max_output_bytes.unwrap_or(DEFAULT_MAX_OUTPUT_BYTES);
40
41        let mut state = parse::ParseState::default();
42        let mut text_tracker: HashMap<String, String> = HashMap::new();
43
44        let outcome = crate::adapters::spawn_and_stream(
45            crate::adapters::SpawnParams {
46                cli_label: "codex",
47                binary: &binary,
48                args: &args,
49                extra_env: &extra_env,
50                cwd: opts.cwd.as_deref().unwrap_or("."),
51                max_bytes,
52                cancel: &cancel,
53            },
54            |line| parse::parse_line(line, &mut state, &mut text_tracker, emit),
55        )
56        .await?;
57
58        match outcome {
59            crate::adapters::SpawnOutcome::Cancelled => Ok(RunResult {
60                success: false,
61                text: Some("Cancelled.".into()),
62                ..Default::default()
63            }),
64            crate::adapters::SpawnOutcome::Done { exit_code, stderr } => {
65                let success = !state.failed && exit_code == 0;
66                let text = if !success && state.result_text.is_none() {
67                    crate::adapters::extract_error_message(stderr.as_deref())
68                } else {
69                    state.result_text
70                };
71                Ok(RunResult {
72                    success,
73                    text,
74                    exit_code: Some(exit_code),
75                    stats: state.stats,
76                    session_id: state.session_id,
77                    stderr,
78                    cost_usd: None,
79                })
80            }
81        }
82    }
83}
84
85fn build_args(opts: &RunOptions) -> Vec<String> {
86    let mut args = vec!["exec".into()];
87
88    // Resume a previous session if requested
89    if let Some(session_id) = &opts.resume_session_id {
90        args.push("resume".into());
91        args.push(session_id.clone());
92    }
93
94    args.push(opts.task.clone());
95    args.push("--json".into());
96
97    if let Some(model) = &opts.model {
98        args.push("--model".into());
99        args.push(model.clone());
100    }
101
102    if let Some(cwd) = &opts.cwd {
103        args.push("-C".into());
104        args.push(cwd.clone());
105    }
106
107    let codex_opts = opts.providers.as_ref().and_then(|p| p.codex.as_ref());
108
109    if let Some(co) = codex_opts {
110        if let Some(policy) = &co.approval_policy {
111            match policy.as_str() {
112                "full-auto" => args.push("--full-auto".into()),
113                "suggest" | "auto-edit" => {
114                    // Default Codex behavior — no flag needed
115                }
116                other => {
117                    warn!(policy = other, "unknown Codex approval policy, ignoring");
118                }
119            }
120        }
121        if let Some(sandbox) = &co.sandbox_mode {
122            args.push("--sandbox".into());
123            args.push(sandbox.clone());
124        }
125        if let Some(dirs) = &co.additional_directories {
126            for dir in dirs {
127                args.push("-C".into());
128                args.push(dir.clone());
129            }
130        }
131        if let Some(images) = &co.images {
132            for img in images {
133                args.push("--image".into());
134                args.push(img.clone());
135            }
136        }
137        if let Some(schema) = &co.output_schema {
138            args.push("--output-schema".into());
139            args.push(schema.clone());
140        }
141    }
142
143    // Permission bypass for non-interactive use (opt-in).
144    // Skip if an explicit approval_policy is set — the two flags conflict.
145    let has_policy = codex_opts
146        .and_then(|c| c.approval_policy.as_deref())
147        .is_some_and(|p| !p.is_empty());
148    if opts.skip_permissions && !has_policy {
149        args.push("--dangerously-bypass-approvals-and-sandbox".into());
150    }
151
152    // Programmatic callers often set cwd to a non-git directory.
153    if opts.skip_permissions {
154        args.push("--skip-git-repo-check".into());
155    }
156
157    args
158}
159
160// ── Codex TOML config types ──
161
162#[derive(Serialize)]
163struct CodexConfig {
164    #[serde(skip_serializing_if = "Option::is_none")]
165    instructions: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    mcp_servers: Option<HashMap<String, CodexMcpServer>>,
168}
169
170#[derive(Serialize)]
171struct CodexMcpServer {
172    #[serde(skip_serializing_if = "Option::is_none")]
173    command: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    args: Option<Vec<String>>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    env: Option<HashMap<String, String>>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    cwd: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    tool_timeout_sec: Option<u64>,
182}
183
184/// Write temporary Codex config files for MCP servers and system prompts.
185///
186/// Codex reads MCP configuration from `config.toml` and system prompts from
187/// an `instructions` field in the same file. We write a temporary config and
188/// point Codex to it via `CODEX_HOME`.
189///
190/// Returns the env vars to set and the temp dir handle (must be kept alive
191/// until the child process exits).
192async fn write_configs(
193    opts: &RunOptions,
194) -> Result<(HashMap<String, String>, Option<tempfile::TempDir>)> {
195    let has_mcp = opts.mcp_servers.as_ref().is_some_and(|s| !s.is_empty());
196    let system_prompt = resolve_system_prompt(opts).await?;
197
198    if !has_mcp && system_prompt.is_none() {
199        return Ok((HashMap::new(), None));
200    }
201
202    let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
203    let codex_dir = tmp_dir.path().join(".codex");
204    tokio::fs::create_dir_all(&codex_dir)
205        .await
206        .map_err(Error::Io)?;
207
208    let config = CodexConfig {
209        instructions: system_prompt,
210        mcp_servers: opts.mcp_servers.as_ref().map(|servers| {
211            servers
212                .iter()
213                .map(|(name, s)| {
214                    (
215                        name.clone(),
216                        CodexMcpServer {
217                            command: s.command.clone(),
218                            args: s.args.clone(),
219                            env: s.env.clone(),
220                            cwd: s.cwd.clone(),
221                            tool_timeout_sec: s.timeout,
222                        },
223                    )
224                })
225                .collect()
226        }),
227    };
228
229    let toml_str = toml::to_string_pretty(&config)
230        .map_err(|e| Error::Other(format!("TOML serialization: {e}")))?;
231
232    let config_path = codex_dir.join("config.toml");
233    tokio::fs::write(&config_path, toml_str)
234        .await
235        .map_err(Error::Io)?;
236
237    let mut env = HashMap::new();
238    env.insert(
239        "CODEX_HOME".into(),
240        tmp_dir.path().to_string_lossy().into_owned(),
241    );
242    Ok((env, Some(tmp_dir)))
243}
244
245/// Resolve the effective system prompt: `system_prompt_file` takes precedence
246/// over `system_prompt`.
247async fn resolve_system_prompt(opts: &RunOptions) -> Result<Option<String>> {
248    if let Some(path) = &opts.system_prompt_file {
249        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
250            Error::Process(format!("failed to read system prompt file {path}: {e}"))
251        })?;
252        Ok(Some(content))
253    } else {
254        Ok(opts.system_prompt.clone())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn build_args_minimal() {
264        let opts = RunOptions {
265            task: "hello".into(),
266            ..Default::default()
267        };
268        let args = build_args(&opts);
269        assert!(args.contains(&"exec".to_string()));
270        assert!(args.contains(&"hello".to_string()));
271        assert!(args.contains(&"--json".to_string()));
272    }
273
274    #[test]
275    fn build_args_no_permission_bypass_by_default() {
276        let opts = RunOptions {
277            task: "hello".into(),
278            ..Default::default()
279        };
280        let args = build_args(&opts);
281        assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
282    }
283
284    #[test]
285    fn build_args_permission_bypass_when_opted_in() {
286        let opts = RunOptions {
287            task: "hello".into(),
288            skip_permissions: true,
289            ..Default::default()
290        };
291        let args = build_args(&opts);
292        assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
293        assert!(args.contains(&"--skip-git-repo-check".to_string()));
294    }
295
296    #[test]
297    fn build_args_resume_session() {
298        let opts = RunOptions {
299            task: "continue working".into(),
300            resume_session_id: Some("tid-abc123".into()),
301            ..Default::default()
302        };
303        let args = build_args(&opts);
304        // Should be: exec resume <session_id> <task> --json
305        let resume_idx = args.iter().position(|a| a == "resume").unwrap();
306        assert_eq!(args[resume_idx + 1], "tid-abc123");
307    }
308
309    #[test]
310    fn build_args_full_auto() {
311        let opts = RunOptions {
312            task: "fix bug".into(),
313            model: Some("o3".into()),
314            providers: Some(crate::types::ProviderOptions {
315                codex: Some(crate::types::CodexOptions {
316                    approval_policy: Some("full-auto".into()),
317                    sandbox_mode: Some("workspace-write".into()),
318                    ..Default::default()
319                }),
320                ..Default::default()
321            }),
322            ..Default::default()
323        };
324        let args = build_args(&opts);
325        assert!(args.contains(&"--full-auto".to_string()));
326        assert!(args.contains(&"--sandbox".to_string()));
327        assert!(args.contains(&"--model".to_string()));
328        assert!(args.contains(&"o3".to_string()));
329    }
330
331    #[test]
332    fn build_args_full_auto_with_skip_permissions_no_conflict() {
333        let opts = RunOptions {
334            task: "fix bug".into(),
335            skip_permissions: true,
336            providers: Some(crate::types::ProviderOptions {
337                codex: Some(crate::types::CodexOptions {
338                    approval_policy: Some("full-auto".into()),
339                    ..Default::default()
340                }),
341                ..Default::default()
342            }),
343            ..Default::default()
344        };
345        let args = build_args(&opts);
346        assert!(args.contains(&"--full-auto".to_string()));
347        assert!(
348            !args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()),
349            "should not pass both --full-auto and --dangerously-bypass-approvals-and-sandbox"
350        );
351        assert!(args.contains(&"--skip-git-repo-check".to_string()));
352    }
353
354    #[tokio::test]
355    async fn write_configs_creates_mcp_config() {
356        let mut servers = HashMap::new();
357        servers.insert(
358            "test".into(),
359            crate::types::McpServer {
360                command: Some("test-server".into()),
361                args: Some(vec!["--flag".into()]),
362                ..Default::default()
363            },
364        );
365
366        let opts = RunOptions {
367            task: "hello".into(),
368            mcp_servers: Some(servers),
369            ..Default::default()
370        };
371
372        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
373        assert!(env.contains_key("CODEX_HOME"));
374        let tmp = tmp_dir.unwrap();
375
376        let config_path = tmp.path().join(".codex/config.toml");
377        let content = std::fs::read_to_string(&config_path).unwrap();
378        assert!(content.contains("[mcp_servers.test]"));
379        assert!(content.contains("test-server"));
380    }
381
382    #[tokio::test]
383    async fn write_configs_with_system_prompt() {
384        let opts = RunOptions {
385            task: "hello".into(),
386            system_prompt: Some("You are helpful.".into()),
387            ..Default::default()
388        };
389
390        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
391        assert!(env.contains_key("CODEX_HOME"));
392        let tmp = tmp_dir.unwrap();
393
394        let config_path = tmp.path().join(".codex/config.toml");
395        let content = std::fs::read_to_string(&config_path).unwrap();
396        assert!(content.contains("instructions"));
397        assert!(content.contains("You are helpful."));
398    }
399
400    #[tokio::test]
401    async fn write_configs_noop_when_empty() {
402        let opts = RunOptions {
403            task: "hello".into(),
404            ..Default::default()
405        };
406
407        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
408        assert!(env.is_empty());
409        assert!(tmp_dir.is_none());
410    }
411
412    #[tokio::test]
413    async fn write_configs_system_prompt_file_takes_precedence() {
414        let fixture = tempfile::tempdir().unwrap();
415
416        // Write a prompt file
417        let prompt_file = fixture.path().join("prompt.md");
418        std::fs::write(&prompt_file, "File prompt content").unwrap();
419
420        let opts = RunOptions {
421            task: "hello".into(),
422            system_prompt: Some("Inline prompt".into()),
423            system_prompt_file: Some(prompt_file.to_string_lossy().into_owned()),
424            ..Default::default()
425        };
426
427        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
428        assert!(env.contains_key("CODEX_HOME"));
429        let tmp = tmp_dir.unwrap();
430
431        let config_path = tmp.path().join(".codex/config.toml");
432        let content = std::fs::read_to_string(&config_path).unwrap();
433        assert!(content.contains("File prompt content"));
434        assert!(!content.contains("Inline prompt"));
435    }
436}