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 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 } => Ok(RunResult {
65                success: !state.failed && exit_code == 0,
66                text: state.result_text,
67                exit_code: Some(exit_code),
68                stats: state.stats,
69                session_id: state.session_id,
70                stderr,
71                cost_usd: None,
72            }),
73        }
74    }
75}
76
77fn build_args(opts: &RunOptions) -> Vec<String> {
78    let mut args = vec!["exec".into()];
79
80    // Resume a previous session if requested
81    if let Some(session_id) = &opts.resume_session_id {
82        args.push("resume".into());
83        args.push(session_id.clone());
84    }
85
86    args.push(opts.task.clone());
87    args.push("--json".into());
88
89    if let Some(model) = &opts.model {
90        args.push("--model".into());
91        args.push(model.clone());
92    }
93
94    if let Some(cwd) = &opts.cwd {
95        args.push("--cd".into());
96        args.push(cwd.clone());
97    }
98
99    let codex_opts = opts.providers.as_ref().and_then(|p| p.codex.as_ref());
100
101    if let Some(co) = codex_opts {
102        if let Some(policy) = &co.approval_policy {
103            match policy.as_str() {
104                "full-auto" => args.push("--full-auto".into()),
105                "suggest" | "auto-edit" => {
106                    // Default Codex behavior — no flag needed
107                }
108                other => {
109                    warn!(policy = other, "unknown Codex approval policy, ignoring");
110                }
111            }
112        }
113        if let Some(sandbox) = &co.sandbox_mode {
114            args.push("--sandbox".into());
115            args.push(sandbox.clone());
116        }
117        if let Some(dirs) = &co.additional_directories {
118            for dir in dirs {
119                args.push("--cd".into());
120                args.push(dir.clone());
121            }
122        }
123        if let Some(images) = &co.images {
124            for img in images {
125                args.push("--image".into());
126                args.push(img.clone());
127            }
128        }
129        if let Some(schema) = &co.output_schema {
130            args.push("--output-schema".into());
131            args.push(schema.clone());
132        }
133    }
134
135    // Permission bypass for non-interactive use (opt-in)
136    if opts.skip_permissions {
137        args.push("--dangerously-bypass-approvals-and-sandbox".into());
138    }
139
140    args
141}
142
143// ── Codex TOML config types ──
144
145#[derive(Serialize)]
146struct CodexConfig {
147    #[serde(skip_serializing_if = "Option::is_none")]
148    instructions: Option<String>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    mcp_servers: Option<HashMap<String, CodexMcpServer>>,
151}
152
153#[derive(Serialize)]
154struct CodexMcpServer {
155    #[serde(skip_serializing_if = "Option::is_none")]
156    command: Option<String>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    args: Option<Vec<String>>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    env: Option<HashMap<String, String>>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    cwd: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    tool_timeout_sec: Option<u64>,
165}
166
167/// Write temporary Codex config files for MCP servers and system prompts.
168///
169/// Codex reads MCP configuration from `config.toml` and system prompts from
170/// an `instructions` field in the same file. We write a temporary config and
171/// point Codex to it via `CODEX_HOME`.
172///
173/// Returns the env vars to set and the temp dir handle (must be kept alive
174/// until the child process exits).
175async fn write_configs(
176    opts: &RunOptions,
177) -> Result<(HashMap<String, String>, Option<tempfile::TempDir>)> {
178    let has_mcp = opts.mcp_servers.as_ref().is_some_and(|s| !s.is_empty());
179    let system_prompt = resolve_system_prompt(opts).await?;
180
181    if !has_mcp && system_prompt.is_none() {
182        return Ok((HashMap::new(), None));
183    }
184
185    let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
186    let codex_dir = tmp_dir.path().join(".codex");
187    tokio::fs::create_dir_all(&codex_dir)
188        .await
189        .map_err(Error::Io)?;
190
191    let config = CodexConfig {
192        instructions: system_prompt,
193        mcp_servers: opts.mcp_servers.as_ref().map(|servers| {
194            servers
195                .iter()
196                .map(|(name, s)| {
197                    (
198                        name.clone(),
199                        CodexMcpServer {
200                            command: s.command.clone(),
201                            args: s.args.clone(),
202                            env: s.env.clone(),
203                            cwd: s.cwd.clone(),
204                            tool_timeout_sec: s.timeout,
205                        },
206                    )
207                })
208                .collect()
209        }),
210    };
211
212    let toml_str = toml::to_string_pretty(&config)
213        .map_err(|e| Error::Other(format!("TOML serialization: {e}")))?;
214
215    let config_path = codex_dir.join("config.toml");
216    tokio::fs::write(&config_path, toml_str)
217        .await
218        .map_err(Error::Io)?;
219
220    let mut env = HashMap::new();
221    env.insert(
222        "CODEX_HOME".into(),
223        tmp_dir.path().to_string_lossy().into_owned(),
224    );
225    Ok((env, Some(tmp_dir)))
226}
227
228/// Resolve the effective system prompt: `system_prompt_file` takes precedence
229/// over `system_prompt`.
230async fn resolve_system_prompt(opts: &RunOptions) -> Result<Option<String>> {
231    if let Some(path) = &opts.system_prompt_file {
232        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
233            Error::Process(format!("failed to read system prompt file {path}: {e}"))
234        })?;
235        Ok(Some(content))
236    } else {
237        Ok(opts.system_prompt.clone())
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn build_args_minimal() {
247        let opts = RunOptions {
248            task: "hello".into(),
249            ..Default::default()
250        };
251        let args = build_args(&opts);
252        assert!(args.contains(&"exec".to_string()));
253        assert!(args.contains(&"hello".to_string()));
254        assert!(args.contains(&"--json".to_string()));
255    }
256
257    #[test]
258    fn build_args_no_permission_bypass_by_default() {
259        let opts = RunOptions {
260            task: "hello".into(),
261            ..Default::default()
262        };
263        let args = build_args(&opts);
264        assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
265    }
266
267    #[test]
268    fn build_args_permission_bypass_when_opted_in() {
269        let opts = RunOptions {
270            task: "hello".into(),
271            skip_permissions: true,
272            ..Default::default()
273        };
274        let args = build_args(&opts);
275        assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
276    }
277
278    #[test]
279    fn build_args_resume_session() {
280        let opts = RunOptions {
281            task: "continue working".into(),
282            resume_session_id: Some("tid-abc123".into()),
283            ..Default::default()
284        };
285        let args = build_args(&opts);
286        // Should be: exec resume <session_id> <task> --json
287        let resume_idx = args.iter().position(|a| a == "resume").unwrap();
288        assert_eq!(args[resume_idx + 1], "tid-abc123");
289    }
290
291    #[test]
292    fn build_args_full_auto() {
293        let opts = RunOptions {
294            task: "fix bug".into(),
295            model: Some("o3".into()),
296            providers: Some(crate::types::ProviderOptions {
297                codex: Some(crate::types::CodexOptions {
298                    approval_policy: Some("full-auto".into()),
299                    sandbox_mode: Some("workspace-write".into()),
300                    ..Default::default()
301                }),
302                ..Default::default()
303            }),
304            ..Default::default()
305        };
306        let args = build_args(&opts);
307        assert!(args.contains(&"--full-auto".to_string()));
308        assert!(args.contains(&"--sandbox".to_string()));
309        assert!(args.contains(&"--model".to_string()));
310        assert!(args.contains(&"o3".to_string()));
311    }
312
313    #[tokio::test]
314    async fn write_configs_creates_mcp_config() {
315        let mut servers = HashMap::new();
316        servers.insert(
317            "test".into(),
318            crate::types::McpServer {
319                command: Some("test-server".into()),
320                args: Some(vec!["--flag".into()]),
321                ..Default::default()
322            },
323        );
324
325        let opts = RunOptions {
326            task: "hello".into(),
327            mcp_servers: Some(servers),
328            ..Default::default()
329        };
330
331        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
332        assert!(env.contains_key("CODEX_HOME"));
333        let tmp = tmp_dir.unwrap();
334
335        let config_path = tmp.path().join(".codex/config.toml");
336        let content = std::fs::read_to_string(&config_path).unwrap();
337        assert!(content.contains("[mcp_servers.test]"));
338        assert!(content.contains("test-server"));
339    }
340
341    #[tokio::test]
342    async fn write_configs_with_system_prompt() {
343        let opts = RunOptions {
344            task: "hello".into(),
345            system_prompt: Some("You are helpful.".into()),
346            ..Default::default()
347        };
348
349        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
350        assert!(env.contains_key("CODEX_HOME"));
351        let tmp = tmp_dir.unwrap();
352
353        let config_path = tmp.path().join(".codex/config.toml");
354        let content = std::fs::read_to_string(&config_path).unwrap();
355        assert!(content.contains("instructions"));
356        assert!(content.contains("You are helpful."));
357    }
358
359    #[tokio::test]
360    async fn write_configs_noop_when_empty() {
361        let opts = RunOptions {
362            task: "hello".into(),
363            ..Default::default()
364        };
365
366        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
367        assert!(env.is_empty());
368        assert!(tmp_dir.is_none());
369    }
370
371    #[tokio::test]
372    async fn write_configs_system_prompt_file_takes_precedence() {
373        let fixture = tempfile::tempdir().unwrap();
374
375        // Write a prompt file
376        let prompt_file = fixture.path().join("prompt.md");
377        std::fs::write(&prompt_file, "File prompt content").unwrap();
378
379        let opts = RunOptions {
380            task: "hello".into(),
381            system_prompt: Some("Inline prompt".into()),
382            system_prompt_file: Some(prompt_file.to_string_lossy().into_owned()),
383            ..Default::default()
384        };
385
386        let (env, tmp_dir) = write_configs(&opts).await.unwrap();
387        assert!(env.contains_key("CODEX_HOME"));
388        let tmp = tmp_dir.unwrap();
389
390        let config_path = tmp.path().join(".codex/config.toml");
391        let content = std::fs::read_to_string(&config_path).unwrap();
392        assert!(content.contains("File prompt content"));
393        assert!(!content.contains("Inline prompt"));
394    }
395}