aacode-rs 1.8.8

Rust port of the AACode ReAct coding agent, built on fastshell for mobile AI agents
Documentation
// Copyright (c) 2026 xiefujin <490021684@qq.com>
// Licensed under GPL-3.0, see LICENSE file for full license terms.

//! `run_shell` tool — executes commands through a pluggable `ShellBackend`.
//!
//! On desktop the default backend is the real OS shell (`NativeShell`); on
//! mobile it is the fastshell sandbox engine (`FastshellBackend`). See
//! `backend.rs`. Returns a JSON string with `{success, returncode, stdout,
//! stderr, command}`. Oversized output is archived under `.aacode/extracts/`.

use super::backend::ShellBackend;
use super::registry::Tool;
use super::schema::{ParamType, ToolParameter, ToolSchema};
use crate::config::{DangerAction, SafetyConfig};
use crate::error::{AacodeError, Result};
use serde_json::{json, Value};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;

// Re-export the fastshell handle type for the rest of the crate.
pub use super::backend::SharedFastshell as SharedShell;

pub struct ShellTool {
    backend: Arc<dyn ShellBackend>,
    /// Working directory / archive root for command execution.
    cwd: std::path::PathBuf,
    max_output_chars: usize,
    default_timeout_secs: u64,
    default_idle_timeout_secs: u64,
    safety: SafetyConfig,
}

impl ShellTool {
    pub fn new(
        backend: Arc<dyn ShellBackend>,
        cwd: std::path::PathBuf,
        max_output_chars: usize,
        default_timeout_secs: u64,
        default_idle_timeout_secs: u64,
        safety: SafetyConfig,
    ) -> Self {
        ShellTool {
        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
            backend,
            cwd,
            max_output_chars,
            default_timeout_secs,
            default_idle_timeout_secs,
            safety,
        }
    }

    /// Archive a large string to `.aacode/extracts/` and return the path.
    fn archive(&self, content: &str, prefix: &str) -> Option<String> {
        let dir = self.cwd.join(".aacode").join("extracts");
        std::fs::create_dir_all(&dir).ok()?;
        let name = format!("tool_{}_{}.txt", prefix, uuid::Uuid::new_v4().simple());
        let path = dir.join(&name);
        std::fs::write(&path, content).ok()?;
        Some(path.to_string_lossy().to_string())
    }

    /// Truncate a field, archiving the full content when too long.
    fn maybe_truncate(&self, text: String, prefix: &str, limit: usize) -> String {
        if limit == 0 || text.chars().count() <= limit {
            return text;
        }
        let head: String = text.chars().take(limit).collect();
        match self.archive(&text, prefix) {
            Some(path) => format!(
                "{head}\n\n[Full output ({} chars) saved to {path}. Use run_shell to grep/cat it.]",
                text.chars().count()
            ),
            None => format!("{head}\n\n[truncated, {} chars total]", text.chars().count()),
        }
    }

    /// Detect blatantly dangerous commands (used only in `reject` mode).
    fn is_dangerous(command: &str) -> bool {
        let c = command.to_lowercase();
        let patterns = [
            "rm -rf /",
            "rm -rf /*",
            ":(){:|:&};:", // fork bomb
            "mkfs",
            "dd if=/dev/zero",
            "> /dev/sda",
            "chmod -r 000 /",
        ];
        patterns.iter().any(|p| c.contains(p))
    }
}

#[async_trait::async_trait]
impl Tool for ShellTool {
    fn schema(&self) -> ToolSchema {
        ToolSchema::new(
            "run_shell",
            "Execute shell commands — the universal Swiss Army knife. This is the ONLY tool for ALL file operations. Use shell commands for: locating (grep -n pattern file, find), reading full content (cat/tail/head file), writing (echo text > file, cat > file << 'EOF'), editing (sed/awk), running code (python/node/go/rustc/gcc), testing (pytest/cargo test), git, and more. Supports pipes (|), redirection (>), heredocs (<< 'EOF'), chaining (&& / || / ;), command substitution ($(...)), and variable expansion ($VAR). Always returns a result object with stdout, stderr, and returncode — check returncode for success.",
            vec![
                ToolParameter::new(
                    "command",
                    ParamType::String,
                    true,
                    "The shell command to execute. Supports pipes (|), redirection (>), chaining (&& / ;). Always quote filenames with spaces/special chars.",
                    &["cmd", "shell", "script", "exec"],
                ),
                ToolParameter::new(
                    "timeout",
                    ParamType::Integer,
                    false,
                    "Total command timeout in seconds.",
                    &["time_limit", "max_time", "wait"],
                ),
                ToolParameter::new(
                    "idle_timeout",
                    ParamType::Integer,
                    false,
                    "Max seconds with no output before killing (idle timeout). Default: 30.",
                    &["idle_time", "no_output_timeout"],
                ),
                ToolParameter::new(
                    "stdin_input",
                    ParamType::String,
                    false,
                    "Standard input piped to the program (for input()). Separate lines with \\n.",
                    &["input", "stdin"],
                ),
                ToolParameter::new(
                    "max_output",
                    ParamType::Integer,
                    false,
                    "Limit returned output characters (default 24000). Pass a smaller number to save context; omit to keep the default cap.",
                    &["max_chars", "limit", "output_limit"],
                ),
            ],
        )
    }

