car-server-core 0.34.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 Mac".
//!
//! Runs AppleScript / JavaScript-for-Automation (JXA) via `osascript` to drive
//! macOS and its scriptable apps. This capability **cannot be sandboxed** — it
//! drives the real host GUI — so it 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.

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

/// Host-side macOS automation via `osascript`.
pub struct AutomationTools;

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

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

    /// Advertised only on macOS (`osascript` is macOS-only). The tier gate — not
    /// availability — is what enforces approval; a non-macOS host simply has no
    /// tool to offer.
    pub fn tool_defs(&self) -> Vec<Value> {
        if !cfg!(target_os = "macos") {
            return Vec::new();
        }
        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"
        })]
    }

    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_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,
            // 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_full_access_tier_on_macos() {
        let defs = AutomationTools::new().tool_defs();
        if cfg!(target_os = "macos") {
            assert_eq!(defs.len(), 1);
            assert_eq!(defs[0]["tier"], "full_access");
            assert_eq!(defs[0]["mutating"], true);
        } else {
            assert!(defs.is_empty());
        }
    }
}