roboticus_agent/tools/
execution.rs1use super::{
2 Tool, ToolContext, ToolError, ToolResult, resolve_workspace_path_with_allowed,
3 workspace_root_from_ctx,
4};
5use async_trait::async_trait;
6use roboticus_core::RiskLevel;
7use serde_json::Value;
8use std::time::Instant;
9use tokio::process::Command;
10
11pub struct EchoTool;
12
13#[async_trait]
14impl Tool for EchoTool {
15 fn name(&self) -> &str {
16 "echo"
17 }
18
19 fn description(&self) -> &str {
20 "Echoes input back as output"
21 }
22
23 fn risk_level(&self) -> RiskLevel {
24 RiskLevel::Safe
25 }
26
27 fn parameters_schema(&self) -> Value {
28 serde_json::json!({
29 "type": "object",
30 "properties": {
31 "message": { "type": "string" }
32 },
33 "required": ["message"]
34 })
35 }
36
37 async fn execute(
38 &self,
39 params: Value,
40 _ctx: &ToolContext,
41 ) -> std::result::Result<ToolResult, ToolError> {
42 let message = params
43 .get("message")
44 .and_then(|v| v.as_str())
45 .ok_or_else(|| ToolError {
46 message: "missing 'message' parameter".into(),
47 })?;
48
49 Ok(ToolResult {
50 output: message.to_string(),
51 metadata: None,
52 })
53 }
54}
55
56pub struct ScriptRunnerTool {
58 runner: crate::script_runner::ScriptRunner,
59}
60
61impl ScriptRunnerTool {
62 pub fn new(
63 config: roboticus_core::config::SkillsConfig,
64 fs_security: roboticus_core::config::FilesystemSecurityConfig,
65 ) -> Self {
66 Self {
67 runner: crate::script_runner::ScriptRunner::new(config, fs_security),
68 }
69 }
70}
71
72pub(crate) fn classify_script_runner_error(message: &str) -> &'static str {
73 let lower = message.to_ascii_lowercase();
74 if lower.contains("timed out") {
75 "SCRIPT_TIMEOUT"
76 } else if lower.contains("absolute script paths are not allowed")
77 || lower.contains("escapes skills_dir")
78 || lower.contains("not a file")
79 || lower.contains("world-writable")
80 {
81 "SCRIPT_PATH_INVALID"
82 } else if lower.contains("not in whitelist") || lower.contains("cannot infer interpreter") {
83 "SCRIPT_INTERPRETER_DENIED"
84 } else if lower.contains("failed to spawn") {
85 "SCRIPT_SPAWN_FAILED"
86 } else {
87 "SCRIPT_RUNTIME_ERROR"
88 }
89}
90
91#[async_trait]
92impl Tool for ScriptRunnerTool {
93 fn name(&self) -> &str {
94 "run_script"
95 }
96
97 fn description(&self) -> &str {
98 "Execute a whitelisted skill script with sandboxed environment"
99 }
100
101 fn risk_level(&self) -> RiskLevel {
102 RiskLevel::Caution
103 }
104
105 fn parameters_schema(&self) -> Value {
106 serde_json::json!({
107 "type": "object",
108 "properties": {
109 "path": { "type": "string", "description": "Path to the script file" },
110 "args": { "type": "array", "items": { "type": "string" }, "description": "Arguments to pass" }
111 },
112 "required": ["path"]
113 })
114 }
115
116 async fn execute(
117 &self,
118 params: Value,
119 _ctx: &ToolContext,
120 ) -> std::result::Result<ToolResult, ToolError> {
121 let path = params
122 .get("path")
123 .and_then(|v| v.as_str())
124 .ok_or_else(|| ToolError {
125 message: "missing 'path' parameter".into(),
126 })?;
127
128 let args: Vec<String> = params
129 .get("args")
130 .and_then(|v| v.as_array())
131 .map(|arr| {
132 arr.iter()
133 .filter_map(|v| v.as_str().map(|s| s.to_string()))
134 .collect()
135 })
136 .unwrap_or_default();
137
138 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
139 let script_path = std::path::Path::new(path);
140
141 match self.runner.execute(script_path, &arg_refs).await {
142 Ok(result) => {
143 if result.exit_code != 0 {
144 return Err(ToolError {
145 message: format!(
146 "SCRIPT_EXIT_NONZERO: script exited with code {}: {}",
147 result.exit_code, result.stderr
148 ),
149 });
150 }
151 Ok(ToolResult {
152 output: result.stdout,
153 metadata: Some(serde_json::json!({
154 "adapter": "script_runner",
155 "schema_version": 1,
156 "status": "ok",
157 "error_class": null,
158 "exit_code": result.exit_code,
159 "duration_ms": result.duration_ms,
160 })),
161 })
162 }
163 Err(e) => {
164 let msg = e.to_string();
165 let class = classify_script_runner_error(&msg);
166 Err(ToolError {
167 message: format!("{class}: {msg}"),
168 })
169 }
170 }
171 }
172}
173
174pub struct BashTool;
175
176#[async_trait]
177impl Tool for BashTool {
178 fn name(&self) -> &str {
179 "bash"
180 }
181
182 fn description(&self) -> &str {
183 "Execute a shell command. Runs in the workspace root by default, but cwd can be set to any configured allowed path."
184 }
185
186 fn risk_level(&self) -> RiskLevel {
187 RiskLevel::Dangerous
188 }
189
190 fn parameters_schema(&self) -> Value {
191 serde_json::json!({
192 "type": "object",
193 "properties": {
194 "command": { "type": "string", "description": "Shell command to execute" },
195 "cwd": { "type": "string", "description": "Working directory under workspace root", "default": "." },
196 "timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 120, "default": 20 }
197 },
198 "required": ["command"]
199 })
200 }
201
202 async fn execute(
203 &self,
204 params: Value,
205 ctx: &ToolContext,
206 ) -> std::result::Result<ToolResult, ToolError> {
207 let command = params
208 .get("command")
209 .and_then(|v| v.as_str())
210 .ok_or_else(|| ToolError {
211 message: "missing 'command' parameter".into(),
212 })?;
213 let cwd_raw = params.get("cwd").and_then(|v| v.as_str()).unwrap_or(".");
214 let timeout_seconds = params
215 .get("timeout_seconds")
216 .and_then(|v| v.as_u64())
217 .unwrap_or(20)
218 .clamp(1, 120);
219
220 let root = workspace_root_from_ctx(ctx)?;
221 let cwd =
222 resolve_workspace_path_with_allowed(&root, cwd_raw, false, &ctx.tool_allowed_paths)?;
223 let started = Instant::now();
224 let output = tokio::time::timeout(
225 std::time::Duration::from_secs(timeout_seconds),
226 Command::new("bash")
227 .arg("-lc")
228 .arg(command)
229 .current_dir(&cwd)
230 .output(),
231 )
232 .await
233 .map_err(|_| ToolError {
234 message: format!("command timed out after {timeout_seconds}s"),
235 })?
236 .map_err(|e| ToolError {
237 message: format!("failed to run bash command: {e}"),
238 })?;
239
240 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
241 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
242 let exit_code = output.status.code().unwrap_or(-1);
243 if !output.status.success() {
244 return Err(ToolError {
245 message: format!("command exited with code {exit_code}: {stderr}"),
246 });
247 }
248 Ok(ToolResult {
249 output: stdout,
250 metadata: Some(serde_json::json!({
251 "exit_code": exit_code,
252 "duration_ms": started.elapsed().as_millis() as u64,
253 "cwd": cwd.display().to_string(),
254 })),
255 })
256 }
257}