use std::process::Stdio; use std::time::Duration; use anyhow::Result; use serde_json::{Value, json}; use tokio::io::AsyncReadExt; use crate::tools::{ SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL, }; const DEFAULT_TIMEOUT_SECS: u64 = 120; const MAX_TIMEOUT_SECS: u64 = 600; const MAX_OUTPUT_BYTES: usize = 100_000; /// Returned by the context-free `Tool` entry points (`execute`/`execute_async`). /// `execute_cmd` only ever runs through `run_with`, which carries the caller's /// `ToolContext` and dispatches into the per-user container. There is no safe /// host fallback (blueprint §6): running on the host would execute the command /// in the Skald process itself, outside the sandbox the user approved. const HOST_PATH_ERROR: &str = "execute_cmd requires the per-user container (ToolContext); it cannot run on the host"; pub struct ExecuteCmd; impl Tool for ExecuteCmd { fn name(&self) -> &str { crate::tools::tool_names::EXECUTE_CMD } fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Shell } fn description(&self) -> &str { "Execute a shell command (sh -c) inside your sandbox container (python + node available). \ Reserve this for: builds, installs, git, tests, scripts, processes, network, package managers. \ Runs as a non-root user; prefix system-package or global installs with `sudo` (e.g. `sudo apt-get install …`). \ Do NOT use cat/head/tail to read files — use read_file instead. \ Do NOT use grep/rg/find to search — use grep_files instead. \ Do NOT use ls to list directories — use list_files instead. \ Do NOT use sed/awk to edit files — use edit_file instead. \ Do NOT use echo/cat heredoc to write files — use write_file instead. \ Captures stdout and stderr. Requires user approval before running." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "command": { "type": "string", "description": "Full command line, passed to `sh -c`. May include pipes, redirects, and shell expansions." }, "workdir": { "type": "string", "description": "Working directory for the command (an agent path like `projects/{owner}/{slug}` or `~`). \ Omit to use your home directory (`~`)." }, "timeout": { "type": "integer", "description": format!( "Max seconds to wait (default: {DEFAULT_TIMEOUT_SECS}, max: {MAX_TIMEOUT_SECS}). \ The command returns immediately when it finishes — set high for long builds, \ you won't wait unnecessarily." ), "default": DEFAULT_TIMEOUT_SECS, "minimum": 1, "maximum": MAX_TIMEOUT_SECS } }, "required": ["command"] }) } fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String { let cmd = args["command"].as_str().unwrap_or("?"); match length { ToolDescriptionLength::Short => { let binary = cmd.split_whitespace().next().unwrap_or(cmd); let name = binary.split('/').last().unwrap_or(binary); truncate_label(&format!("execute_cmd `{name}`"), MAX_LABEL_SHORT) } ToolDescriptionLength::Full => { truncate_label(&format!("execute_cmd `{cmd}`"), MAX_LABEL_FULL) } } } /// Context-free entry point — deliberately unreachable for real work. Without a /// `ToolContext` there is no per-user container to target, so this must NOT fall /// back to a host `sh -c` (blueprint §6 sandbox). Any dispatch that lands here /// (e.g. a REST resolve that bypasses the tool loop) is a caller bug: fail loud /// rather than escape the sandbox. The live path is `run_with`. fn execute(&self, _args: Value) -> Result { anyhow::bail!(HOST_PATH_ERROR) } /// See [`Self::execute`]: no container without a `ToolContext`, so no host fallback. fn execute_async<'a>(&'a self, _args: Value) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { anyhow::bail!(HOST_PATH_ERROR) }) } /// The real entry point (blueprint §6): the command runs **inside the caller's /// container** via `docker exec`, never on the host. `workdir` is interpreted as /// a path in the agent's namespace (`~/…`, `shared/{X}/…`) and mapped to its /// container path; omitted → the container home. Cancellation still works — /// `kill_on_drop` kills the `docker exec` client when the work future is dropped /// on /stop (best-effort; the in-container process may outlive it — see below). fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box { let container = ctx.fs.container_name.clone(); let workdir = match args.get("workdir").and_then(Value::as_str) { Some(p) => ctx.fs.to_container(p), None => ctx.fs.container_home.clone(), }; let command = match args.get("command").and_then(Value::as_str) { Some(c) => c.to_string(), None => return crate::tools::fs::error_exec("Missing required argument: command".to_string()), }; let timeout_secs = args.get("timeout").and_then(Value::as_u64) .unwrap_or(DEFAULT_TIMEOUT_SECS) .clamp(1, MAX_TIMEOUT_SECS); // Robust /stop: run the command in its own session/process-group whose // leader pid is recorded in a container-side pidfile. On /stop (the work // future dropped) or on a timeout, the `KillReaper` drop-guard reaps that // group via a second `docker exec … kill` — `kill_on_drop` alone only kills // the local `docker exec` client, not the tree Docker started *inside* the // container. let pidfile = format!("/tmp/skald-exec-{}.pgid", uuid::Uuid::new_v4()); let wrapper = format!("echo $$ > {pidfile}; trap 'rm -f {pidfile}' EXIT; {command}"); Box::new(SimpleExecution::new(Box::pin(async move { let guard = KillReaper::new(container.clone(), pidfile.clone()); let out = run_in_container(&container, &workdir, &wrapper, &command, timeout_secs).await?; guard.disarm(); Ok(ToolResult::Text(out)) }))) } } /// Runs a wrapped command inside a user's container: /// `docker exec -w setsid -w sh -c