car-server-core 0.47.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Automation tools for the general assistant — "control the desktop".
//!
//! One tool per platform, over the OS-shipped scripting layer: `run_applescript`
//! (AppleScript / JXA via `osascript`) on macOS, `run_powershell`
//! (`powershell.exe`) on Windows. Both drive the real host GUI and its apps and
//! **cannot be sandboxed**, so each self-declares `"tier": "full_access"` and is
//! approval-gated by the tier-based gating in `build_assistant_runtime` unless
//! the session was granted `--full-access`. A capability no sandboxed or
//! text-only agent has. Distinct from the `shell` tool, which runs a
//! command interpreter (`sh`/`cmd`) and cannot reach the GUI/COM surface.

use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};

/// Host-side desktop automation: `osascript` on macOS, `powershell.exe` on
/// Windows.
pub struct AutomationTools;

impl Default for AutomationTools {
    fn default() -> Self {
        Self::new()
    }
}

impl AutomationTools {
    pub fn new() -> Self {
        Self
    }

    /// The host desktop-control tool, per platform: `run_applescript` on macOS
    /// (`osascript`), `run_powershell` on Windows (`powershell.exe`), nothing
    /// elsewhere. The tier gate — not availability — is what enforces approval;
    /// a platform with no OS scripting tool simply has nothing to offer.
    pub fn tool_defs(&self) -> Vec<Value> {
        #[cfg(target_os = "macos")]
        {
            vec![json!({
                "name": "run_applescript",
                "description": "Run an AppleScript or JavaScript-for-Automation (JXA) script to \
                    control macOS and its apps — Finder, System Events, Notes, Calendar, Mail, \
                    Reminders, notifications, window/app control, clipboard, and anything \
                    scriptable. Returns the script's stdout/stderr. This drives the real host \
                    desktop (it cannot be sandboxed), so it requires full-access approval — a \
                    capability no sandboxed or text-only agent has. Prefer JXA (`language: \
                    \"javascript\"`), which models generate more cleanly than AppleScript.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "script": {
                            "type": "string",
                            "description": "The AppleScript or JXA source to run."
                        },
                        "language": {
                            "type": "string",
                            "enum": ["applescript", "javascript"],
                            "description": "Script language (default applescript)."
                        }
                    },
                    "required": ["script"]
                },
                "mutating": true,
                "tier": "full_access"
            })]
        }
        #[cfg(target_os = "windows")]
        {
            vec![json!({
                "name": "run_powershell",
                "description": "Run a Windows PowerShell script to control Windows and its apps — \
                    toast notifications, clipboard (Get/Set-Clipboard), Explorer and COM app \
                    automation (New-Object -ComObject — Office, browsers, Shell), UI Automation, \
                    window/process control, registry, and anything scriptable. Returns the \
                    script's stdout/stderr. This drives the real host desktop (it cannot be \
                    sandboxed) and is distinct from the `shell` tool, which runs cmd.exe and \
                    cannot reach the GUI/COM automation surface — so it requires full-access \
                    approval, a capability no sandboxed or text-only agent has.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "script": {
                            "type": "string",
                            "description": "The Windows PowerShell source to run."
                        }
                    },
                    "required": ["script"]
                },
                "mutating": true,
                "tier": "full_access"
            })]
        }
        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
        {
            Vec::new()
        }
    }

    async fn run_applescript(&self, params: &Value) -> Result<Value, String> {
        let script = params
            .get("script")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("run_applescript requires a non-empty `script`")?;

        let lang = match params.get("language").and_then(|v| v.as_str()) {
            Some(l)
                if l.eq_ignore_ascii_case("javascript")
                    || l.eq_ignore_ascii_case("jxa")
                    || l.eq_ignore_ascii_case("js") =>
            {
                car_automation::applescript::Language::JavaScript
            }
            _ => car_automation::applescript::Language::AppleScript,
        };

        // Bound the script so a hung GUI call can't wedge the turn (the tool
        // call itself is uninterruptible from the loop).
        let out = car_automation::applescript::run(
            script,
            lang,
            Some(std::time::Duration::from_secs(60)),
        )
        .await
        .map_err(|e| format!("automation failed: {e}"))?;

        Ok(json!({
            "stdout": out.stdout,
            "stderr": out.stderr,
            "exit_code": out.exit_code,
        }))
    }

    async fn run_powershell(&self, params: &Value) -> Result<Value, String> {
        let script = params
            .get("script")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or("run_powershell requires a non-empty `script`")?;

        // Bound the script so a hung GUI call can't wedge the turn (the tool
        // call itself is uninterruptible from the loop).
        let out = car_automation::powershell::run(script, Some(std::time::Duration::from_secs(60)))
            .await
            .map_err(|e| format!("automation failed: {e}"))?;

        Ok(json!({
            "stdout": out.stdout,
            "stderr": out.stderr,
            "exit_code": out.exit_code,
        }))
    }
}

#[async_trait]
impl ToolExecutor for AutomationTools {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        match tool {
            "run_applescript" => self.run_applescript(params).await,
            "run_powershell" => self.run_powershell(params).await,
            // The prefix must be exactly "unknown tool" so the ChainedDelegate
            // falls through to the next executor.
            other => Err(format!("unknown tool: '{other}'")),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn unknown_tool_falls_through() {
        let err = AutomationTools::new()
            .execute("nope", &json!({}))
            .await
            .unwrap_err();
        assert!(err.starts_with("unknown tool"), "{err}");
    }

    #[tokio::test]
    async fn rejects_empty_script() {
        let err = AutomationTools::new()
            .execute("run_applescript", &json!({ "script": "  " }))
            .await
            .unwrap_err();
        assert!(err.contains("non-empty"), "{err}");
    }

    #[test]
    fn declares_platform_desktop_tool_at_full_access_tier() {
        let defs = AutomationTools::new().tool_defs();
        #[cfg(target_os = "macos")]
        let expected_name = Some("run_applescript");
        #[cfg(target_os = "windows")]
        let expected_name = Some("run_powershell");
        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
        let expected_name: Option<&str> = None;

        match expected_name {
            Some(name) => {
                assert_eq!(defs.len(), 1);
                assert_eq!(defs[0]["name"], name);
                assert_eq!(defs[0]["tier"], "full_access");
                assert_eq!(defs[0]["mutating"], true);
            }
            None => assert!(defs.is_empty()),
        }
    }

    #[cfg(target_os = "windows")]
    #[tokio::test]
    async fn rejects_empty_powershell_script() {
        let err = AutomationTools::new()
            .execute("run_powershell", &json!({ "script": "  " }))
            .await
            .unwrap_err();
        assert!(err.contains("non-empty"), "{err}");
    }
}