zeroclaw_tools/
opencode_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::OpenCodeCliConfig;
10
11const SAFE_ENV_VARS: &[&str] = &[
13 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16pub struct OpenCodeCliTool {
25 security: Arc<SecurityPolicy>,
26 config: OpenCodeCliConfig,
27}
28
29impl OpenCodeCliTool {
30 pub fn new(security: Arc<SecurityPolicy>, config: OpenCodeCliConfig) -> Self {
31 Self { security, config }
32 }
33}
34
35#[async_trait]
36impl Tool for OpenCodeCliTool {
37 fn name(&self) -> &str {
38 "opencode_cli"
39 }
40
41 fn description(&self) -> &str {
42 "Delegate a coding task to OpenCode CLI (opencode run). Supports file editing and bash execution. Use for complex coding work that benefits from OpenCode'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 OpenCode"
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, "opencode_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 "opencode_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 mut cmd = Command::new("opencode");
141 cmd.arg("run").arg(prompt);
142
143 cmd.env_clear();
145 for var in SAFE_ENV_VARS {
146 if let Ok(val) = std::env::var(var) {
147 cmd.env(var, val);
148 }
149 }
150 for var in &self.config.env_passthrough {
151 let trimmed = var.trim();
152 if !trimmed.is_empty()
153 && let Ok(val) = std::env::var(trimmed)
154 {
155 cmd.env(trimmed, val);
156 }
157 }
158
159 cmd.current_dir(&work_dir);
160 let timeout = Duration::from_secs(self.config.timeout_secs);
164 cmd.kill_on_drop(true);
165
166 let result = tokio::time::timeout(timeout, cmd.output()).await;
167
168 match result {
169 Ok(Ok(output)) => {
170 let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
171 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
172
173 if stdout.len() > self.config.max_output_bytes {
175 let mut b = self.config.max_output_bytes.min(stdout.len());
176 while b > 0 && !stdout.is_char_boundary(b) {
177 b -= 1;
178 }
179 stdout.truncate(b);
180 stdout.push_str("\n... [output truncated]");
181 }
182
183 Ok(ToolResult {
184 success: output.status.success(),
185 output: stdout,
186 error: if stderr.is_empty() {
187 None
188 } else {
189 Some(stderr)
190 },
191 })
192 }
193 Ok(Err(e)) => {
194 let err_msg = e.to_string();
195 let msg = if err_msg.contains("No such file or directory")
196 || err_msg.contains("not found")
197 || err_msg.contains("cannot find")
198 {
199 "OpenCode CLI ('opencode') not found in PATH. Install with: go install github.com/opencode-ai/opencode@latest".into()
200 } else {
201 format!("Failed to execute opencode: {e}")
202 };
203 Ok(ToolResult {
204 success: false,
205 output: String::new(),
206 error: Some(msg),
207 })
208 }
209 Err(_) => {
210 Ok(ToolResult {
213 success: false,
214 output: String::new(),
215 error: Some(format!(
216 "OpenCode CLI timed out after {}s and was killed",
217 self.config.timeout_secs
218 )),
219 })
220 }
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use zeroclaw_config::autonomy::AutonomyLevel;
229 use zeroclaw_config::policy::SecurityPolicy;
230 use zeroclaw_config::schema::OpenCodeCliConfig;
231
232 fn test_config() -> OpenCodeCliConfig {
233 OpenCodeCliConfig::default()
234 }
235
236 fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
237 Arc::new(SecurityPolicy {
238 autonomy,
239 workspace_dir: std::env::temp_dir(),
240 ..SecurityPolicy::default()
241 })
242 }
243
244 #[test]
245 fn opencode_cli_tool_name() {
246 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
247 assert_eq!(tool.name(), "opencode_cli");
248 }
249
250 #[test]
251 fn opencode_cli_tool_schema_has_prompt() {
252 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
253 let schema = tool.parameters_schema();
254 assert!(schema["properties"]["prompt"].is_object());
255 assert!(
256 schema["required"]
257 .as_array()
258 .expect("schema required should be an array")
259 .contains(&json!("prompt"))
260 );
261 assert!(schema["properties"]["working_directory"].is_object());
262 }
263
264 #[tokio::test]
265 async fn opencode_cli_blocks_rate_limited() {
266 let security = Arc::new(SecurityPolicy {
267 autonomy: AutonomyLevel::Supervised,
268 max_actions_per_hour: 0,
269 workspace_dir: std::env::temp_dir(),
270 ..SecurityPolicy::default()
271 });
272 let tool = OpenCodeCliTool::new(security, test_config());
273 let result = tool
274 .execute(json!({"prompt": "hello"}))
275 .await
276 .expect("rate-limited should return a result");
277 assert!(!result.success);
278 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
279 }
280
281 #[tokio::test]
282 async fn opencode_cli_blocks_readonly() {
283 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
284 let result = tool
285 .execute(json!({"prompt": "hello"}))
286 .await
287 .expect("readonly should return a result");
288 assert!(!result.success);
289 assert!(
290 result
291 .error
292 .as_deref()
293 .unwrap_or("")
294 .contains("read-only mode")
295 );
296 }
297
298 #[tokio::test]
299 async fn opencode_cli_missing_prompt_param() {
300 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
301 let result = tool.execute(json!({})).await;
302 assert!(result.is_err());
303 assert!(result.unwrap_err().to_string().contains("prompt"));
304 }
305
306 #[tokio::test]
307 async fn opencode_cli_rejects_path_outside_workspace() {
308 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Full), test_config());
309 let result = tool
310 .execute(json!({
311 "prompt": "hello",
312 "working_directory": "/etc"
313 }))
314 .await
315 .expect("should return a result for path validation");
316 assert!(!result.success);
317 assert!(
318 result
319 .error
320 .as_deref()
321 .unwrap_or("")
322 .contains("outside the workspace")
323 );
324 }
325
326 #[test]
327 fn opencode_cli_env_passthrough_defaults() {
328 let config = OpenCodeCliConfig::default();
329 assert!(
330 config.env_passthrough.is_empty(),
331 "env_passthrough should default to empty"
332 );
333 }
334
335 #[test]
336 fn opencode_cli_default_config_values() {
337 let config = OpenCodeCliConfig::default();
338 assert!(!config.enabled);
339 assert_eq!(config.timeout_secs, 600);
340 assert_eq!(config.max_output_bytes, 2_097_152);
341 }
342}