cli_agents/adapters/codex/
mod.rs1mod 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 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 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 }
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 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 if opts.skip_permissions {
154 args.push("--skip-git-repo-check".into());
155 }
156
157 args
158}
159
160#[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
184async 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
245async 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 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 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}