cli_agents/adapters/codex/
mod.rs1mod 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 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 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 }
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 if opts.skip_permissions {
137 args.push("--dangerously-bypass-approvals-and-sandbox".into());
138 }
139
140 args
141}
142
143#[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
167async 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
228async 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 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 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}