SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
7.6 KB · 179 lines swift
Raw Blame History
1//2//  AppleScriptTool.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The agent's `osascript` tool: runs AppleScript to automate macOS apps9//  (Finder, Notes, Mail, Calendar, System Events…). Single-line scripts run10//  via `osascript -e`; multi-line scripts are written to a temp file under11//  the workspace's `.zyquo/scripts/` dir and run as `osascript <file>`.12//  Same policy → execute → audit path as the shell tool. When macOS TCC13//  blocks Apple events (error -1743 / "Not authorized"), the result explains14//  how to grant Automation access instead of leaving a cryptic error.15//1617import Foundation1819struct AppleScriptTool: Tool {20    private let executor: ExecutionService2122    init(executor: ExecutionService) {23        self.executor = executor24    }2526    let name = "osascript"2728    let description = """29        Run an AppleScript on this Mac via /usr/bin/osascript to automate \30        macOS applications: Finder, Notes, Reminders, Mail, Calendar, Safari, \31        Music, System Events, and any scriptable app. Use it for things the \32        shell cannot do cleanly — creating a Note or Reminder, reading \33        Calendar events, controlling app windows. AppleScript is powerful \34        and therefore gated: scripts are usually held for user approval, and \35        the first automation of each app triggers a one-time macOS \36        permission prompt the user must accept. If the result mentions \37        \"Not authorized\", the user needs to grant access in System \38        Settings > Privacy & Security > Automation — tell them so. Keep \39        scripts short and single-purpose; the script's `return` value is \40        returned to you as text.41        """4243    var parametersSchema: JSONValue {44        .object([45            "type": .string("object"),46            "properties": .object([47                "script": .object([48                    "type": .string("string"),49                    "description": .string("The complete AppleScript source to execute."),50                ]),51                "timeout_seconds": .object([52                    "type": .string("integer"),53                    "description": .string("Optional timeout in seconds for this script (default 120)."),54                ]),55                "explanation": .object([56                    "type": .string("string"),57                    "description": .string("One short sentence explaining what this script does and why — shown to the user on approval cards."),58                ]),59            ]),60            "required": .array([.string("script")]),61        ])62    }6364    func execute(arguments: JSONValue, context: ToolExecutionContext) async throws -> ToolExecutionResult {65        guard case .object(let object) = arguments,66              case .string(let script)? = object["script"],67              !script.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {68            return .failure("osascript: missing required parameter `script`.")69        }70        var timeout: TimeInterval?71        if case .number(let seconds)? = object["timeout_seconds"], seconds > 0 {72            timeout = seconds73        }74        var explanation: String?75        if case .string(let text)? = object["explanation"] {76            explanation = text77        }7879        // ---- Safety gate (never skipped) --------------------------------80        let cleared: ClearedAction81        do {82            cleared = try await context.policy.clear(ActionRequest(83                kind: .appleScript,84                payload: script,85                cwd: context.workspaceURL,86                explanation: explanation87            ))88        } catch let denial as PolicyDenied {89            await context.audit.append(AuditEntry(90                actionKind: name,91                payload: script,92                cwd: context.workspaceURL.path,93                ruling: PolicyDecisionRecord.Ruling.denied.rawValue,94                exitCode: nil,95                outputExcerpt: denial.reason96            ))97            return .failure("Script not run — denied by the safety policy: \(denial.reason)")98        }99100        // ---- Execute -----------------------------------------------------101        let effectiveScript = cleared.payload102        let isMultiline = effectiveScript.contains("\n")103        let result: ExecutionResult104        var scriptFileURL: URL?105        do {106            if isMultiline {107                // Multi-line: write to .zyquo/scripts/ and run the file.108                let scriptsDir = context.workspaceURL109                    .appendingPathComponent(".zyquo")110                    .appendingPathComponent("scripts")111                try FileManager.default.createDirectory(at: scriptsDir, withIntermediateDirectories: true)112                let fileURL = scriptsDir.appendingPathComponent("osascript-\(UUID().uuidString.prefix(8)).applescript")113                try effectiveScript.write(to: fileURL, atomically: true, encoding: .utf8)114                scriptFileURL = fileURL115                result = try await executor.runOSAScriptFile(116                    scriptFile: fileURL,117                    cwd: context.workspaceURL,118                    timeout: timeout,119                    onOutput: context.onOutput120                )121            } else {122                result = try await executor.runOSAScript(123                    lines: [effectiveScript],124                    cwd: context.workspaceURL,125                    timeout: timeout,126                    onOutput: context.onOutput127                )128            }129        } catch let launchError as ExecutionLaunchError {130            await context.audit.append(AuditEntry(131                actionKind: name,132                payload: effectiveScript,133                cwd: context.workspaceURL.path,134                ruling: cleared.decision.ruling.rawValue,135                exitCode: nil,136                outputExcerpt: launchError.reason137            ))138            return .failure("osascript failed to launch: \(launchError.reason)")139        }140        // Keep the script file for the audit trail (it lives in .zyquo/,141        // which is excluded from workspace file tracking).142        _ = scriptFileURL143144        // ---- Audit -------------------------------------------------------145        await context.audit.append(AuditEntry(146            actionKind: name,147            payload: effectiveScript,148            cwd: context.workspaceURL.path,149            ruling: cleared.decision.ruling.rawValue,150            exitCode: result.exitCode,151            outputExcerpt: result.combinedOutput152        ))153154        // ---- Result for the model ----------------------------------------155        var content = result.combinedOutput156        if result.isTimeout {157            content += "\n[timed out after \(Int(timeout ?? 120))s — osascript was killed]"158        }159        if result.isCancelled {160            content += "\n[cancelled by the user]"161        }162        content += "\n[exit code: \(result.exitCode.map(String.init) ?? "none")]"163164        // TCC / Automation permission guidance.165        let lowered = result.stderr.lowercased()166        if lowered.contains("not authorized") || lowered.contains("-1743") || result.stderr.contains("errAEEventNotPermitted") {167            content += """168                \n[Automation permission needed] macOS blocked Zyquo Agent from \169                controlling that application. Ask the user to open System \170                Settings > Privacy & Security > Automation, find "Zyquo Agent", \171                and enable the target app — then run the script again.172                """173        }174175        let failed = result.isTimeout || result.isCancelled || (result.exitCode ?? 1) != 0176        return ToolExecutionResult(content: content, isError: failed)177    }178}179