    async fn call(&self, args: &Value, _cancel: &AtomicBool) -> Result<String> {
        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
        let command = args
            .get("command")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        // Danger policy.
        if self.safety.dangerous_command_action == DangerAction::Reject
            && Self::is_dangerous(&command)
        {
            return Ok(json!({
                "success": false,
                "error": "Command rejected by safety guard (dangerous pattern).",
                "command": command,
            })
            .to_string());
        }

        let stdin_input = args.get("stdin_input").and_then(|v| v.as_str());
        let timeout = args
            .get("timeout")
            .and_then(|v| v.as_u64())
            .unwrap_or(self.default_timeout_secs);
        let idle_timeout = args
            .get("idle_timeout")
            .and_then(|v| v.as_u64())
            .unwrap_or(self.default_idle_timeout_secs);

        let stdin_owned = stdin_input.map(|s| s.to_string());
        let backend = self.backend.clone();
        let cwd = self.cwd.clone();
        let command2 = command.clone();
        let result = tokio::task::spawn_blocking(move || {
            backend.run(&command2, stdin_owned.as_deref(), timeout, idle_timeout, &cwd)
        }).await.map_err(|e| AacodeError::Other(format!("shell spawn: {e}")))?;

        // Per-call max_output override; else the tool's configured cap.
        let limit = args
            .get("max_output")
            .and_then(|v| v.as_u64())
            .map(|n| n as usize)
            .unwrap_or(self.max_output_chars);

        let stdout = self.maybe_truncate(result.stdout, "stdout", limit);
        let stderr = self.maybe_truncate(result.stderr, "stderr", limit);

        Ok(json!({
            "success": true,
            "returncode": result.exit_code,
            "stdout": stdout,
            "stderr": stderr,
            "backend": self.backend.kind(),
        })
        .to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::super::backend::{FastshellBackend, NativeShell};
    use super::*;
    use fastshell::{Config, Fastshell};
    use std::sync::Mutex;

    fn native_tool(cwd: std::path::PathBuf) -> ShellTool {
        ShellTool::new(
            Arc::new(NativeShell::new()),
            cwd,
            24000,
            30,
            30,
            SafetyConfig::default(),
        )
    }

    fn tmp() -> std::path::PathBuf {
        let d = std::env::temp_dir().join(format!(
            "aacode_shelltool_{}_{}",
            std::process::id(),
            uuid::Uuid::new_v4().simple()
        ));
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    #[tokio::test]
    async fn native_echo_roundtrip() {
        let dir = tmp();
        let t = native_tool(dir);
        let cancel = AtomicBool::new(false);
        let out = t.call(&json!({"command": "echo hello"}), &cancel).await.unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["success"], true);
        assert_eq!(v["returncode"], 0);
        assert_eq!(v["backend"], "native");
        assert!(v["stdout"].as_str().unwrap().contains("hello"));
    }

    #[tokio::test]
    async fn native_writes_to_real_cwd() {
        let dir = tmp();
        let t = native_tool(dir.clone());
        let cancel = AtomicBool::new(false);
        t.call(&json!({"command": "echo content > note.txt"}), &cancel).await.unwrap();
        assert!(dir.join("note.txt").exists());
    }

    #[tokio::test]
    async fn native_heredoc_via_tool() {
        let dir = tmp();
        let t = native_tool(dir);
        let cancel = AtomicBool::new(false);
        let out = t.call(&json!({"command": "cat > x.txt <<'EOF'\nhi\nEOF\ncat x.txt"}), &cancel).await.unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert!(v["stdout"].as_str().unwrap().contains("hi"));
    }

    #[tokio::test]
    async fn max_output_truncation() {
        let dir = tmp();
        let t = native_tool(dir);
        let cancel = AtomicBool::new(false);
        let out = t.call(&json!({"command": "for i in $(seq 1 500); do echo linelineline; done", "max_output": 100}), &cancel).await.unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert!(v["stdout"].as_str().unwrap().len() < 5000);
    }

    #[tokio::test]
    async fn dangerous_reject_mode() {
        let dir = tmp();
        let mut safety = SafetyConfig::default();
        safety.dangerous_command_action = DangerAction::Reject;
        let t = ShellTool::new(Arc::new(NativeShell::new()), dir, 24000, 30, 30, safety);
        let cancel = AtomicBool::new(false);
        let out = t.call(&json!({"command": "rm -rf /"}), &cancel).await.unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["success"], false);
    }

    #[tokio::test]
    async fn fastshell_backend_still_works() {
        let dir = tmp();
        let mut fs = Fastshell::new();
        let mut cfg = Config::default();
        cfg.sandbox_path = dir.to_string_lossy().to_string();
        cfg.python_enabled = false;
        fs.init(cfg).unwrap();
        let backend = Arc::new(FastshellBackend::new(Arc::new(Mutex::new(fs))));
        let t = ShellTool::new(backend, dir, 24000, 30, 30, SafetyConfig::default());
        let cancel = AtomicBool::new(false);
        let out = t.call(&json!({"command": "echo sandboxed"}), &cancel).await.unwrap();
        let v: Value = serde_json::from_str(&out).unwrap();
        assert_eq!(v["backend"], "fastshell");
        assert!(v["stdout"].as_str().unwrap().contains("sandboxed"));
    }

    #[test]
    fn is_dangerous_detects() {
        assert!(ShellTool::is_dangerous("rm -rf /"));
        assert!(!ShellTool::is_dangerous("ls -la"));
    }
}