zeroclaw_tools/
codex_cli.rs1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use std::time::Duration;
5use tokio::process::Command;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8use zeroclaw_config::policy::ToolOperation;
9use zeroclaw_config::schema::CodexCliConfig;
10
11const SAFE_ENV_VARS: &[&str] = &[
13 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16pub struct CodexCliTool {
25 security: Arc<SecurityPolicy>,
26 config: CodexCliConfig,
27}
28
29impl CodexCliTool {
30 pub fn new(security: Arc<SecurityPolicy>, config: CodexCliConfig) -> Self {
31 Self { security, config }
32 }
33}
34
35#[async_trait]
36impl Tool for CodexCliTool {
37 fn name(&self) -> &str {
38 "codex_cli"
39 }
40
41 fn description(&self) -> &str {
42 "Delegate a coding task to Codex CLI (codex exec). Supports file editing and bash execution. Use for complex coding work that benefits from Codex's full agent loop."
43 }
44
45 fn parameters_schema(&self) -> serde_json::Value {
46 json!({
47 "type": "object",
48 "properties": {
49 "prompt": {
50 "type": "string",
51 "description": "The coding task to delegate to Codex"
52 },
53 "working_directory": {
54 "type": "string",
55 "description": "Working directory within the workspace (must be inside workspace_dir)"
56 }
57 },
58 "required": ["prompt"]
59 })
60 }
61
62 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
63 if let Err(error) = self
68 .security
69 .enforce_tool_operation(ToolOperation::Act, "codex_cli")
70 {
71 return Ok(ToolResult {
72 success: false,
73 output: String::new(),
74 error: Some(error),
75 });
76 }
77
78 let prompt = args.get("prompt").and_then(|v| v.as_str()).ok_or_else(|| {
80 ::zeroclaw_log::record!(
81 WARN,
82 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
83 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
84 .with_attrs(::serde_json::json!({"param": "prompt"})),
85 "codex_cli: missing prompt parameter"
86 );
87 anyhow::Error::msg("Missing 'prompt' parameter")
88 })?;
89
90 let work_dir = if let Some(wd) = args.get("working_directory").and_then(|v| v.as_str()) {
95 let wd_path = std::path::PathBuf::from(wd);
96 let workspace = &self.security.workspace_dir;
97 let canonical_wd = match wd_path.canonicalize() {
98 Ok(p) => p,
99 Err(_) => {
100 return Ok(ToolResult {
101 success: false,
102 output: String::new(),
103 error: Some(format!(
104 "working_directory '{}' does not exist or is not accessible",
105 wd
106 )),
107 });
108 }
109 };
110 let canonical_ws = match workspace.canonicalize() {
111 Ok(p) => p,
112 Err(_) => {
113 return Ok(ToolResult {
114 success: false,
115 output: String::new(),
116 error: Some(format!(
117 "workspace directory '{}' does not exist or is not accessible",
118 workspace.display()
119 )),
120 });
121 }
122 };
123 if !canonical_wd.starts_with(&canonical_ws) {
124 return Ok(ToolResult {
125 success: false,
126 output: String::new(),
127 error: Some(format!(
128 "working_directory '{}' is outside the workspace '{}'",
129 wd,
130 workspace.display()
131 )),
132 });
133 }
134 canonical_wd
135 } else {
136 self.security.workspace_dir.clone()
137 };
138
139 let codex_bin = if cfg!(target_os = "windows") {
141 "codex.cmd"
142 } else {
143 "codex"
144 };
145 let mut cmd = Command::new(codex_bin);
146 cmd.arg("exec");
147
148 for arg in &self.config.extra_args {
150 let trimmed = arg.trim();
151 if !trimmed.is_empty() {
152 cmd.arg(trimmed);
153 }
154 }
155
156 cmd.arg(prompt);
157
158 cmd.env_clear();
160 for var in SAFE_ENV_VARS {
161 if let Ok(val) = std::env::var(var) {
162 cmd.env(var, val);
163 }
164 }
165 for var in &self.config.env_passthrough {
166 let trimmed = var.trim();
167 if !trimmed.is_empty()
168 && let Ok(val) = std::env::var(trimmed)
169 {
170 cmd.env(trimmed, val);
171 }
172 }
173
174 cmd.current_dir(&work_dir);
175 let timeout = Duration::from_secs(self.config.timeout_secs);
179 cmd.kill_on_drop(true);
180
181 let result = tokio::time::timeout(timeout, cmd.output()).await;
182
183 match result {
184 Ok(Ok(output)) => {
185 let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
186 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
187
188 if stdout.len() > self.config.max_output_bytes {
190 let mut b = self.config.max_output_bytes.min(stdout.len());
191 while b > 0 && !stdout.is_char_boundary(b) {
192 b -= 1;
193 }
194 stdout.truncate(b);
195 stdout.push_str("\n... [output truncated]");
196 }
197
198 Ok(ToolResult {
199 success: output.status.success(),
200 output: stdout,
201 error: if stderr.is_empty() {
202 None
203 } else {
204 Some(stderr)
205 },
206 })
207 }
208 Ok(Err(e)) => {
209 let err_msg = e.to_string();
210 let msg = if err_msg.contains("No such file or directory")
211 || err_msg.contains("not found")
212 || err_msg.contains("cannot find")
213 {
214 "Codex CLI ('codex') not found in PATH. Install with: npm install -g @openai/codex".into()
215 } else {
216 format!("Failed to execute codex: {e}")
217 };
218 Ok(ToolResult {
219 success: false,
220 output: String::new(),
221 error: Some(msg),
222 })
223 }
224 Err(_) => {
225 Ok(ToolResult {
228 success: false,
229 output: String::new(),
230 error: Some(format!(
231 "Codex CLI timed out after {}s and was killed",
232 self.config.timeout_secs
233 )),
234 })
235 }
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use zeroclaw_config::autonomy::AutonomyLevel;
244 use zeroclaw_config::policy::SecurityPolicy;
245 use zeroclaw_config::schema::CodexCliConfig;
246
247 fn test_config() -> CodexCliConfig {
248 CodexCliConfig::default()
249 }
250
251 fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
252 Arc::new(SecurityPolicy {
253 autonomy,
254 workspace_dir: std::env::temp_dir(),
255 ..SecurityPolicy::default()
256 })
257 }
258
259 #[test]
260 fn codex_cli_tool_name() {
261 let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
262 assert_eq!(tool.name(), "codex_cli");
263 }
264
265 #[test]
266 fn codex_cli_tool_schema_has_prompt() {
267 let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
268 let schema = tool.parameters_schema();
269 assert!(schema["properties"]["prompt"].is_object());
270 assert!(
271 schema["required"]
272 .as_array()
273 .expect("schema required should be an array")
274 .contains(&json!("prompt"))
275 );
276 assert!(schema["properties"]["working_directory"].is_object());
277 }
278
279 #[tokio::test]
280 async fn codex_cli_blocks_rate_limited() {
281 let security = Arc::new(SecurityPolicy {
282 autonomy: AutonomyLevel::Supervised,
283 max_actions_per_hour: 0,
284 workspace_dir: std::env::temp_dir(),
285 ..SecurityPolicy::default()
286 });
287 let tool = CodexCliTool::new(security, test_config());
288 let result = tool
289 .execute(json!({"prompt": "hello"}))
290 .await
291 .expect("rate-limited should return a result");
292 assert!(!result.success);
293 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
294 }
295
296 #[tokio::test]
297 async fn codex_cli_blocks_readonly() {
298 let tool = CodexCliTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
299 let result = tool
300 .execute(json!({"prompt": "hello"}))
301 .await
302 .expect("readonly should return a result");
303 assert!(!result.success);
304 assert!(
305 result
306 .error
307 .as_deref()
308 .unwrap_or("")
309 .contains("read-only mode")
310 );
311 }
312
313 #[tokio::test]
314 async fn codex_cli_missing_prompt_param() {
315 let tool = CodexCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
316 let result = tool.execute(json!({})).await;
317 assert!(result.is_err());
318 assert!(result.unwrap_err().to_string().contains("prompt"));
319 }
320
321 #[tokio::test]
322 async fn codex_cli_rejects_path_outside_workspace() {
323 let tool = CodexCliTool::new(test_security(AutonomyLevel::Full), test_config());
324 let result = tool
325 .execute(json!({
326 "prompt": "hello",
327 "working_directory": "/etc"
328 }))
329 .await
330 .expect("should return a result for path validation");
331 assert!(!result.success);
332 assert!(
333 result
334 .error
335 .as_deref()
336 .unwrap_or("")
337 .contains("outside the workspace")
338 );
339 }
340
341 #[test]
342 fn codex_cli_env_passthrough_defaults() {
343 let config = CodexCliConfig::default();
344 assert!(
345 config.env_passthrough.is_empty(),
346 "env_passthrough should default to empty"
347 );
348 }
349
350 #[test]
351 fn codex_cli_extra_args_defaults() {
352 let config = CodexCliConfig::default();
353 assert!(
354 config.extra_args.is_empty(),
355 "extra_args should default to empty"
356 );
357 }
358
359 #[test]
360 fn codex_cli_default_config_values() {
361 let config = CodexCliConfig::default();
362 assert!(!config.enabled);
363 assert_eq!(config.timeout_secs, 600);
364 assert_eq!(config.max_output_bytes, 2_097_152);
365 }
366}