Skip to main content

roboticus_agent/tools/
execution.rs

1use 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
56/// Tool wrapper around `ScriptRunner` for executing skill scripts via the ToolRegistry.
57pub 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}