construct/tools/
opencode_cli.rs1use super::traits::{Tool, ToolResult};
2use crate::config::OpenCodeCliConfig;
3use crate::security::SecurityPolicy;
4use crate::security::policy::ToolOperation;
5use async_trait::async_trait;
6use serde_json::json;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::process::Command;
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 self.security.is_rate_limited() {
65 return Ok(ToolResult {
66 success: false,
67 output: String::new(),
68 error: Some("Rate limit exceeded: too many actions in the last hour".into()),
69 });
70 }
71
72 if let Err(error) = self
74 .security
75 .enforce_tool_operation(ToolOperation::Act, "opencode_cli")
76 {
77 return Ok(ToolResult {
78 success: false,
79 output: String::new(),
80 error: Some(error),
81 });
82 }
83
84 let prompt = args
86 .get("prompt")
87 .and_then(|v| v.as_str())
88 .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
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 if !self.security.record_action() {
141 return Ok(ToolResult {
142 success: false,
143 output: String::new(),
144 error: Some("Rate limit exceeded: action budget exhausted".into()),
145 });
146 }
147
148 let mut cmd = Command::new("opencode");
150 cmd.arg("run").arg(prompt);
151
152 cmd.env_clear();
154 for var in SAFE_ENV_VARS {
155 if let Ok(val) = std::env::var(var) {
156 cmd.env(var, val);
157 }
158 }
159 for var in &self.config.env_passthrough {
160 let trimmed = var.trim();
161 if !trimmed.is_empty() {
162 if let Ok(val) = std::env::var(trimmed) {
163 cmd.env(trimmed, val);
164 }
165 }
166 }
167
168 cmd.current_dir(&work_dir);
169 let timeout = Duration::from_secs(self.config.timeout_secs);
173 cmd.kill_on_drop(true);
174
175 let result = tokio::time::timeout(timeout, cmd.output()).await;
176
177 match result {
178 Ok(Ok(output)) => {
179 let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
180 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
181
182 if stdout.len() > self.config.max_output_bytes {
184 let mut b = self.config.max_output_bytes.min(stdout.len());
185 while b > 0 && !stdout.is_char_boundary(b) {
186 b -= 1;
187 }
188 stdout.truncate(b);
189 stdout.push_str("\n... [output truncated]");
190 }
191
192 Ok(ToolResult {
193 success: output.status.success(),
194 output: stdout,
195 error: if stderr.is_empty() {
196 None
197 } else {
198 Some(stderr)
199 },
200 })
201 }
202 Ok(Err(e)) => {
203 let err_msg = e.to_string();
204 let msg = if err_msg.contains("No such file or directory")
205 || err_msg.contains("not found")
206 || err_msg.contains("cannot find")
207 {
208 "OpenCode CLI ('opencode') not found in PATH. Install with: go install github.com/opencode-ai/opencode@latest".into()
209 } else {
210 format!("Failed to execute opencode: {e}")
211 };
212 Ok(ToolResult {
213 success: false,
214 output: String::new(),
215 error: Some(msg),
216 })
217 }
218 Err(_) => {
219 Ok(ToolResult {
222 success: false,
223 output: String::new(),
224 error: Some(format!(
225 "OpenCode CLI timed out after {}s and was killed",
226 self.config.timeout_secs
227 )),
228 })
229 }
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::config::OpenCodeCliConfig;
238 use crate::security::{AutonomyLevel, SecurityPolicy};
239
240 fn test_config() -> OpenCodeCliConfig {
241 OpenCodeCliConfig::default()
242 }
243
244 fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
245 Arc::new(SecurityPolicy {
246 autonomy,
247 workspace_dir: std::env::temp_dir(),
248 ..SecurityPolicy::default()
249 })
250 }
251
252 #[test]
253 fn opencode_cli_tool_name() {
254 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
255 assert_eq!(tool.name(), "opencode_cli");
256 }
257
258 #[test]
259 fn opencode_cli_tool_schema_has_prompt() {
260 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
261 let schema = tool.parameters_schema();
262 assert!(schema["properties"]["prompt"].is_object());
263 assert!(
264 schema["required"]
265 .as_array()
266 .expect("schema required should be an array")
267 .contains(&json!("prompt"))
268 );
269 assert!(schema["properties"]["working_directory"].is_object());
270 }
271
272 #[tokio::test]
273 async fn opencode_cli_blocks_rate_limited() {
274 let security = Arc::new(SecurityPolicy {
275 autonomy: AutonomyLevel::Supervised,
276 max_actions_per_hour: 0,
277 workspace_dir: std::env::temp_dir(),
278 ..SecurityPolicy::default()
279 });
280 let tool = OpenCodeCliTool::new(security, test_config());
281 let result = tool
282 .execute(json!({"prompt": "hello"}))
283 .await
284 .expect("rate-limited should return a result");
285 assert!(!result.success);
286 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
287 }
288
289 #[tokio::test]
290 async fn opencode_cli_blocks_readonly() {
291 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
292 let result = tool
293 .execute(json!({"prompt": "hello"}))
294 .await
295 .expect("readonly should return a result");
296 assert!(!result.success);
297 assert!(
298 result
299 .error
300 .as_deref()
301 .unwrap_or("")
302 .contains("read-only mode")
303 );
304 }
305
306 #[tokio::test]
307 async fn opencode_cli_missing_prompt_param() {
308 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
309 let result = tool.execute(json!({})).await;
310 assert!(result.is_err());
311 assert!(result.unwrap_err().to_string().contains("prompt"));
312 }
313
314 #[tokio::test]
315 async fn opencode_cli_rejects_path_outside_workspace() {
316 let tool = OpenCodeCliTool::new(test_security(AutonomyLevel::Full), test_config());
317 let result = tool
318 .execute(json!({
319 "prompt": "hello",
320 "working_directory": "/etc"
321 }))
322 .await
323 .expect("should return a result for path validation");
324 assert!(!result.success);
325 assert!(
326 result
327 .error
328 .as_deref()
329 .unwrap_or("")
330 .contains("outside the workspace")
331 );
332 }
333
334 #[test]
335 fn opencode_cli_env_passthrough_defaults() {
336 let config = OpenCodeCliConfig::default();
337 assert!(
338 config.env_passthrough.is_empty(),
339 "env_passthrough should default to empty"
340 );
341 }
342
343 #[test]
344 fn opencode_cli_default_config_values() {
345 let config = OpenCodeCliConfig::default();
346 assert!(!config.enabled);
347 assert_eq!(config.timeout_secs, 600);
348 assert_eq!(config.max_output_bytes, 2_097_152);
349 }
350}