Skip to main content

agent_tools/special/
codex.rs

1use crate::error::Result;
2use crate::{CmdOutput, CmdRequest, CmdStdin, CmdTool};
3use std::path::PathBuf;
4
5pub struct CodexTool;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SandboxMode {
9    ReadOnly,
10    WorkspaceWrite,
11    DangerFullAccess,
12}
13
14impl SandboxMode {
15    fn as_str(self) -> &'static str {
16        match self {
17            SandboxMode::ReadOnly => "read-only",
18            SandboxMode::WorkspaceWrite => "workspace-write",
19            SandboxMode::DangerFullAccess => "danger-full-access",
20        }
21    }
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum OssProvider {
26    LmStudio,
27    Ollama,
28}
29
30impl OssProvider {
31    fn as_str(self) -> &'static str {
32        match self {
33            OssProvider::LmStudio => "lmstudio",
34            OssProvider::Ollama => "ollama",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ColorMode {
41    Always,
42    Never,
43    Auto,
44}
45
46impl ColorMode {
47    fn as_str(self) -> &'static str {
48        match self {
49            ColorMode::Always => "always",
50            ColorMode::Never => "never",
51            ColorMode::Auto => "auto",
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct CodexRequest {
58    pub task: Option<String>,
59    pub stdin: Option<CmdStdin>,
60    pub timeout_ms: Option<u64>,
61    pub fail_on_non_zero: bool,
62    pub background: bool,
63    pub dangerously_bypass_approvals_and_sandbox: bool,
64    pub options: CodexOptions,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct CodexOptions {
69    pub search: bool,
70    pub config: Vec<String>,
71    pub enable: Vec<String>,
72    pub disable: Vec<String>,
73    pub images: Vec<PathBuf>,
74    pub model: Option<String>,
75    pub oss: bool,
76    pub local_provider: Option<OssProvider>,
77    pub sandbox: Option<SandboxMode>,
78    pub profile: Option<String>,
79    pub full_auto: bool,
80    pub dangerously_bypass_approvals_and_sandbox: bool,
81    pub cd: Option<String>,
82    pub skip_git_repo_check: bool,
83    pub add_dirs: Vec<String>,
84    pub ephemeral: bool,
85    pub output_schema: Option<String>,
86    pub color: Option<ColorMode>,
87    pub progress_cursor: bool,
88    pub json: bool,
89    pub output_last_message: Option<String>,
90}
91
92impl CodexRequest {
93    pub fn new(task: impl Into<String>) -> Self {
94        Self {
95            task: Some(task.into()),
96            stdin: None,
97            timeout_ms: None,
98            fail_on_non_zero: true,
99            background: false,
100            dangerously_bypass_approvals_and_sandbox: false,
101            options: CodexOptions::default(),
102        }
103    }
104
105    pub fn from_stdin(stdin: CmdStdin) -> Self {
106        Self {
107            task: None,
108            stdin: Some(stdin),
109            timeout_ms: None,
110            fail_on_non_zero: true,
111            background: false,
112            dangerously_bypass_approvals_and_sandbox: false,
113            options: CodexOptions::default(),
114        }
115    }
116}
117
118impl CodexTool {
119    pub fn exec(req: CodexRequest) -> Result<CmdOutput> {
120        Self::run_args(
121            build_exec_args(&req),
122            req.stdin.clone(),
123            req.timeout_ms,
124            req.fail_on_non_zero,
125            req.background,
126        )
127    }
128
129    fn run_args(
130        args: Vec<String>,
131        stdin: Option<CmdStdin>,
132        timeout_ms: Option<u64>,
133        fail_on_non_zero: bool,
134        background: bool,
135    ) -> Result<CmdOutput> {
136        CmdTool::run(CmdRequest {
137            program: "codex".to_string(),
138            args,
139            cwd: None,
140            env: None,
141            timeout_ms,
142            fail_on_non_zero,
143            stdin,
144            background,
145        })
146    }
147}
148
149fn build_exec_args(req: &CodexRequest) -> Vec<String> {
150    let mut args = Vec::new();
151    push_common_args(
152        &mut args,
153        req.options.search,
154        &req.options.config,
155        &req.options.enable,
156        &req.options.disable,
157        &req.options.images,
158        req.options.model.as_deref(),
159        req.options.oss,
160        req.options.local_provider,
161        req.options.sandbox,
162        req.options.profile.as_deref(),
163        req.options.full_auto,
164        req.dangerously_bypass_approvals_and_sandbox
165            || req.options.dangerously_bypass_approvals_and_sandbox,
166        req.options.cd.as_deref(),
167        &req.options.add_dirs,
168    );
169    args.push("exec".to_string());
170
171    if req.options.skip_git_repo_check {
172        args.push("--skip-git-repo-check".to_string());
173    }
174    if req.options.ephemeral {
175        args.push("--ephemeral".to_string());
176    }
177    if let Some(output_schema) = &req.options.output_schema {
178        args.push("--output-schema".to_string());
179        args.push(output_schema.clone());
180    }
181    if let Some(color) = req.options.color {
182        args.push("--color".to_string());
183        args.push(color.as_str().to_string());
184    }
185    if req.options.progress_cursor {
186        args.push("--progress-cursor".to_string());
187    }
188    if req.options.json {
189        args.push("--json".to_string());
190    }
191    if let Some(output_last_message) = &req.options.output_last_message {
192        args.push("--output-last-message".to_string());
193        args.push(output_last_message.clone());
194    }
195
196    push_prompt_arg(&mut args, req.task.as_deref(), req.stdin.as_ref());
197    args
198}
199
200#[allow(clippy::too_many_arguments)]
201fn push_common_args(
202    args: &mut Vec<String>,
203    search: bool,
204    config: &[String],
205    enable: &[String],
206    disable: &[String],
207    images: &[PathBuf],
208    model: Option<&str>,
209    oss: bool,
210    local_provider: Option<OssProvider>,
211    sandbox: Option<SandboxMode>,
212    profile: Option<&str>,
213    full_auto: bool,
214    dangerously_bypass_approvals_and_sandbox: bool,
215    cd: Option<&str>,
216    add_dirs: &[String],
217) {
218    if search {
219        args.push("--search".to_string());
220    }
221
222    for entry in config {
223        args.push("-c".to_string());
224        args.push(entry.clone());
225    }
226    for feature in enable {
227        args.push("--enable".to_string());
228        args.push(feature.clone());
229    }
230    for feature in disable {
231        args.push("--disable".to_string());
232        args.push(feature.clone());
233    }
234    for image in images {
235        args.push("--image".to_string());
236        args.push(image.display().to_string());
237    }
238    if let Some(model) = model {
239        args.push("--model".to_string());
240        args.push(model.to_string());
241    }
242    if oss {
243        args.push("--oss".to_string());
244    }
245    if let Some(local_provider) = local_provider {
246        args.push("--local-provider".to_string());
247        args.push(local_provider.as_str().to_string());
248    }
249    if let Some(sandbox) = sandbox {
250        args.push("--sandbox".to_string());
251        args.push(sandbox.as_str().to_string());
252    }
253    if let Some(profile) = profile {
254        args.push("--profile".to_string());
255        args.push(profile.to_string());
256    }
257    if full_auto {
258        args.push("--full-auto".to_string());
259    }
260    if dangerously_bypass_approvals_and_sandbox {
261        args.push("--dangerously-bypass-approvals-and-sandbox".to_string());
262    }
263    if let Some(cd) = cd {
264        args.push("--cd".to_string());
265        args.push(cd.to_string());
266    }
267    for dir in add_dirs {
268        args.push("--add-dir".to_string());
269        args.push(dir.clone());
270    }
271}
272
273fn push_prompt_arg(args: &mut Vec<String>, prompt: Option<&str>, stdin: Option<&CmdStdin>) {
274    if let Some(prompt) = prompt {
275        args.push(prompt.to_string());
276        return;
277    }
278
279    if stdin.is_some() {
280        args.push("-".to_string());
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_build_exec_args_with_structured_options() {
290        let req = CodexRequest {
291            task: Some("run tests".to_string()),
292            stdin: None,
293            timeout_ms: Some(10_000),
294            fail_on_non_zero: true,
295            background: false,
296            dangerously_bypass_approvals_and_sandbox: true,
297            options: CodexOptions {
298                search: true,
299                config: vec!["model=\"gpt-5\"".to_string()],
300                enable: vec!["fast_mode".to_string()],
301                disable: vec!["slow_mode".to_string()],
302                images: vec![PathBuf::from("/tmp/a.png")],
303                model: Some("gpt-5".to_string()),
304                oss: true,
305                local_provider: Some(OssProvider::Ollama),
306                sandbox: Some(SandboxMode::WorkspaceWrite),
307                profile: Some("default".to_string()),
308                full_auto: true,
309                dangerously_bypass_approvals_and_sandbox: false,
310                cd: Some("/tmp/work".to_string()),
311                skip_git_repo_check: true,
312                add_dirs: vec!["/tmp/extra".to_string()],
313                ephemeral: true,
314                output_schema: Some("/tmp/schema.json".to_string()),
315                color: Some(ColorMode::Never),
316                progress_cursor: true,
317                json: true,
318                output_last_message: Some("/tmp/out.txt".to_string()),
319            },
320        };
321
322        let args = build_exec_args(&req);
323        assert_eq!(args[0], "--search");
324        assert!(args.iter().any(|x| x == "exec"));
325        assert!(args.contains(&"--skip-git-repo-check".to_string()));
326        assert!(args.contains(&"--ephemeral".to_string()));
327        assert!(args.contains(&"--json".to_string()));
328        assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
329        assert!(args.contains(&"run tests".to_string()));
330    }
331
332    #[test]
333    fn test_build_exec_args_uses_stdin_marker_without_prompt() {
334        let req = CodexRequest {
335            stdin: Some(CmdStdin::Text("hello".to_string())),
336            ..CodexRequest::default()
337        };
338
339        let args = build_exec_args(&req);
340        assert_eq!(args, vec!["exec".to_string(), "-".to_string()]);
341    }
342
343    #[test]
344    fn test_new_request_defaults_to_simple_task() {
345        let req = CodexRequest::new("fix tests");
346        let args = build_exec_args(&req);
347
348        assert_eq!(args, vec!["exec".to_string(), "fix tests".to_string()]);
349        assert!(req.fail_on_non_zero);
350        assert!(!req.background);
351        assert!(!req.options.search);
352    }
353
354    #[test]
355    fn test_top_level_dangerous_flag_is_applied() {
356        let mut req = CodexRequest::new("do work");
357        req.dangerously_bypass_approvals_and_sandbox = true;
358
359        let args = build_exec_args(&req);
360        assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
361    }
362}