use super::{
Tool, ToolContext, ToolError, ToolResult, resolve_workspace_path_with_allowed,
workspace_root_from_ctx,
};
use async_trait::async_trait;
use roboticus_core::RiskLevel;
use serde_json::Value;
use std::time::Instant;
use tokio::process::Command;
pub struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes input back as output"
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Safe
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
})
}
async fn execute(
&self,
params: Value,
_ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'message' parameter".into(),
})?;
Ok(ToolResult {
output: message.to_string(),
metadata: None,
})
}
}
pub struct ScriptRunnerTool {
runner: crate::script_runner::ScriptRunner,
}
impl ScriptRunnerTool {
pub fn new(
config: roboticus_core::config::SkillsConfig,
fs_security: roboticus_core::config::FilesystemSecurityConfig,
) -> Self {
Self {
runner: crate::script_runner::ScriptRunner::new(config, fs_security),
}
}
}
pub(crate) fn classify_script_runner_error(message: &str) -> &'static str {
let lower = message.to_ascii_lowercase();
if lower.contains("timed out") {
"SCRIPT_TIMEOUT"
} else if lower.contains("absolute script paths are not allowed")
|| lower.contains("escapes skills_dir")
|| lower.contains("not a file")
|| lower.contains("world-writable")
{
"SCRIPT_PATH_INVALID"
} else if lower.contains("not in whitelist") || lower.contains("cannot infer interpreter") {
"SCRIPT_INTERPRETER_DENIED"
} else if lower.contains("failed to spawn") {
"SCRIPT_SPAWN_FAILED"
} else {
"SCRIPT_RUNTIME_ERROR"
}
}
#[async_trait]
impl Tool for ScriptRunnerTool {
fn name(&self) -> &str {
"run_script"
}
fn description(&self) -> &str {
"Execute a whitelisted skill script with sandboxed environment"
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the script file" },
"args": { "type": "array", "items": { "type": "string" }, "description": "Arguments to pass" }
},
"required": ["path"]
})
}
async fn execute(
&self,
params: Value,
_ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'path' parameter".into(),
})?;
let args: Vec<String> = params
.get("args")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let script_path = std::path::Path::new(path);
match self.runner.execute(script_path, &arg_refs).await {
Ok(result) => {
if result.exit_code != 0 {
return Err(ToolError {
message: format!(
"SCRIPT_EXIT_NONZERO: script exited with code {}: {}",
result.exit_code, result.stderr
),
});
}
Ok(ToolResult {
output: result.stdout,
metadata: Some(serde_json::json!({
"adapter": "script_runner",
"schema_version": 1,
"status": "ok",
"error_class": null,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
})),
})
}
Err(e) => {
let msg = e.to_string();
let class = classify_script_runner_error(&msg);
Err(ToolError {
message: format!("{class}: {msg}"),
})
}
}
}
}
pub struct BashTool;
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Execute a shell command. Runs in the workspace root by default, but cwd can be set to any configured allowed path."
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Dangerous
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command to execute" },
"cwd": { "type": "string", "description": "Working directory under workspace root", "default": "." },
"timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 120, "default": 20 }
},
"required": ["command"]
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let command = params
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'command' parameter".into(),
})?;
let cwd_raw = params.get("cwd").and_then(|v| v.as_str()).unwrap_or(".");
let timeout_seconds = params
.get("timeout_seconds")
.and_then(|v| v.as_u64())
.unwrap_or(20)
.clamp(1, 120);
let root = workspace_root_from_ctx(ctx)?;
let cwd =
resolve_workspace_path_with_allowed(&root, cwd_raw, false, &ctx.tool_allowed_paths)?;
let started = Instant::now();
let output = tokio::time::timeout(
std::time::Duration::from_secs(timeout_seconds),
Command::new("bash")
.arg("-lc")
.arg(command)
.current_dir(&cwd)
.output(),
)
.await
.map_err(|_| ToolError {
message: format!("command timed out after {timeout_seconds}s"),
})?
.map_err(|e| ToolError {
message: format!("failed to run bash command: {e}"),
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
if !output.status.success() {
return Err(ToolError {
message: format!("command exited with code {exit_code}: {stderr}"),
});
}
Ok(ToolResult {
output: stdout,
metadata: Some(serde_json::json!({
"exit_code": exit_code,
"duration_ms": started.elapsed().as_millis() as u64,
"cwd": cwd.display().to_string(),
})),
})
}
}