From 27ec64df54150acc1aac57243b02543f4dddebbe Mon Sep 17 00:00:00 2001 From: zheli Date: Tue, 18 Aug 2026 19:31:58 +0800 Subject: [PATCH 1/2] feat: add allowlisted Windows computer use --- .gitignore | 1 + README.md | 1 + coworker/connectors/catalog_copy.py | 8 + coworker/connectors/computer_automation.py | 1132 +++++++++++++++++ coworker/connectors/descriptors.py | 17 + coworker/connectors/integration_tools.py | 2 + coworker/connectors/tool_defs.py | 56 + coworker/server/app.py | 11 + coworker/server/manager.py | 60 + docs/assets/computer-use-settings.png | Bin 0 -> 90788 bytes packaging/build_windows.ps1 | 40 +- packaging/cua-driver-LICENSE.txt | 21 + packaging/cua-driver-capabilities.yaml | 20 + surfaces/gui/src-tauri/src/lib.rs | 16 + surfaces/gui/src/App.tsx | 4 +- surfaces/gui/src/api.ts | 30 + .../components/ComputerUseSection.test.tsx | 73 ++ .../gui/src/components/ComputerUseSection.tsx | 193 +++ surfaces/gui/src/components/SettingsView.tsx | 8 +- surfaces/gui/src/tauri.ts | 6 + tests/test_computer_automation.py | 262 ++++ tests/test_computer_use_settings.py | 72 ++ 22 files changed, 2025 insertions(+), 8 deletions(-) create mode 100644 coworker/connectors/computer_automation.py create mode 100644 docs/assets/computer-use-settings.png create mode 100644 packaging/cua-driver-LICENSE.txt create mode 100644 packaging/cua-driver-capabilities.yaml create mode 100644 surfaces/gui/src/components/ComputerUseSection.test.tsx create mode 100644 surfaces/gui/src/components/ComputerUseSection.tsx create mode 100644 tests/test_computer_automation.py create mode 100644 tests/test_computer_use_settings.py diff --git a/.gitignore b/.gitignore index 4cc93b88b7..a71a4cba29 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ *.egg-info/ build/ dist/ +packaging/cache/ .coverage # Local secrets (live-smoke BYO keys) — never committed diff --git a/README.md b/README.md index 72547b427f..42f8e7b601 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Under the hood: - **Produce real deliverables** - documents, spreadsheets, reports, and web pages land as files you can open and share. - **Work from Slack** - mention `@OpenWorker` in a channel; a session opens on your desktop, the work happens with your tools, and the answer comes back as a thread reply. - **Use your everyday tools** - 25+ integrations including GitHub, Slack, Jira, Notion, Linear, HubSpot, Outlook, monday.com, Gmail, and Google Calendar, plus your **terminal and local files**. Any tool reachable over [MCP](https://modelcontextprotocol.io/) plugs in too, with per-tool control. +- **Control selected Windows apps** - opt in under Settings → Computer use, choose exact executable paths, and approve every program launch and input action. - **Run on a schedule** - automations for recurring work: a morning brief, a weekly report, a standing watch over a channel. Runs land in the app with full transcripts. - **Ask before acting** - writes, sends, and shell commands are approval-gated. Unattended runs park their asks in an inbox instead of acting on their own. diff --git a/coworker/connectors/catalog_copy.py b/coworker/connectors/catalog_copy.py index 25e09b30de..ecfc1997f0 100644 --- a/coworker/connectors/catalog_copy.py +++ b/coworker/connectors/catalog_copy.py @@ -30,6 +30,9 @@ "browser": "A built-in browser agents drive to read pages and act on " "websites — separate from your personal browser, with actions subject to " "approval.", + "computer": "Operate explicitly allowed Windows applications through a local " + "native accessibility driver. OpenWorker reads fresh window state before each " + "action and keeps program launches and desktop input behind approval.", "github": "Work with issues, pull requests, repository files, and CI " "status. One click installs the OpenWorker GitHub App on the repositories " "you pick; mention the agent on an issue or PR and it answers from your " @@ -89,6 +92,11 @@ "Clicks, types, and uploads files only inside that session.", "Never touches your personal browser or its logins.", ], + "computer": [ + "Reads visible window structure and screenshots only from programs you allow.", + "Asks before opening a program, clicking, typing, or pressing a key.", + "The bundled driver policy denies access to other applications and does not expose shell or file-control tools.", + ], "github": [ "Reads code, issues, pull requests, and CI on repositories you grant.", "Creates issues, replies, and reviews pull requests.", diff --git a/coworker/connectors/computer_automation.py b/coworker/connectors/computer_automation.py new file mode 100644 index 0000000000..f232f53642 --- /dev/null +++ b/coworker/connectors/computer_automation.py @@ -0,0 +1,1132 @@ +"""Allowlist-enforced Windows desktop automation through the Cua Driver CLI. + +The driver is a local native sidecar. OpenWorker remains the approval authority +for every input action, and refreshes a deny-by-default capability manifest with +the exact executable paths, process IDs, and window IDs selected by the user. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable, Optional + +import aisuite as ai +import yaml + +from .tool_defs import approval_for_tool + + +_DRIVER_LOCK = threading.Lock() +_DAEMON_READY = False +_BLOCKED_PROGRAM_NAMES = { + "bash.exe", + "cmd.exe", + "conhost.exe", + "cscript.exe", + "explorer.exe", + "java.exe", + "javaw.exe", + "mshta.exe", + "node.exe", + "powershell.exe", + "pwsh.exe", + "python.exe", + "pythonw.exe", + "regedit.exe", + "rundll32.exe", + "regsvr32.exe", + "sh.exe", + "wscript.exe", + "wsl.exe", + "wt.exe", +} +_CONFIG_LOCK = threading.RLock() +_COMPUTER_USE_ENABLED = False +_ALLOWED_PROGRAMS: tuple[dict[str, str], ...] = () +_TOKEN_LOCK = threading.Lock() +_TOKEN_LABELS: dict[tuple[str, int, int, str], str] = {} + + +def _path_key(value: str | Path) -> str: + path = os.path.normpath(os.path.expandvars(str(value or "").strip())) + if path.startswith("\\\\?\\"): + path = path[4:] + return path.casefold() + + +def _program_name(path: str | Path) -> str: + stem = Path(str(path)).stem.strip() + return stem or Path(str(path)).name or "Program" + + +def validate_allowed_programs( + value: Any, *, require_exists: bool = True +) -> list[dict[str, str]]: + if not isinstance(value, list): + raise ValueError("allowed_programs must be a list") + if len(value) > 20: + raise ValueError("at most 20 local programs can be allowed") + programs: list[dict[str, str]] = [] + seen: set[str] = set() + for item in value: + if isinstance(item, str): + raw_path, raw_name = item, "" + elif isinstance(item, dict): + raw_path = str(item.get("path") or "") + raw_name = str(item.get("name") or "") + else: + raise ValueError("each allowed program must be a path or object") + expanded = os.path.expandvars(os.path.expanduser(raw_path.strip())) + path = Path(expanded) + if not path.is_absolute(): + raise ValueError(f"program path must be absolute: {raw_path}") + if path.suffix.casefold() != ".exe": + raise ValueError(f"only Windows .exe programs can be allowed: {path}") + if path.name.casefold() in _BLOCKED_PROGRAM_NAMES: + raise ValueError(f"system command interpreters cannot be allowed: {path.name}") + if require_exists and not path.is_file(): + raise ValueError(f"program was not found: {path}") + key = _path_key(path) + if key in seen: + continue + seen.add(key) + name = re.sub(r"[\r\n\t]+", " ", raw_name).strip()[:80] or _program_name(path) + programs.append({"name": name, "path": str(path)}) + return programs + + +def configure_computer_use( + *, enabled: bool, allowed_programs: list[dict[str, str]] +) -> None: + global _COMPUTER_USE_ENABLED, _ALLOWED_PROGRAMS + with _CONFIG_LOCK: + _COMPUTER_USE_ENABLED = bool(enabled) + _ALLOWED_PROGRAMS = tuple( + {"name": str(item["name"]), "path": str(item["path"])} + for item in allowed_programs + ) + + +def computer_use_configuration() -> dict[str, Any]: + with _CONFIG_LOCK: + return { + "enabled": _COMPUTER_USE_ENABLED, + "allowed_programs": [dict(item) for item in _ALLOWED_PROGRAMS], + } + + +def _effective_allowed_paths() -> dict[str, dict[str, Any]]: + with _CONFIG_LOCK: + if not _COMPUTER_USE_ENABLED: + return {} + entries: list[dict[str, Any]] = [ + {"name": item["name"], "path": item["path"], "launch": True} + for item in _ALLOWED_PROGRAMS + ] + return {_path_key(item["path"]): item for item in entries} + + +def _element_text(element: dict[str, Any]) -> str: + values = [ + element.get(key) + for key in ("label", "name", "value", "text", "title", "description") + ] + return " ".join(str(value).strip() for value in values if str(value or "").strip()) + + +def _remember_snapshot_tokens( + session: str, pid: int, window_id: int, elements: list[dict[str, Any]] +) -> None: + with _TOKEN_LOCK: + stale = [ + key + for key in _TOKEN_LABELS + if key[0] == session and key[1] == int(pid) and key[2] == int(window_id) + ] + for key in stale: + _TOKEN_LABELS.pop(key, None) + for element in elements: + token = str(element.get("element_token") or "").strip() + if token: + _TOKEN_LABELS[(session, int(pid), int(window_id), token)] = _element_text( + element + ) + + +def _consume_snapshot_token( + session: str, pid: int, window_id: int, token: str +) -> Optional[str]: + with _TOKEN_LOCK: + return _TOKEN_LABELS.pop((session, int(pid), int(window_id), str(token)), None) + + +def _label_key(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip().casefold() + + +def _consume_matching_token( + session: str, + pid: int, + window_id: int, + token: str, + element_label: str, +) -> Optional[dict[str, Any]]: + remembered = _consume_snapshot_token(session, pid, window_id, token) + if remembered is None: + return { + "ok": False, + "error": "element_token is not from the immediately preceding computer_snapshot", + } + claimed = _label_key(element_label) + if not claimed: + return {"ok": False, "error": "element_label is required"} + if claimed not in _label_key(remembered): + return { + "ok": False, + "error": "element_label does not match the control bound to element_token", + } + return None + + +def _meta(name: str, *, approval: bool) -> ai.ToolMetadata: + return ai.ToolMetadata( + name=name, + category="connector", + risk_level="medium" if approval else "low", + capabilities=["computer", "desktop"], + requires_approval=approval, + ) + + +def _schema( + name: str, description: str, properties: dict[str, Any], required: list[str] +) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + }, + } + + +def _attach(fn: Callable[..., Any], schema: dict[str, Any]) -> Callable[..., Any]: + name = schema["function"]["name"] + approval = approval_for_tool(name, default=True) + fn.__coworker_schema__ = schema + fn.__aisuite_tool_metadata__ = _meta(name, approval=approval) + fn.__doc__ = schema["function"]["description"] + return fn + + +def _driver_path() -> Optional[Path]: + override = str(os.environ.get("OPENWORKER_CUA_DRIVER") or "").strip() + if override: + return Path(override).expanduser() + + exe = "cua-driver.exe" if os.name == "nt" else "cua-driver" + server_dir = Path(sys.executable).resolve().parent + candidates = [ + server_dir / "cua-driver" / exe, + server_dir / exe, + ] + on_path = shutil.which("cua-driver") + if on_path: + candidates.append(Path(on_path)) + return next((path for path in candidates if path.is_file()), None) + + +def _process_executable(pid: int) -> Optional[str]: + """Resolve a Windows PID to its full executable without shelling out.""" + + if os.name != "nt" or int(pid) <= 0: + return None + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + open_process.restype = wintypes.HANDLE + query_path = kernel32.QueryFullProcessImageNameW + query_path.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + wintypes.LPWSTR, + ctypes.POINTER(wintypes.DWORD), + ] + query_path.restype = wintypes.BOOL + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + handle = open_process(0x1000, False, int(pid)) + if not handle: + return None + try: + size = wintypes.DWORD(32768) + buffer = ctypes.create_unicode_buffer(size.value) + if not query_path(handle, 0, buffer, ctypes.byref(size)): + return None + return buffer.value + finally: + close_handle(handle) + except (AttributeError, OSError, ValueError): + return None + + +def _allowed_window_records( + windows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + allowed_paths = _effective_allowed_paths() + if not allowed_paths: + return [] + pid_paths: dict[int, Optional[str]] = {} + allowed: list[dict[str, Any]] = [] + for window in windows: + if not isinstance(window, dict): + continue + try: + pid = int(window.get("pid") or 0) + window_id = int(window.get("window_id") or 0) + except (TypeError, ValueError): + continue + if pid <= 0 or window_id <= 0: + continue + if pid not in pid_paths: + pid_paths[pid] = _process_executable(pid) + executable = pid_paths[pid] + entry = allowed_paths.get(_path_key(executable or "")) + if entry is None: + continue + allowed.append({**window, "_program": entry, "_executable": executable}) + return allowed + + +def _manifest_document(allowed_windows: list[dict[str, Any]]) -> str: + allowed_paths = _effective_allowed_paths() + app_resources = [] + for item in allowed_paths.values(): + path = Path(str(item["path"])) + if not path.is_file(): + continue + app_resources.append( + { + "executable": str(path), + "launch": bool(item.get("launch")), + "windows": "all", + "terminate": "deny", + } + ) + pids = sorted( + { + int(window["pid"]) + for window in allowed_windows + if int(window.get("pid") or 0) > 0 + } + ) + exact_windows = sorted( + { + (int(window["pid"]), int(window["window_id"])) + for window in allowed_windows + if int(window.get("pid") or 0) > 0 + and int(window.get("window_id") or 0) > 0 + } + ) + document = { + "version": 3, + "resources": { + "apps": app_resources, + "desktop": { + "display": True, + "applications": pids, + "windows": [ + {"pid": pid, "window_id": window_id} + for pid, window_id in exact_windows + ], + }, + }, + "allow": { + "tools": [ + "list_windows", + "get_window_state", + "click", + "type_text", + "press_key", + ] + }, + } + return yaml.safe_dump(document, allow_unicode=True, sort_keys=False) + + +def _install_manifest( + driver: Path, allowed_windows: list[dict[str, Any]], *, restart_if_running: bool +) -> bool: + """Install a reviewed manifest and restart the immutable CUA daemon if needed.""" + + global _DAEMON_READY + manifest = driver.with_name("cua-driver-capabilities.yaml") + content = _manifest_document(allowed_windows) + try: + current = manifest.read_text(encoding="utf-8") if manifest.is_file() else "" + except OSError: + current = "" + if current == content: + return False + with _DRIVER_LOCK: + try: + current = manifest.read_text(encoding="utf-8") if manifest.is_file() else "" + except OSError: + current = "" + if current == content: + return False + was_running = _daemon_running(driver) + if was_running: + try: + subprocess.run( + [str(driver), "stop"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + pass + tmp = manifest.with_name(manifest.name + ".tmp") + tmp.write_text(content, encoding="utf-8") + tmp.replace(manifest) + _DAEMON_READY = False + if was_running and restart_if_running: + _start_daemon(driver) + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + time.sleep(0.3) + if _daemon_running(driver): + _DAEMON_READY = True + break + if not _DAEMON_READY: + raise RuntimeError("Cua Driver did not restart after updating its allowlist") + return True + + +def _sync_allowed_windows( + windows: list[dict[str, Any]], *, restart_if_running: bool = True +) -> tuple[list[dict[str, Any]], bool]: + allowed = _allowed_window_records(windows) + driver = _driver_path() + changed = False + if driver is not None: + changed = _install_manifest( + driver, allowed, restart_if_running=restart_if_running + ) + return allowed, changed + + +def reset_computer_use_permissions() -> dict[str, Any]: + """Revoke bound PIDs/windows after a Settings change; the next list rebinds safely.""" + + driver = _driver_path() + if driver is None: + return {"driver_installed": False, "driver_reloaded": False} + changed = _install_manifest(driver, [], restart_if_running=True) + return {"driver_installed": True, "driver_reloaded": changed} + + +def shutdown_computer_use() -> None: + """Revoke the live manifest and stop the per-user daemon on server shutdown.""" + + global _DAEMON_READY + driver = _driver_path() + if driver is None: + return + try: + _install_manifest(driver, [], restart_if_running=False) + subprocess.run( + [str(driver), "stop"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, RuntimeError, subprocess.TimeoutExpired): + pass + finally: + _DAEMON_READY = False + + +def _daemon_args(driver: Path) -> list[str]: + manifest = driver.with_name("cua-driver-capabilities.yaml") + return [ + str(driver), + "serve", + "--permission-mode", + "bounded", + "--capability-manifest", + str(manifest), + "--approve-capability-manifest", + ] + + +def _start_daemon(driver: Path) -> None: + manifest = driver.with_name("cua-driver-capabilities.yaml") + if not manifest.is_file(): + raise RuntimeError(f"Cua Driver capability manifest is missing: {manifest}") + env = os.environ.copy() + env["CUA_DRIVER_RS_TELEMETRY_ENABLED"] = "false" + env["CUA_TELEMETRY_ENABLED"] = "false" + creationflags = 0 + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + subprocess.Popen( + _daemon_args(driver), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + creationflags=creationflags, + ) + + +def _daemon_running(driver: Path) -> bool: + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + try: + proc = subprocess.run( + [str(driver), "status"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + creationflags=creationflags, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return proc.returncode == 0 and "daemon is running" in ( + f"{proc.stdout}\n{proc.stderr}".casefold() + ) + + +def _ensure_daemon(driver: Path) -> None: + global _DAEMON_READY + if _DAEMON_READY: + return + with _DRIVER_LOCK: + if _daemon_running(driver): + _DAEMON_READY = True + return + _start_daemon(driver) + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + time.sleep(0.4) + if _daemon_running(driver): + _DAEMON_READY = True + return + raise RuntimeError("Cua Driver daemon did not start in the interactive desktop session") + + +def _execute( + driver: Path, + tool: str, + args: dict[str, Any], + *, + screenshot_path: Optional[Path] = None, +) -> subprocess.CompletedProcess[str]: + command = [str(driver), tool] + if screenshot_path is not None: + command.extend(["--screenshot-out-file", str(screenshot_path)]) + env = os.environ.copy() + env["CUA_DRIVER_RS_TELEMETRY_ENABLED"] = "false" + env["CUA_TELEMETRY_ENABLED"] = "false" + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + return subprocess.run( + command, + input=json.dumps(args, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=45, + env=env, + creationflags=creationflags, + check=False, + ) + + +def _looks_disconnected(proc: subprocess.CompletedProcess[str]) -> bool: + text = f"{proc.stdout}\n{proc.stderr}".casefold() + return any( + marker in text + for marker in ( + "daemon is not running", + "failed to connect", + "could not connect", + "named pipe", + "the system cannot find the file specified", + ) + ) + + +def _run_driver( + tool: str, + args: dict[str, Any], + *, + screenshot_path: Optional[Path] = None, +) -> dict[str, Any]: + driver = _driver_path() + if driver is None: + return { + "ok": False, + "error": "Cua Driver is not installed. Reinstall the Windows OpenWorker build.", + } + try: + _ensure_daemon(driver) + proc = _execute(driver, tool, args, screenshot_path=screenshot_path) + if _looks_disconnected(proc): + with _DRIVER_LOCK: + retry_probe = _execute(driver, tool, args, screenshot_path=screenshot_path) + if _looks_disconnected(retry_probe): + _start_daemon(driver) + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + time.sleep(0.4) + retry_probe = _execute( + driver, tool, args, screenshot_path=screenshot_path + ) + if not _looks_disconnected(retry_probe): + break + proc = retry_probe + except subprocess.TimeoutExpired: + return {"ok": False, "error": f"Cua Driver tool {tool} timed out"} + except (OSError, RuntimeError) as exc: + return {"ok": False, "error": str(exc)} + + raw = (proc.stdout or "").strip() + if not raw: + detail = (proc.stderr or "").strip() or f"exit code {proc.returncode}" + return {"ok": False, "error": f"Cua Driver {tool} failed: {detail}"} + try: + result = json.loads(raw) + except json.JSONDecodeError: + return { + "ok": False, + "error": f"Cua Driver {tool} returned invalid UTF-8 JSON", + "detail": raw[:1000], + } + if not isinstance(result, dict): + return {"ok": False, "error": f"Cua Driver {tool} returned no object"} + if result.get("status") == "refused" or result.get("isError") is True: + refusal = result.get("refusal") or {} + return { + "ok": False, + "error": refusal.get("message") or result.get("error") or "action refused", + "code": refusal.get("code") or result.get("code"), + } + if proc.returncode != 0: + return { + "ok": False, + "error": result.get("error") or f"Cua Driver exited {proc.returncode}", + } + return {"ok": True, **result} + + +def _session_label(session_id: Optional[str]) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", session_id or "openworker") + return f"ow-{cleaned[:48]}" + + +def _capture_path(roots: Optional[list[Any]], pid: int, window_id: int) -> Optional[Path]: + for root in roots or []: + if bool(getattr(root, "writable", False)): + directory = Path(getattr(root, "path")).resolve() + directory.mkdir(parents=True, exist_ok=True) + return directory / f"computer-{pid}-{window_id}-{int(time.time() * 1000)}.png" + return None + + +def _prepare_allowed_window(pid: int, window_id: int) -> Optional[dict[str, Any]]: + if not computer_use_configuration()["enabled"]: + return { + "ok": False, + "error": "Computer use is disabled in Settings > Computer use.", + } + result = _run_driver("list_windows", {}) + if not result.get("ok"): + return result + try: + allowed, _ = _sync_allowed_windows(result.get("windows") or []) + except (OSError, RuntimeError, yaml.YAMLError) as exc: + return {"ok": False, "error": f"could not apply computer-use allowlist: {exc}"} + if not any( + int(window.get("pid") or 0) == int(pid) + and int(window.get("window_id") or 0) == int(window_id) + for window in allowed + ): + return { + "ok": False, + "error": ( + "the requested window belongs to a program that is not allowed in " + "Settings > Computer use" + ), + } + return None + + +def make_computer_automation_tools( + *, roots: Optional[list[Any]] = None, session_id: Optional[str] = None +) -> list[Callable[..., Any]]: + """Create the allowlist-enforced Cua CLI surface exposed to Cowork sessions.""" + + session = _session_label(session_id) + tools: list[Callable[..., Any]] = [] + + def _disabled_error() -> Optional[dict[str, Any]]: + if computer_use_configuration()["enabled"]: + return None + return { + "ok": False, + "error": "Computer use is disabled in Settings > Computer use.", + } + + def _public_window(window: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in window.items() if not key.startswith("_")} + + def computer_list_allowed_programs() -> dict[str, Any]: + config = computer_use_configuration() + return {"ok": True, **config} + + computer_list_allowed_programs.__name__ = "computer_list_allowed_programs" + tools.append( + _attach( + computer_list_allowed_programs, + _schema( + "computer_list_allowed_programs", + "List the local desktop programs the user explicitly allowed in " + "Settings. Only these programs may be opened or controlled.", + {}, + [], + ), + ) + ) + + def computer_find_windows(app_name: str = "") -> dict[str, Any]: + disabled = _disabled_error() + if disabled: + return disabled + result = _run_driver("list_windows", {}) + if not result.get("ok"): + return result + try: + allowed, reloaded = _sync_allowed_windows(result.get("windows") or []) + except (OSError, RuntimeError, yaml.YAMLError) as exc: + return {"ok": False, "error": f"could not apply computer-use allowlist: {exc}"} + needle = str(app_name or "").strip().casefold() + matched = [ + _public_window(window) + for window in allowed + if not needle + or needle in f"{window.get('app_name', '')} {window.get('title', '')}".casefold() + ] + return { + "ok": True, + "app_name": app_name, + "windows": matched, + "allowlist_reloaded": reloaded, + } + + computer_find_windows.__name__ = "computer_find_windows" + tools.append( + _attach( + computer_find_windows, + _schema( + "computer_find_windows", + "Find visible windows for an installed desktop application. Start " + "here, then use the exact pid and window_id returned. Results are " + "filtered to app_name before the model sees them.", + { + "app_name": { + "type": "string", + "description": ( + "Optional application or window-title substring. Leave " + "empty to list all allow-listed windows." + ), + } + }, + [], + ), + ) + ) + + def _prepare_window(pid: int, window_id: int) -> Optional[dict[str, Any]]: + return _prepare_allowed_window(pid, window_id) + + def computer_snapshot( + pid: int, + window_id: int, + query: str = "", + max_elements: int = 600, + ) -> dict[str, Any]: + denied = _prepare_window(pid, window_id) + if denied: + return denied + args: dict[str, Any] = { + "pid": int(pid), + "window_id": int(window_id), + "session": session, + "include_screenshot": False, + "max_elements": max(1, min(int(max_elements or 600), 2000)), + } + if query: + args["query"] = str(query) + result = _run_driver("get_window_state", args) + if not result.get("ok"): + return result + elements = [ + element + for element in (result.get("elements") or []) + if isinstance(element, dict) + ] + _remember_snapshot_tokens(session, int(pid), int(window_id), elements) + # Structured elements are authoritative; avoid duplicating the same tree. + result.pop("tree_markdown", None) + result.pop("screenshot_png_b64", None) + result.pop("_note", None) + result["instruction"] = ( + "Use element_token from this fresh snapshot. Snapshot again after every action; " + "tokens from older snapshots fail closed." + ) + return result + + computer_snapshot.__name__ = "computer_snapshot" + tools.append( + _attach( + computer_snapshot, + _schema( + "computer_snapshot", + "Read a fresh accessibility snapshot of one exact desktop window. " + "Call once before every action and again to verify the result. Use " + "element_token, not a remembered index.", + { + "pid": {"type": "integer"}, + "window_id": {"type": "integer"}, + "query": { + "type": "string", + "description": "Optional label substring to return matches plus ancestors.", + }, + "max_elements": {"type": "integer"}, + }, + ["pid", "window_id"], + ), + ) + ) + + def computer_screenshot(pid: int, window_id: int) -> dict[str, Any]: + denied = _prepare_window(pid, window_id) + if denied: + return denied + capture = _capture_path(roots, int(pid), int(window_id)) + if capture is None: + return { + "ok": False, + "error": "no writable session folder is available for the screenshot", + } + result = _run_driver( + "get_window_state", + { + "pid": int(pid), + "window_id": int(window_id), + "session": session, + "include_screenshot": True, + "max_elements": 1, + }, + screenshot_path=capture, + ) + if not result.get("ok"): + return result + result.pop("tree_markdown", None) + result.pop("screenshot_png_b64", None) + result.pop("elements", None) + result.pop("_note", None) + if not capture.is_file(): + return {"ok": False, "error": "Cua Driver did not write the screenshot"} + result["screenshot_path"] = str(capture) + return result + + computer_screenshot.__name__ = "computer_screenshot" + tools.append( + _attach( + computer_screenshot, + _schema( + "computer_screenshot", + "Save a screenshot of one exact allow-listed desktop window to the " + "writable session folder after approval.", + { + "pid": {"type": "integer"}, + "window_id": {"type": "integer"}, + }, + ["pid", "window_id"], + ), + ) + ) + + def computer_open_program(program_path: str) -> dict[str, Any]: + disabled = _disabled_error() + if disabled: + return disabled + requested = _path_key(program_path) + allowed = _effective_allowed_paths().get(requested) + if allowed is None or not bool(allowed.get("launch")): + return { + "ok": False, + "error": "program is not allowed in Settings > Computer use", + } + path = Path(str(allowed["path"])) + if not path.is_file(): + return {"ok": False, "error": f"program was not found: {path}"} + try: + process = subprocess.Popen( + [str(path)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=( + getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + ), + ) + except OSError as exc: + return {"ok": False, "error": f"could not open {allowed['name']}: {exc}"} + time.sleep(1.5) + result = _run_driver("list_windows", {}) + windows: list[dict[str, Any]] = [] + reloaded = False + if result.get("ok"): + try: + current, reloaded = _sync_allowed_windows(result.get("windows") or []) + windows = [ + _public_window(window) + for window in current + if _path_key(window.get("_executable") or "") == requested + ] + except (OSError, RuntimeError, yaml.YAMLError): + pass + return { + "ok": True, + "program": {"name": allowed["name"], "path": str(path)}, + "pid": process.pid, + "windows": windows, + "allowlist_reloaded": reloaded, + "instruction": ( + "Call computer_find_windows, then take a fresh computer_snapshot " + "before acting." + ), + } + + computer_open_program.__name__ = "computer_open_program" + tools.append( + _attach( + computer_open_program, + _schema( + "computer_open_program", + "Open one local desktop program that the user selected in Settings > " + "Computer use. Call computer_list_allowed_programs first and pass an " + "exact returned path.", + {"program_path": {"type": "string"}}, + ["program_path"], + ), + ) + ) + + _ACTION_TARGET = { + "pid": {"type": "integer"}, + "window_id": {"type": "integer"}, + "element_token": { + "type": "string", + "description": "Fresh token returned by computer_snapshot.", + }, + "element_label": { + "type": "string", + "description": ( + "Visible control label from the same snapshot. It is shown for " + "approval and verified against element_token." + ), + }, + "delivery_mode": { + "type": "string", + "enum": ["background", "foreground"], + "description": ( + "Always try background first. Retry foreground only after " + "background_unavailable or a verified no-op." + ), + }, + } + + def _target_args( + pid: int, + window_id: int, + element_token: str, + delivery_mode: str, + ) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]: + args: dict[str, Any] = { + "pid": int(pid), + "window_id": int(window_id), + "session": session, + "delivery_mode": ( + "foreground" if delivery_mode == "foreground" else "background" + ), + } + if not element_token: + return None, { + "ok": False, + "error": "take a fresh computer_snapshot and provide element_token", + } + args["element_token"] = element_token + return args, None + + def computer_click( + pid: int, + window_id: int, + element_token: str = "", + element_label: str = "", + delivery_mode: str = "background", + ) -> dict[str, Any]: + denied = _prepare_window(pid, window_id) + if denied: + return denied + if not element_token: + return { + "ok": False, + "error": ( + "coordinate-only clicks are disabled for direct desktop actions; " + "take a fresh computer_snapshot and provide element_token" + ), + } + label_error = _consume_matching_token( + session, pid, window_id, element_token, element_label + ) + if label_error: + return label_error + args, error = _target_args(pid, window_id, element_token, delivery_mode) + if error: + return error + return _run_driver("click", args or {}) + + computer_click.__name__ = "computer_click" + tools.append( + _attach( + computer_click, + _schema( + "computer_click", + "Click one labelled element in an allow-listed desktop program after " + "approval. A fresh element_token is mandatory; coordinates are not " + "accepted. Snapshot again to verify the result.", + _ACTION_TARGET, + ["pid", "window_id", "element_token", "element_label"], + ), + ) + ) + + def computer_type_text( + pid: int, + window_id: int, + text: str, + element_token: str = "", + element_label: str = "", + delivery_mode: str = "background", + ) -> dict[str, Any]: + denied = _prepare_window(pid, window_id) + if denied: + return denied + label_error = _consume_matching_token( + session, pid, window_id, element_token, element_label + ) + if label_error: + return label_error + args, error = _target_args(pid, window_id, element_token, delivery_mode) + if error: + return error + args = args or {} + args["text"] = str(text) + return _run_driver("type_text", args) + + computer_type_text.__name__ = "computer_type_text" + tools.append( + _attach( + computer_type_text, + _schema( + "computer_type_text", + "Type text into one labelled field in an allow-listed desktop program " + "after approval. A fresh element_token is mandatory; snapshot " + "afterward to verify the result.", + {**_ACTION_TARGET, "text": {"type": "string"}}, + ["pid", "window_id", "element_token", "element_label", "text"], + ), + ) + ) + + def computer_press_key( + pid: int, + window_id: int, + key: str, + element_token: str = "", + element_label: str = "", + delivery_mode: str = "background", + modifiers: Optional[list[str]] = None, + ) -> dict[str, Any]: + denied = _prepare_window(pid, window_id) + if denied: + return denied + if not element_token: + return { + "ok": False, + "error": ( + "coordinate-only key presses are disabled; take a fresh " + "computer_snapshot and provide element_token" + ), + } + label_error = _consume_matching_token( + session, pid, window_id, element_token, element_label + ) + if label_error: + return label_error + args, error = _target_args(pid, window_id, element_token, delivery_mode) + if error: + return error + args = args or {} + args["key"] = str(key) + args["modifiers"] = list(modifiers or []) + return _run_driver("press_key", args) + + computer_press_key.__name__ = "computer_press_key" + tools.append( + _attach( + computer_press_key, + _schema( + "computer_press_key", + "Press a key in an allow-listed desktop program after approval using " + "a fresh labelled element_token. Snapshot afterward to verify the " + "result.", + { + **_ACTION_TARGET, + "key": {"type": "string"}, + "modifiers": {"type": "array", "items": {"type": "string"}}, + }, + ["pid", "window_id", "element_token", "element_label", "key"], + ), + ) + ) + + return tools diff --git a/coworker/connectors/descriptors.py b/coworker/connectors/descriptors.py index 32e103c6f5..0787e315e8 100644 --- a/coworker/connectors/descriptors.py +++ b/coworker/connectors/descriptors.py @@ -605,6 +605,23 @@ def _validate_outlook(creds: dict) -> ValidationResult: ], available=True, ), + ConnectorDescriptor( + name="computer", + title="Computer use", + icon="▣", + blurb="Let agents read and operate explicitly allowed Windows apps with approval.", + auth="none", + two_way=False, + brand_color="#2563eb", + logo="computer", + aliases=("desktop", "Windows", "CUA", "computer use"), + fields=[], + instructions=[ + "No account setup is required.", + "Program launches and desktop input require approval and are bounded to programs selected in Settings by the bundled Cua Driver policy.", + ], + available=True, + ), ConnectorDescriptor( name="github", title="GitHub", diff --git a/coworker/connectors/integration_tools.py b/coworker/connectors/integration_tools.py index 0c5c671461..0143d408e1 100644 --- a/coworker/connectors/integration_tools.py +++ b/coworker/connectors/integration_tools.py @@ -21,6 +21,7 @@ from ..secrets import SecretStore from ..web.guard import get_checked from .browser_automation import make_browser_automation_tools +from .computer_automation import make_computer_automation_tools from .email_tools import make_email_tools from .tool_defs import approval_for_tool, connector_for_tool @@ -553,6 +554,7 @@ def make_integration_tools( # Browser upload/screenshot touch local files but classify EXTERNAL, so the engine's # root scoping never runs for them — they enforce the granted roots themselves. tools: list[Callable[..., Any]] = make_browser_automation_tools(roots=roots) + tools.extend(make_computer_automation_tools(roots=roots)) # Email needs the session roots: attachment downloads land in the primary scratch # and outgoing attachments must resolve inside a granted directory. tools.extend(make_email_tools(secrets, roots=roots)) diff --git a/coworker/connectors/tool_defs.py b/coworker/connectors/tool_defs.py index d0a7a86045..92000ebca9 100644 --- a/coworker/connectors/tool_defs.py +++ b/coworker/connectors/tool_defs.py @@ -88,6 +88,62 @@ class ConnectorToolDef: "write", "Close the browser session.", ), + ConnectorToolDef( + "computer", + "computer_list_allowed_programs", + "List allowed programs", + "read", + "List local desktop programs explicitly allowed in Settings.", + ), + ConnectorToolDef( + "computer", + "computer_find_windows", + "Find app windows", + "read", + "Find visible windows for an installed desktop application.", + ), + ConnectorToolDef( + "computer", + "computer_snapshot", + "Read app window", + "read", + "Read a fresh accessibility snapshot.", + ), + ConnectorToolDef( + "computer", + "computer_screenshot", + "Screenshot app window", + "write", + "Save a screenshot of an allowed desktop window.", + ), + ConnectorToolDef( + "computer", + "computer_open_program", + "Open allowed program", + "write", + "Open one local desktop program selected in Settings.", + ), + ConnectorToolDef( + "computer", + "computer_click", + "Click desktop app", + "write", + "Click a labelled element in an allowed desktop program.", + ), + ConnectorToolDef( + "computer", + "computer_type_text", + "Type in desktop app", + "write", + "Type text in an allowed desktop program.", + ), + ConnectorToolDef( + "computer", + "computer_press_key", + "Press desktop key", + "write", + "Press a key in an allowed desktop program.", + ), ConnectorToolDef( "github", "github_search", diff --git a/coworker/server/app.py b/coworker/server/app.py index 2f55808537..84bb02004d 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1860,6 +1860,17 @@ def codex_signout() -> dict[str, Any]: def settings_get() -> dict[str, Any]: return manager.get_settings() + @app.get("/v1/settings/computer-use") + def settings_computer_use_get() -> dict[str, Any]: + return manager.computer_use_settings() + + @app.post("/v1/settings/computer-use") + def settings_computer_use_set(body: dict) -> dict[str, Any]: + b = body or {} + return manager.set_computer_use_settings( + enabled=b.get("enabled"), + allowed_programs=b.get("allowed_programs"), + ) @app.post("/v1/settings/model-key") def settings_set_model_key(body: dict) -> dict[str, Any]: return manager.set_model_key((body or {}).get("api_key", "")) diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 1486d9064b..fc3fb5e60c 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -61,6 +61,13 @@ browser_state, browser_take_screenshot, ) +from ..connectors.computer_automation import ( + computer_use_configuration, + configure_computer_use, + reset_computer_use_permissions, + shutdown_computer_use, + validate_allowed_programs, +) from ..connectors.parked import ParkedStore from ..mcp import ( MCPManager, @@ -259,6 +266,7 @@ def __init__( self._prefs = self._load_prefs() if self._prefs.get("default_model"): self.model = self._prefs["default_model"] + self._apply_computer_use_preferences() # Seed the PDF-fallback module global from prefs so engines see the user's # choice from the first turn (set_pdf_settings keeps it in sync after). from ..pdf_support import set_fallback_mode @@ -3150,6 +3158,57 @@ def _save_prefs(self) -> None: json.dumps(self._prefs, indent=2), encoding="utf-8" ) + def _computer_use_programs(self) -> list[dict[str, str]]: + raw = self._prefs.get("computer_use_programs") + if raw is None: + return [] + try: + return validate_allowed_programs(raw, require_exists=False) + except ValueError: + return [] + + def _apply_computer_use_preferences(self) -> dict[str, Any]: + programs = self._computer_use_programs() + configure_computer_use( + enabled=bool(self._prefs.get("computer_use_enabled", False)), + allowed_programs=programs, + ) + config = computer_use_configuration() + config["supported"] = os.name == "nt" + config["allowed_programs"] = [ + {**program, "available": Path(program["path"]).is_file()} + for program in config["allowed_programs"] + ] + return config + + def computer_use_settings(self) -> dict[str, Any]: + return self._apply_computer_use_preferences() + + def set_computer_use_settings( + self, + *, + enabled: Optional[bool] = None, + allowed_programs: Any = None, + ) -> dict[str, Any]: + if allowed_programs is not None: + try: + programs = validate_allowed_programs(allowed_programs) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + self._prefs["computer_use_programs"] = programs + if enabled is not None: + self._prefs["computer_use_enabled"] = bool(enabled) + self._save_prefs() + payload = self._apply_computer_use_preferences() + try: + runtime = reset_computer_use_permissions() + except (OSError, RuntimeError) as exc: + runtime = { + "driver_installed": True, + "driver_reloaded": False, + "reload_warning": str(exc), + } + return {"ok": True, **payload, **runtime} # -- direct-message routing ------------------------------------------------- def dm_session(self) -> Optional[str]: """The session a DM to the bot is routed to (user-designated). None → DMs are parked.""" @@ -4087,6 +4146,7 @@ async def aclose(self) -> None: await self.scheduler.stop() await self.stop_gateway() await self.mcp.aclose() + await asyncio.to_thread(shutdown_computer_use) self.audit_store.close() # -- automation (scheduled tasks) ------------------------------------------- diff --git a/docs/assets/computer-use-settings.png b/docs/assets/computer-use-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..dc72013b73fceee9bd35252f8d9af5271c16c58a GIT binary patch literal 90788 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 z z;IZz2S*;CqNQc}lFsSfSfAtFCY=X9*WZn&`vLD}T4vkqr!5@WA!d>b$>OAZ)7v~Mr z)e$FsK0mjI;+s3^46TUVM4#@Wqn9Q}-CeYP181yoog%JeZQ9WR_uz>C*q62ImM=QA z5;)-OwmxdCNz+oVh#xvn9CMqxTmRvpdxc`|pZx8lDOmDN2(G)Zln$neyGPCWRu#=3 zWr=cZjb(rG*S1j2-^c4y@R8A0|DJYTj4b_ibWr0A(az4}40GOWwG6TRs|b#ujVwze z(pMd*wU*$Jh08dm0{#A9YMIwblt_O>MMZ&V0v1UlTU%Rob@lhZfYK{78U*%(BJ3)NRSz($WGsE|SK3p1-psx=lhmq(@8WaGc9i zQsv0pA7}BxSFCSO>)Pz!x!}3I8`DLnRO0btiApv)s1kF7-$bS1Ch(d@3pHrYLld4# z`DGP9JvmSW1z+3^;3p)raRFB_%40RZn|OItxT2l+bEGfu@S^PH{rExh37D2ycYlMo z1d5p5^c4$yd1!d9^C2RQQF*WK#Ut{+?Q&kQyI}nq)ASfyXMQd3{@?H195SQR{`=i)B1Us9zaMSk;XNXM zVpR zjRCw+%hz5tRiXlzvTkA#fL>W(QDgTJNgcme<9M=GTKWb;uGqj&fG0F~mrhLP+D>wF z3uS|t`2E?$rw_mWO}P6Xj8hxvUP)XxYR4H_z!Z!j4E+)jji*$gVE4&$=gm%P!x?7V z9oW3IcrUxtmaTeYdAy600c&DZy(Q*-0mvu4D#IecW6i?^oW_Ip4#q2%xpEOx!tOP- zwF)NyBF$)1&NZz4fkZa!LOex0Pm?!P)nC0$NXXgC?Y7>3kkQ6pY&JSNY6BS6fl|8x z{Fc^M=ZSL0KXyIktH8*4pY5BO?^5O`p!mEa;q}5kjv-KlwQSrG=ACI-l6B20=fw-@ zXI9?N#Q7S^Pzy{pGrxNx5=e7r`OeEzw8ZI#n$+q}vv-kA!!tL#o`d zja_=hmR(6LwSUaZfbdx-=|#>U#K7J6HHduF&V;DZ7PHh&pVaIH-%g*w~x5VqfOeCnX~jb=dlWVTT6Jo`dGIEg~)7xGY&2 zT}xK}YPXzP&^cAY-FZ>ZLhtevIS*UnH1=B^z!(X-6bKSJ47r7BtPSOFZ3beKWxN(a z(uY!zf$!mjCx&NZ+WV;k2v*}Amwq>lDvl}YYHq}ZP;eRqo$QgfIS%J*a~Re{?TH0o zaU{@-1}QIr15*}S!bcKYg2(S}D}H`vJ(US~P010LZ#y^-xmp@GfFO(dZG0$vCc^xZ zGj`E@W!cQ6-ot>XCmtC{+|W?(Fd6^?Sh_ni+$65kqZ@A!Lq0hVqfK!2j+az%|y$W^7N?iC+?TkC?%WZ5v$K=VD3=v5wBkDHwNXb#`nbf;cavQeN)$mS$>TGYYvO;T zkiKXJy{MX-sG=@kDrr-V4PFd7d%|vjn-t0W>KCJ|@>rN~1d7`vfm~3g3HjEgl|(AL z%#@o5Ya&d7-qrEDZ1$o8KUu_SI*=x&mCx562j!8{IN|u$Eru<2Y86WtK+b zj_C$@Re1*P52_veWjG@vBBJ+)-7tzQE6L?n>k&W)>E}BwPJ+c^9nO|6Eq_=avSl>V zp}~G-19jC|Ffvoo1k?kFTw-P>g1ob%R5gdwIQRQPtZ1)otfgEv*EL0u!#Ie4Q_+VB z`Muv3C^ii8EqRJ`3wl}8I{+)Wi*t5sp9bkh>w?Z8qAHoajw{x)vOrCV5< zR)2*D1CrY4p>mhsHrEjT{9$hJQn)-v-~)%+}yN?5bZnCt<3XR zO6shW7U--+}m8^j$chyS{VWAAdG@_CHdi(N$(ecmu}1LlhnEBs$JUq z0-I$8A*FW-35$SqaWG4T5wl0!sj)jM2}e8c*I7svsj`1e$L?{o%_9N=7j>B7F}du( zlePD&9-#P@3~2lL&9N#L;L7?knD+khN221B!$=}bXS)YrX|w)VI~=~=eg|eEcI`)L zZuG(xHtxk=ZO>XSnQl9%qmH=QM}|s_x?J&q#H0?ay(Ge`r@895-#YZ$PAz^l>=gsy zuS|04SFmLC&iZTaT*BLt?f0SAG-kwVKatSRK0yQe?||M0yFOi^yh_@;?=n_d1f(*d zqm1v~Gui4EY_1)y15aQYztWP&>cF{}_gtx(WHg@)@B7z`FPD4cz+=P(MxOn29A?pY zJ~Hw&$gs|G7o7exHNN}9*5T~BPmm6M5}}ort}z{aHW%}*=&v5`?xN7S`B4j&D+nKl zrhb;GHhPG|GBqad4o-oK+;i;eyztT!m3FkSxZOBnOZOwA;ll&iBgcvIz@`oQAyWYb zgqr^Gj)n!(OP(ZUC8z;zJSs@jJ#VrtkRXBGNRi5z?PRz;J)(=nDk#)W;vDdz&W?rY zt?WA9W8OQ}iyrpr&B|vaG_Zg30Ucd}urHmL%3`EwsIvGj#JM)mb z868Rd`>TUCfYNy)%Jq>QT>rjLoGoGG1~rzo9H!4CIH;dp_=m%dsvQmDOHPY`V@RrzXMRu%x^y%uCy7Y^Y647@{L(`ZMVv{dTOex>QL0_{#NI9i z`q!a#Wz6c)?r!MkuLHJKWQ~_n<>^KU=|)OHp!g}2dY_3Y2#U*;z6&yiw&h;~M_|0i z-a%qTLWyzAGB$%Q1>B~w-4RMXhax6R-INI&M&oB0TM^k>7ykI!;GrLS8U1w$BM3-V zI1Mae1V+Jro{L$W_g=qWp3gDZhqOiCfjsdpuEj^DzkaP5bNyt0hLG?MF|j-~{VRxq zDQfTfRJDaSh;AMtO*Am|1Aa`>$&PsjhS#LLpJZh+Q3=fo4m6UkyiIwTJZP*5aF`4PR+v zWALETLV$1q=$`l=&Tr^(HfYMyid`)A7-gUy<^wCTve){N32FHTIOXJy92@++qU8vn z0&6S%WL90Vv5y$Rg9x(W6MjWXo?uV;4N8@Aysgat{NcWGc`AhTWCu)KT6C~7MXVmp zv1~UwZBIV783iqSHORPLQlWAluiFR0!w%wt10cekT4|a*pXcyv!Aj0Ev2t4QqfL^hFZLzgd`@E8D`N z2#(I=2Xcc$BIGF;uf_L(z02p#&1Gf^MVb7{)@PnN00fhSUd+daS}ZCgqHM>(12EYT z*I*c@cq@4BXA|S$`Vg$}*`uFkOOBwbfw)4@LgwDGB$1660~&?xzt*ZE164`*z8*II^h-OHGCZ0!_HTdCx84evu&)F1GxtPs4>4vGFgtPA6 z`}QlCv*X1$8mK|qd7^yU1f3^z_e}-sn5??LN$g5=Mn^41utRG#K2AxVrB9>oj6nZ~ z=Ew4w?Cv-C`n(k!5NQ0D2b@fOs`9qE^(#zO%5LMJtOBK*oSfYDRKxc4rI$hl68f@G z#7C%Rl*QXWCUEy8$KIw+w?trbW{N)ny zZIT25?Rg{3qR5S)!Iy_;&{JPfKOn)m`ssy+`#_$WzZ=qBYqK4csGg<;4#KAQCo~VH z#P8ZcLt_s78bHF9doj~sj%u2xx3@Q!@>~R(kdP-)*`gK7JqWAKR7)7)fzC5N{Rpn= zM=T??xa-=1oOm76v&gg(m-)chD87%PTxTmjUZ#15C(f_0E8f$dN=FVMGu1%uR+)eHLyn6lW*)eaQ>42nv^eIgz5&B$HtDA=I#H#veBZWxiTW4Nd_;P&aB^TA8uc*y}wXirs@Kt&W{=_qXq1ZE4ELBS3% zx!inWX&-D97jBn1&TUjyH_+a^Nkz}D4g@Rsy(A(_qIz` zoOk{jU$zmJujgaMg|Nm^l?<(*<4kw2Uq{}&xp4}wF^2zHxV%o8e8+pEtVb~M{Q09D$*IXhzvm?OXrt;(~Flsu;CPcSKco*AJMQmGxf*g!{>J-N}o zyu}DkJ$;GE{v3OEzpc;@@7`&C-y#Q(91vS8-_{mM_eavLmw?H#t`-F6<@@&BkOD~h zYb6dd*@0>EJ=qe$*TvgF$O|TfD_wE%j{^B2=hW##&Ss288LkS8zM^-3Vjwee@e|x1 z-7lbtEp}Qc>P>8Z3dk+jzBB&w{MI0zC}c}JNU^v~fT2fpy(ge%&v4__y1Tg?&iXsO z_|Z0)sq0f2`1nL3=*?sLH&Y=UWC+{3YnQGJPs}*3&#vbZJJmBO-A>$Z|4cIUrO_xS zxi*J0fN$2lGcBAAg67Qo1%X|eaoUlDmDihs0&v~tCnszmdXet??(SYXxL5RnxeVGt zLkD}JsN~;EOH2n}aM@S1ic(*@fRN_`vvCBXzoS&M5BxYlod882Hut|H^aHg1zIt_#tk1Gc1OzjyI{_{t0M?Zz@1u04nhg#F1OW_$LagQkc{w^2 zF63;t$fLo8XZ!V{`)_BLnxpv>pBJpvUIgvKr{$oIne&>0f$nA0Tv7{PJ4y`WU~Z^C zl_w>8Z8yH%W$2|a&BoQ~g$cr5hfARDqSj54}Ny03C-%UG^DDv73h()YcevZw*2piv_f(-^-R za54S&yr#KO9XpLeB35$p(Qh&EtNAMsD2MVQ_(fZ5ece{iZ;*i>r4lq8gg(z z_Gez>3nJC$4Ulm;5gPTH_!_(X!zkUR$9mL3b@NHh91Kr`L`cF8{0eCwe31dg8%{})QL3;A zN@<4L=>cNr;|z}92Nr*`;l|hUk~~kQ#ozik-Y{-S*Db0nq(g~BYQSBNK z^21{lyn~S!JtQ4_1TUA{0>slydW0;7{^%VfyI=|fxQRRN%7~3V9iVATD?GC~FL2!; z2bk)e&&HzkT!e{6CRKVe?85T>nAVG*x|7=sa&()<$AWgW%98UZJ?6%snh5#PJHw7Q z%PVspd3cG|h;|r2`Hv=(yn^DnSQGLaKr&qyygcmq^eiDf95y_%d_Rm1lDZ{73pyzX zpo@NZ6W&r z(3mdaKjjaRsg*ZyQYb0}Toc*RV|zAynl>LQUU1$^0MaS>Rf%2$WqyfkLu5fo2y^+W z@Q@IJ<@(u`rximDu}k5omS;Drjv~rC4AB(!7{S}+>WOVk<<^IsP7Kxd*4*M0A?A)C z_9Wm(grDKozu74NQ?G0Q2`kTe#;yMB**TG*x@TF?Xyxd`4R~R{w@`J^A7tH11?U5W>!gWI}X{>&Ze?EB1#H^i0cql#? z%%1R7A1s}a))=fOyl;%~zFA1xm~cmWdly5mkiX$b8kTS6C=562^T{E^W?Qqi^ zE-odXheZxqYKJ~21sY}Q5u5p1uAVkv2`D<9yFUtxi;X$W;Km^2IS8!ZFp3VdvbMIg zv7$)rNvD0pNt>%G5KUly#jscYh<$@aYljgH2gZsJ`j+|=CQEMBTZ9@kw`V#RJe3ce zS;8OFnW^XFHX*>vC@TsnYoN_wqKqqelcd_0Zb9JMmP`(LJMw35eVpZILL@r%+2c9O zNiJTz7+1Q-G_eZKe|86-YrhD42P?jqMLkcBc{0*Md{HdD-*Df}_X)$S8wJ(X$hBAR zXo@V_dUsN<5SX_oe>-QEDve`*D%~F_*XE$xc*Pi?w!cRo!cWfX~AkS(%y>7X#@APMsZvUcAKS)3f(r(+oV! z0X!0coH5k|CASF@%tMRNWfKiy`FQ8WJd^c^#=Cken}K$|+jjGm;Od;DCBVsPQ&H)~ zVIY;Ml$vmd#Ol`glv<8@zME!(a8)H4n^rK7d5ry#Y1j8B&%@IndaZb!xg5c7pbP|@ z-W$#bhJ%KixQPapY=dTQ`@uajmI(L#p1X|A!MqAXrTE}P9juu(rnM-r5S>q(Hf4^o z*86n{`(=CXbG+J#=yBzMth~w|t}+u2Tk}z^tj~5;PglpaeZjeu0?X~WJk)RuT-EPz z>&;v@$0$1*qzQ~O1y=3jU%Uu5t-i3xoS#lr>ci42JNL=GmEt){D1X8elj+^t>+jDP zBN^EgJM{Lr$1R3CNF)P;GQ;G5k2!^5^(A8kF}kWjrLp~KY8(?nHvRDKJlEZ7AMy?vB@hpWtw}KCqDunki!`=Mp5d|>pJ z+56U4a$VEYV$6eSFMc&>L8&lH~czQy;KnSWw+v%R@us1N+47Dr~TQkl}Uxa@8$ES7fN zkc~kCs$3=ONb+e~g$^1eu;{D1IJ3R@3eb(ZX#TK@3;6(?APXO7KngIKSPvV~ zp^pY_=<;kwsyr+G%OfxA@uugv)zjOqmroA&-RLO4}-aHN<3T^Q6OaT))45z?0y$ab`(!M1%>jV};)AH9Q#)N=ZexxS*Q zOit^#$hhZ#IEnq%k^9PQ?|s2n-dw}I`KxUKXF7)jS2xzJ)QdWT$DRB%b32*7xTYou zGBj;ee{~N^0fIJ~qQG0E$1di(%da0*FG?v*xtI)yk5h)UNxE?3?VpF-M4dn^*@!(` zM>XgN8eC@7kUJtTRJsn2y=2+du0fF4n1G=zU{5MBes9iEiaDXdP;T7}ZKOo+3-#i1 ze8N~+v`e2s`3zr5p1>6k55&8ujrkr@5|Th#QNNpyPZQHDS9_fy&loCL-r!%fo7;c+ z+{@GZY{*KX^_1yQDF(1m371>R07WgmeG531R5UmCo&kVkP<9~4vW-DJv&MyA*|8N& zsr&rZWO*a9V*_>zy?KrZxnHmh z{=+*^;q;Jm=0E~A5^6UL&&+HOIxp7PS7z^;bbp70yzOi|;8Euf_HyvM#P_=-GQ0e* z@!nL=OMAzx@%{IgP@hDJ`o~b8>D;G-v!lB*rs8SyUz8HA=Yvf$)x^SFVSe(6Szfmb z2FDr@=dNwI`iqF_GN+eXUVfIemz_GsV|7 zEF#;n%nVHl9R-flm%)NcM>Y+`EvX@JDr-h=fYS5%_+f+N33*6 ztF6zg1JE&I1=knzJ42Y1_r^ADfiypIWZ%r}z>W&aXnFpS6^V0wn?$g!*SIUh&E2WX zzP-E4pK1E@V+F?9{mybwad9y!5O~+J&I3yj5`jt*usGSBLFr=faCF--)VHz8ZDd?c zEqgwFJvlVOojw$1{^J}%g0jwfG{0n-(zQuAwjBEH8O@3Lw2qXw1w!TFZ*oUYh@t<0 z)fhxa!Dap(n;^|4p_8Hb4bC?eMKq@(!8$hNwS<<8a7lyBt2K!U@WK?!yvDBIIDCbP zxuP*p3zncrk{$Jt6T$=l=f^A4MP)GOM*8PjB&7(q%Fq zm%k_|2?~n#3SNvnQMpqnRVKA)Lue*!K#pl{|snmGN@h_HP0XulDX07Mw_ zxiHbX^j?GKW1MaEQF%tf8tvohq%vgEM}jits7+p_kN1Oqg1AAhS1jtqyl2D7danFS4}}5mWWJFMUUq+U{l)6cp%^eeipC@5*R!BFz5Gx2U;ZS`9{f zk46+9g9d=2vjnM$R$>rxT3k;xn;O_F4zE>kYqlDEMGs+(oJm$x=}H$-6Ac-)+x%8? z{pE(bwP^ku0529pJxGXAi@0&wt!I`ovl@cz50KST+toa$oE{S+NAL=mBVaKL@q-~$ zg?9KIke@?MRZYjUkf3ag;018@s_sty&_M*SEuhVATP0_)eyt~t%GKT8DdS<1efcd2 zKrF~~ui}uGy85YLyA$BO^^gQM3|B!N&2wc`iXkUIY)aTR#)`owbtWW@WkS;QAHf~G z7E2a|R`CM+#oVXP*M~69OxW_QE&msA$5LgrBv@yhJ1?m9_0ba=bMxBM{?zQ- zGfmITd?Bm3>I=czz;scO$MKSp;JNtuwP^Mk3g1ca7>k!6YcoR6MuFKU0C7;)42ZI8 zRy^$Nv*3I&0udFfsUoY1lsge15Y@HI(OYGB@Ze<=eevdeey<6r970T!=?_2yxG&w0 z7M*2?42>XXMs+u|DX@Itx&l3wBeD^E+7OGV9coY^%HGiP~|aiKf0ut+2%5yP&Rc4l>d%lE*!^Uh-N!7 z0vu$f26!EJq@Z9CovZxRO|LWcWyxmtBn1F!Et4jKamH9T74P-c$-2uIE)2cISy*hO zsX={aiR?)jtEM1H1vS;n*9QN5lAYI+7R#$l{|qjHI9 z_{yJ@KJOZY9EsR(Av*i#BJ(V}LuK4C@|v9a-G5n;=NNa z@j$BF*I9a-Gu6}_Ugj{n78^effD9Oy^SO_-e!fB5tXXj1rTt2+*GtCSgW;!jAvMyU zMyEf9*zCUcBBm7FnQj?_48<0>3>aah8QOqjpg~@`^HS#EL9FPbM|{m}FU8`wsEtyf zI<$FDUYlcdg{VLA_Iv{vL=w!^2BJaJ3Ze9YB5To`B;ofhrg2K%;AFrJxD`qEhgi;q zSYP$_N^u2)rkMQctvUs5|LC>dSoc(~F$SrSaM>unRm-vpl7(ciSodXY@6bWu31S-F zP@S@UjRRR#)z=DW+HZ7q1NMW_kX&opbHv5C5|OcJ-ZQNKpqS5pN(@`JWt4X9YgyW! zQhFhYyX#jz7A`{E+T2AeR2O>$;m~`?d1?e`B}#6`yUVIWfYoZM{)?Tx)qU%Yk{hfU zVk58o0zXZ6!+ipxd&S&WB_+wTcBsVBc#T}xmM>Z)F67tjx-yo2`96&mb1YlC2^|~+!y%lBl=2gMS*y@MumP9 zqrsbR)_3*l<%!$dbFtDa5nM*?pJk+^OuAxRdog&CmwS#A8)IpXS6dU!dkadRH=*74 z*0+cE!`bz2KS}!u!BnsvOb8{Yha=(@-#sIt<4RIdxBF%k@*XUJ?4Z#7#zyaV%elyO z_4*u(g&kjTB5irW2^BojS#KLgb0$muRA6Y?-wN1Ln*9n2Sw?cupi2TEVm zosN7K1oR6DqoVN_EyQi5Y=*vo6p47NBg<7EcNuJtpxAN!r+Dr6D|yBk7?;B$>on<0 z^HC9>%e7)%PnyaNSL^n^YCpuYT zxA2A}c581t%vFOa+`2YH;6Q8mDk&}ftT03wERfaj>_wI74A<>EdIw1ag z3>K@Nx4>^9moTvfyi5o_P}*j$wRF+r3rW0qd4$Wqw=OQw+S#XLKj;<7St=n*kkozM zjBL|ohr$=m?MoF+UiaCQ)p$yZ-3sm_(DQM^kP&rk6<&A8}27gu^K`OHEecYP}LEI#h^4bM5A>74vQ7i_@gm@*5)mE7=I;q#k) z(Lbbvne1-WMevxZs>qA2XT%N-SXeKXdh%M2-**}RRA=J1c*$`n;uvB{@!m;@j+bi! z&eWH)>G7;g)npg8u2d6X3=G@n!qsV+f1Kh)8tmQehVjd1G5s@zYfe`ftcTcrvCqK{IKowqLCi|aQ1fDA;i})gLZI32I4rm7^{j#M zP@Y-egzCtbs*07xm!96RP*3`i@BhO(aI-FIicLdt3TircO)z(Ts|0=hmTF~IqprVA zk$VtBxJ*CKf?ILW??ydT_v|YIVW#Hm(s;<%^Ij%Vy-+_L)dt9RhT5)ZnV`UUxMr^I z;}yW-hm*e&9kw4BMo&&B+xU-FWN4jfXaI4P+lo%DLZBU$umikb&_0Srl?8 z5Y60wFC8F7=U$4G0GEOPm_yZ;(O>RT=Zamz6N8xpY*mBfgKNqDaSm0Y^+p?Mts5iP zkp1v4Z`YH6jb8@R9lOY`E5>6mc=H{w{^{d)fS!>bbxtzvE8I;D5VyaOsvKVl=Aj)k z2bkWqzC{N6?a%&^HR*G^i@a`zSwOmwCN@t0+7^CSBjWy4yP04u3IK3e7>FLVVi;?C zLCf2gcp&#~RS?&sgj2qwZ8kQ!aO@;;7%zRm2Okcm<>+cH)6N&=iL|i(sE=lLezkL! zs=aGFWB&20z;XLpwUlnE4siGKe05Zv)A-w<>AF$Sy|L28^}0lylWWzEEnQN!>K&kt zT;{M{bS$Quos_X>uPJ9pi8~H1s#rrnm?^(ow`}y-lA@RBBPccU(wg`>{llu8hpCT| z$0F6bQn4bAiacgs-!h)Ffe&(DAM=AREidFnzmwhg^3;{+Y&81_B{4Da#1nD8;mOGa zE$@j&{hV{x*s~RO^E=Kg2hy#6lF}D-jkzao%}J5Y_Xq=kT-1L~9DcKiZo)>EsCrg7 zURmFW7P@wT6>}`L;fuE3yqzpNYEY}oJ(t@{wDDD9{zM;@63`!fms};tWaX?8Z2n;j z3P&`E2-|$bKCPt_ebnl2;^$5hPp{se2_X@>>+&iPaA{nR^Y72RzCJ!p9|TT~!q){d z*CeLb#ZCAg7z4T&Cs}JJ@jl+gKJctGi3($#!Bl{I*9zgIMrq}~-Nne`6bMa44O#zY zQi1PFcVO4$|6vgo>swH8Kd0wrk7(eji-DJ@NXHf?)_-C?YGBXg@guavT$svNAHp_L zT$OX+&se}8NyAUkMOq?)gKX_;zX)$dARin`b_v@y4GKb@?O*fCp$IB7K{g5&tn$2n zW{3#S%JR1OHbWM&)c@kZ#2*zPVE0#F_qSxz?}R;9468YttWSr;n-icGVlbch)9&C8 zBZa1+=GqGe;}0LgCo%qg(#sYEF7Dc~pHw|tbd@0-lXBC1=-=~-@1V*s!eIv~FkoBc zku)*LOo2(BB(uGqCOHsb#}4) zHt=1Kz2J%f|KGoz>7gX?9VF9v*efm}z`UNd>Bn8z!4C)25uMt;e}jhU(s00G8xvW8 zMAtv^UX~@K(aLE9h)Gr~9||hoE~h<|NfA$uWcd5N)4OdCC;Dxi_O;8$kN%{mmy!vF0|sISJuv2PwOCU?WJ8pyY`qJ%C%{VLJB7;98aN@B(t0saOayC3~eue^L9$nD_h zbi4d{8zG-ZT!l9iN_!D4Gq*4^GBT0@f9fYCHvk=PIF`)bK3Yq3-s6@hEhMLqoYBgu ztDO;2lS!Xc#k1F}v{ON~e)@UNQN)r}HluQC$FrqX@;tf0&aAGz96JSuvLp8s|bgm2` z9p#wRqIerkio&Vphacc>EM;5#$I9VCCmb05oXDTxi~9CvOgu-~OJ7X&d_!tiLoX<~ zv^5KX31oKyZAOUJsbv3l3HWQdxm*b!Laz#X>fd5LR65azfZOL+u#1^q8Cgps^^H5GBkML zpOK7*)v)2!{~=c$^KI=&gvrbwH*Ne6YZZ1YgI1aO*)ZN*Wep1y+AKupP z!|xNctmIs#ZBLoFM=>^;kefckn-%FkF=$U$M;Q3jK#af`fU~sw{l;DY8k^9#TWc|# zrbhcD^jWH$+`$8d#i*<3sSNc}jccwe*^V|uWh@mn9sFbMCk!^vZJFsc4k{$({{AdMZnOANicuyA{@x+mvU?&D9t(-rrLLSLaV;5X)zNBtOjb(!L`Ht?VgxDjI?)GdW~atI zfjmn`|NPQZVR~ou+v;sPZsKNBj=J<`Cv-5Kt|~tUbHm|;e2N66#Fn9*g%wOw68mOb zS^tSA#hL79!kHyI=hQrvTFViG3^lr-Uc4qM5-WJn%JSxAx~q&vo<@-#uVc?0dKH|i zii!Um4+lXkirDu#w45cqKx#nQ*}0jHiZL^LUn6a(R$VpJw=n%UM`n(U!VqK8CV0)c zFG6c2Q+=;oncpwm1;e@6u^vb#saX}OGsaxKR&CW&rr8iB!m%0Tv3A5o4o>btMOadl zlj_{<<@593O2L?yb-9e=uolMNmKR%2_SIC?bZISjvorWsr=)dLPkkCPQV2EYSbH&v zA8G#$J>JOG@UiE~knM6%V?%A5=}yrn^$}$lrHGGg51ya11pj9`THMb*hbZNHykv8EpY@7rJ z(c>251Dp8MzD{pT742`%IKUB)1DzKznG$~fv2^v{k1inuW6b`3oXT-}&$;PX{B7)x zaL~Yb@#v+u{rmSV>D#)7a%MQh$Fxh#CcsIDAOG=SFe#s-em`f4B|L40-yJ@1iIp** za6Wc1@0kO3#L^FEkK8!G z{?!P7^#sIU!vQ9PzZ&8Hi$-86w(6KY++}^O=RSKi3gLeJG&@|mNwDalw&Q>336Wiw zpq#AO+grboyq44&5R;sdQLm_Cz#gu_s;F`CqSglwcdyJxC05%KU#Lc+%e z{UR$RgI1>eFbPv>EK846p4`v6Lz-%>h2e~Gi!f3kZFMGp zf0dVkcm`2!fGRtx?Ck6;jeT>h)4I%UWsFbf%syYuUXSkW|V!z_OvN3CG9M%U5-YW57 zrCbYpIZa!Ab^Ow^)cq|jmb=lLjr$Wp6B`@&gn8F~9>0`~jMB-X$c=8-F4xT-W~#gP zgRAyK)U z{pVN?bvVYRCz}n2o;p=arTNodzkuGnY&A!H(P3fz1i$Q{JVG6n5N&BB zT=kLJ-Nv@dq%2}rBcf`5_Pb}B6o_y$n@bilnuKx`C4@vwO&5lu?Uf_?aF~ON^@Av! z{k)-mWzpoD$Tk&1^zv+)TY7nlBA?UFN(Hpm+B(jeM#H|X(KMf$!*F!#+<8fJ*<0e= z8?2(&oR)aWU3Pm4S2z0cX9lLUowhsl8k99wqJ37jT`0Z-K#M0b5OAs=x>hz&U@BtH zYlQERG8~M!>sB0n%RZy7Xg<(AEfd^*J49(>b9QmX)My4b-#-00!zqomW$&~U!IAE` zHc#vuV*braA4*D<#LfM^vPGep$XZ;^W{mihzLw|yMD}8=LnV)v%W{Z(md#|lf>q)C zH!6mb(CT8jpd}_t;yBG zZ!|_mr)#NJyLkF-pkm>?;C7#vPp$tDckIE7gS2S7{--0A^cbnYn#?J!PY0L=gT|Ou zMa_ZFQj?^{wPY+>?1rNo1Li#Xjng>E=Xt%^b2{xmS*!~CVWjf$@ww#;V;!3hc0&7b zQrfaY9Qo<#rZd?SGjxW6l5fDxo8&OXelVP z^|$ufq54b&ChHs6`dohK=MPwJ7Lsd~ShUWlrTBVb^d{wT${kqoPWFNZ*-l zYGzgGFTdKlF4>8jH!E&3evJoKkklWuRxC8_WW>g0H?Pdr=$`Cc{ zdE~Imp_YAz$G%4dV7kcN+i)JI{m_sBAG5ziRB{FJ#_OX~rM-q@L5I1aOnh?& zmojE+RlvS@eWSS5F|a9&Q{l<&fd1I5z0pjbj=wXwgYu~^dVm&>rE;HdX>M}Om~)u^_2x`-tlc`A zXK<@6ywC;rllJ_;^G(g5CbVLleUILXp+S^=y?Cv=;SihMkn^-QwN3ShRGAn)3L3c7TUcwbxVtbBYJyT;3Pw4$VTzQo6WYBms3dOaIjlPRC9 zp{_2rlbB_>K3ritH-AHkDI~jRen&g-lX9YqpteHJOCD1rqm0zu+cnYdEkab5-4(lw z`LeC48ygZZ+eNf?@|FR}`sU4zQznh|N~zxG(q(mizoXsK-R2JVYlugknU__QAKvIH zDB_K6p2&_hOUbixv{h(e*iKY*#SgE`Zsfk)`FMlU?*u$vw!MW)HwvEn^+NZzo)S1X zkPLHkTk)%_(Pi(L>q-p<%S!~}d-9t73FT~knpAmp$E{yGv$LhRB=DOGO@Glim7P|F z$w45FJQ08NK+$kpqSC<*SHdfc#29tVK8ee_zxS)`3YV47aku8Q@@Lo82o{wtBBrlA zLYTU}N7-T7NRR5@NnmVyG$*;ao_U(j`FO&*N3TJJ ziz|Okke%w|c}b#F0M4O*Ja*qKpY2rpo*n)yE4@(95gYM21lB*)OlgWtrV|_T$?0g- zM*Zw{Poj1FQ(?lt?Ef&$_6U*4Q8YFEB439_heSq1^tD+i&8S+JGD)j}wSbvix((r~ z%M-I1k12<7K5;}|fL}eNtDOkNDgK9|1i3>n5g!}`xkYfpXPzW9?lA;g>8bk;9LF07 PUqk${%%hBlI Coworker NSIS setup .exe + .msi (resources copied in). + 3. Verify and stage the pinned Cua Driver Windows sidecar + capability policy. + 4. `tauri build --bundles nsis,msi` -> Coworker NSIS setup .exe + .msi (resources copied in). Prerequisites (see the toolchain notes in the PR/plan): - Rust (rustup) with the x86_64-pc-windows-msvc target + the MSVC C++ build tools (link.exe). @@ -62,13 +63,13 @@ if ($running) { Start-Sleep -Seconds 1 } -Write-Host "==> [1/3] PyInstaller: bundling openworker-server ($Triple)" -ForegroundColor Cyan +Write-Host "==> [1/4] PyInstaller: bundling openworker-server ($Triple)" -ForegroundColor Cyan & $PyInst --noconfirm --clean ` --distpath (Join-Path $Here "dist") --workpath (Join-Path $Here "build") ` (Join-Path $Here "openworker-server.spec") if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed (exit $LASTEXITCODE)" } -Write-Host "==> [2/3] staging sidecar resources" -ForegroundColor Cyan +Write-Host "==> [2/4] staging sidecar resources" -ForegroundColor Cyan # Onedir bundle (exe + _internal\) ships via Tauri `resources`, landing at \sidecar\ # next to the app exe — onefile's per-launch self-extraction cost seconds of boot splash. $BinDir = Join-Path $Gui "src-tauri\binaries" @@ -81,7 +82,38 @@ Remove-Item -Force (Join-Path $BinDir "openworker-server-$Triple.exe") -ErrorAct Copy-Item -Recurse -Force $Src $Dst Write-Host " -> $Dst" -Write-Host "==> [3/3] tauri build (--bundles $Bundles)" -ForegroundColor Cyan +Write-Host "==> [3/4] staging pinned Cua Driver" -ForegroundColor Cyan +$CuaVersion = "0.20.0" +$CuaSha256 = "c020fefee01aacc174a27fea84a0cb77d47ef8290bfc772b3db7e3e06670d2b2" +$CuaCache = Join-Path $Here "cache" +$CuaArchive = if ($env:CUA_DRIVER_ARCHIVE) { + $env:CUA_DRIVER_ARCHIVE +} else { + Join-Path $CuaCache "cua-driver-$CuaVersion-windows-x86_64.zip" +} +if (-not (Test-Path $CuaArchive)) { + New-Item -ItemType Directory -Force -Path $CuaCache | Out-Null + $CuaUrl = "https://github.com/trycua/cua/releases/download/cua-driver-rs-v$CuaVersion/cua-driver-rs-$CuaVersion-windows-x86_64-binary.zip" + Write-Host " downloading $CuaUrl" + Invoke-WebRequest -UseBasicParsing -Uri $CuaUrl -OutFile $CuaArchive +} +$ActualCuaSha = (Get-FileHash -Algorithm SHA256 $CuaArchive).Hash.ToLowerInvariant() +if ($ActualCuaSha -ne $CuaSha256) { + throw "Cua Driver archive checksum mismatch: expected $CuaSha256, got $ActualCuaSha" +} +$CuaStage = Join-Path $CuaCache "extracted-$CuaVersion" +if (Test-Path $CuaStage) { Remove-Item -Recurse -Force $CuaStage } +New-Item -ItemType Directory -Force -Path $CuaStage | Out-Null +Expand-Archive -Path $CuaArchive -DestinationPath $CuaStage -Force +$CuaDst = Join-Path $Dst "cua-driver" +if (Test-Path $CuaDst) { Remove-Item -Recurse -Force $CuaDst } +New-Item -ItemType Directory -Force -Path $CuaDst | Out-Null +Copy-Item -Force (Join-Path $CuaStage "*") $CuaDst +Copy-Item -Force (Join-Path $Here "cua-driver-capabilities.yaml") $CuaDst +Copy-Item -Force (Join-Path $Here "cua-driver-LICENSE.txt") $CuaDst +Write-Host " -> $CuaDst (v$CuaVersion, SHA256 verified)" + +Write-Host "==> [4/4] tauri build (--bundles $Bundles)" -ForegroundColor Cyan # Auto-update artifacts (NSIS setup .exe + minisign .sig): produced only when the updater # signing key env is present (CI secret TAURI_SIGNING_PRIVATE_KEY). Keyless builds skip # the overlay so dev builds keep working; keyless RELEASES strand installs without diff --git a/packaging/cua-driver-LICENSE.txt b/packaging/cua-driver-LICENSE.txt new file mode 100644 index 0000000000..b8b198ce3e --- /dev/null +++ b/packaging/cua-driver-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Cua AI, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packaging/cua-driver-capabilities.yaml b/packaging/cua-driver-capabilities.yaml new file mode 100644 index 0000000000..995baa5223 --- /dev/null +++ b/packaging/cua-driver-capabilities.yaml @@ -0,0 +1,20 @@ +version: 3 + +# Bootstrap profile. OpenWorker replaces this file with exact PIDs and window IDs +# for programs selected in Settings > Computer use before exposing any window state +# or input action. No shell, filesystem, browser-profile, process-control, or +# desktop-wide screenshot/input tools are admitted. +resources: + apps: [] + desktop: + display: true + applications: [] + windows: [] + +allow: + tools: + - list_windows + - get_window_state + - click + - type_text + - press_key diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index d96c3c6f4c..c378b3d6a2 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -357,6 +357,21 @@ async fn pick_folder(app: tauri::AppHandle) -> Option { rx.recv().ok().flatten().map(|fp| fp.to_string()) } +/// Native executable picker for Settings > Computer use. The selected path is +/// still validated by the local Python server before it enters the allowlist. +#[tauri::command] +async fn pick_program(app: tauri::AppHandle) -> Option { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = std::sync::mpsc::channel(); + app.dialog() + .file() + .add_filter("Windows programs", &["exe"]) + .pick_file(move |p| { + let _ = tx.send(p); + }); + rx.recv().ok().flatten().map(|fp| fp.to_string()) +} + #[tauri::command] fn get_autostart(app: tauri::AppHandle) -> bool { app.autolaunch().is_enabled().unwrap_or(false) @@ -724,6 +739,7 @@ pub fn run() { )) .invoke_handler(tauri::generate_handler![ pick_folder, + pick_program, get_autostart, set_autostart, get_keep_awake, diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 6bebaea4e9..33e3b81219 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -257,10 +257,10 @@ export function App() { const [gateCreate, setGateCreate] = useState(false); // Which Settings section the full-page Settings surface opens on (§ Settings-as-page). const [settingsTab, setSettingsTab] = useState< - "appearance" | "models" | "skills" | "voice" | "memory" | "personas" + "appearance" | "models" | "skills" | "voice" | "memory" | "computer-use" | "personas" >("appearance"); const openSettings = ( - tab: "appearance" | "models" | "skills" | "voice" | "memory" | "personas" = "appearance", + tab: "appearance" | "models" | "skills" | "voice" | "memory" | "computer-use" | "personas" = "appearance", ) => { setSettingsTab(tab); setSurface("settings"); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index d87322d361..5566619e8c 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -908,6 +908,20 @@ export interface ModelSettings { compaction_model?: string; } +export interface ComputerUseProgram { + name: string; + path: string; + available: boolean; +} + +export interface ComputerUseSettings { + enabled: boolean; + supported: boolean; + allowed_programs: ComputerUseProgram[]; + driver_installed?: boolean; + driver_reloaded?: boolean; + reload_warning?: string; +} export interface PdfSettings { pdf_fallback: "text" | "images"; pdf_max_pages: number; @@ -1677,6 +1691,22 @@ export async function getSettings(): Promise { return res.json(); } +export async function getComputerUseSettings(): Promise { + const res = await fetch(`${httpBase()}/v1/settings/computer-use`); + return res.json(); +} + +export async function setComputerUseSettings(patch: { + enabled: boolean; + allowed_programs: Array>; +}): Promise { + const res = await fetch(`${httpBase()}/v1/settings/computer-use`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + return res.json(); +} export async function setModelKey( apiKey: string, ): Promise<{ ok: boolean; error?: string; has_key?: boolean; source?: string }> { diff --git a/surfaces/gui/src/components/ComputerUseSection.test.tsx b/surfaces/gui/src/components/ComputerUseSection.test.tsx new file mode 100644 index 0000000000..e894ee26a2 --- /dev/null +++ b/surfaces/gui/src/components/ComputerUseSection.test.tsx @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ComputerUseSection } from "./ComputerUseSection"; + +const SETTINGS = { + enabled: false, + supported: true, + allowed_programs: [ + { + name: "Editor", + path: "C:\\Program Files\\Editor\\editor.exe", + available: true, + }, + ], +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("ComputerUseSection", () => { + it("adds an executable and saves the explicit allowlist", async () => { + const calls: Array<{ method: string; body?: any }> = []; + vi.stubGlobal("__OCW_PLATFORM__", "windows"); + vi.stubGlobal("__TAURI__", { + core: { + invoke: vi.fn(async (command: string) => + command === "pick_program" ? "C:\\Office\\writer.exe" : null, + ), + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + const method = (init?.method || "GET").toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ method, body }); + if (method === "POST") { + return { + ok: true, + json: async () => ({ + ...SETTINGS, + ok: true, + enabled: body.enabled, + allowed_programs: body.allowed_programs.map((program: any) => ({ + ...program, + available: true, + })), + }), + } as Response; + } + return { ok: true, json: async () => SETTINGS } as Response; + }), + ); + + render(); + expect(await screen.findByText("Editor")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Add program…" })); + expect(await screen.findByText("writer")).toBeTruthy(); + fireEvent.click(screen.getByTitle("Allow local computer use")); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(calls.some((call) => call.method === "POST")).toBe(true)); + expect(calls.find((call) => call.method === "POST")?.body).toEqual({ + enabled: true, + allowed_programs: [ + { name: "Editor", path: SETTINGS.allowed_programs[0].path }, + { name: "writer", path: "C:\\Office\\writer.exe" }, + ], + }); + }); +}); diff --git a/surfaces/gui/src/components/ComputerUseSection.tsx b/surfaces/gui/src/components/ComputerUseSection.tsx new file mode 100644 index 0000000000..a18da92e59 --- /dev/null +++ b/surfaces/gui/src/components/ComputerUseSection.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { + getComputerUseSettings, + setComputerUseSettings, + type ComputerUseProgram, + type ComputerUseSettings, +} from "../api"; +import { pickProgram, platformOS } from "../tauri"; +import { Icon } from "./Icon"; +import { PanelHead } from "./IntegrationsView"; +import { Toggle } from "./Toggle"; + +const CARD = "rounded-xl2 border border-line bg-panel"; +const BTN_ACCENT = + "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shrink-0 disabled:opacity-40"; +const BTN_BORDERED = + "text-[12.5px] px-3 py-2 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0 disabled:opacity-40"; + +const programName = (path: string) => { + const filename = path.split(/[\\/]/).pop() || "Program"; + return filename.replace(/\.exe$/i, "") || "Program"; +}; + +function ProgramRow({ + program, + removable, + onRemove, +}: { + program: ComputerUseProgram; + removable?: boolean; + onRemove?: () => void; +}) { + return ( +
+
+ +
+
+
+ {program.name} + + {program.available ? "Available" : "Not found"} + +
+ {program.path} +
+ {removable ? ( + + ) : ( + Built in + )} +
+ ); +} + +export function ComputerUseSection() { + const [settings, setSettings] = useState(null); + const [enabled, setEnabled] = useState(false); + const [programs, setPrograms] = useState([]); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + + const apply = (next: ComputerUseSettings) => { + setSettings(next); + setEnabled(next.enabled); + setPrograms(next.allowed_programs); + }; + + useEffect(() => { + let active = true; + void getComputerUseSettings() + .then((next) => { + if (active) apply(next); + }) + .catch((error) => { + if (active) { + setMessage(error instanceof Error ? error.message : "Could not load Computer use settings."); + } + }); + return () => { + active = false; + }; + }, []); + + const addProgram = async () => { + setMessage(""); + const path = await pickProgram(); + if (!path) return; + if (programs.some((program) => program.path.toLocaleLowerCase() === path.toLocaleLowerCase())) { + setMessage("That program is already allowed."); + return; + } + setPrograms((current) => [ + ...current, + { name: programName(path), path, available: true }, + ]); + }; + + const save = async () => { + setSaving(true); + setMessage(""); + try { + const next = await setComputerUseSettings({ + enabled, + allowed_programs: programs.map(({ name, path }) => ({ name, path })), + }); + if (!next.ok) throw new Error(next.error || "Could not save Computer use settings."); + apply(next); + setMessage( + next.reload_warning + ? `Saved. The driver will retry the allowlist on the next action: ${next.reload_warning}` + : "Saved. New sessions use this program allowlist immediately.", + ); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not save Computer use settings."); + } finally { + setSaving(false); + } + }; + + const supported = settings?.supported ?? platformOS() === "windows"; + + return ( +
+ + +
+
+
+
Allow local computer use
+

+ Window reads are restricted to the executable paths below. Opening programs and every input action require approval. +

+
+ +
+ {!supported ? ( +

+ Program control is available in the Windows desktop build. +

+ ) : null} +
+ +
+
+
+
Allowed programs
+

+ Select an installed .exe. Command interpreters and other programs that could bypass this boundary are rejected. +

+
+ +
+
+ {programs.length ? ( + programs.map((program) => ( + setPrograms((current) => current.filter((item) => item.path !== program.path))} + /> + )) + ) : ( +

+ No optional programs are allowed yet. +

+ )} +
+
+ +
+ + {message ? {message} : null} +
+
+ ); +} diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 000dc6584c..88d866ff8e 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -43,6 +43,7 @@ import { PanelHead } from "./IntegrationsView"; import { ModelsTab } from "./ManageTabs"; import { MemorySection } from "./MemorySection"; import { PersonasTab } from "./PersonasTab"; +import { ComputerUseSection } from "./ComputerUseSection"; import { SkillsTab } from "./SkillsTab"; import { showPersonas } from "../flags"; @@ -53,7 +54,7 @@ import { showPersonas } from "../flags"; // Models + Personas host the existing tab components inside the page shell (field re-skin to follow). // "appearance" is the General tab's stable key — callers deep-link with it, so the // rename (UX-021) changed only the label. "files" folded into General as a card. -type SetTab = "appearance" | "models" | "context" | "skills" | "voice" | "memory" | "personas"; +type SetTab = "appearance" | "models" | "context" | "skills" | "voice" | "memory" | "computer-use" | "personas"; const CARD = "rounded-xl2 border border-line bg-panel"; const FIELD_LABEL = "text-[13px] font-medium text-ink"; @@ -67,7 +68,7 @@ const BTN_BORDERED = const SET_TABS: { key: SetTab; label: string; - icon: "sliders" | "code" | "mic" | "archive" | "sparkle" | "book" | "refresh"; + icon: "sliders" | "code" | "mic" | "archive" | "sparkle" | "book" | "refresh" | "wrench"; }[] = [ { key: "appearance", label: "General", icon: "sliders" }, { key: "models", label: "Models", icon: "code" }, @@ -75,6 +76,7 @@ const SET_TABS: { { key: "skills", label: "Skills", icon: "book" }, { key: "voice", label: "Voice input", icon: "mic" }, { key: "memory", label: "Memory", icon: "archive" }, + { key: "computer-use", label: "Computer use", icon: "wrench" }, { key: "personas", label: "Coworkers", icon: "sparkle" }, ]; @@ -147,6 +149,8 @@ export function SettingsView({ ) : tab === "memory" ? ( + ) : tab === "computer-use" ? ( + ) : ( )} diff --git a/surfaces/gui/src/tauri.ts b/surfaces/gui/src/tauri.ts index 95f3ccdba0..8b4662dd92 100644 --- a/surfaces/gui/src/tauri.ts +++ b/surfaces/gui/src/tauri.ts @@ -56,6 +56,12 @@ export async function pickFolder(): Promise { return typeof path === "string" && path ? path : null; } +/** Select one Windows executable for the Computer use allowlist. */ +export async function pickProgram(): Promise { + const path = await invoke("pick_program"); + return typeof path === "string" && path ? path : null; +} + /** The folder picker that works EVERYWHERE: Tauri's native dialog in the desktop shell, else the * sidecar-opened OS dialog (the sidecar is local, so the browser GUI still gets a real picker — * owner report 2026-07-04: "Browse" was desktop-only and the browser had paste-a-path only). */ diff --git a/tests/test_computer_automation.py b/tests/test_computer_automation.py new file mode 100644 index 0000000000..74d0728f29 --- /dev/null +++ b/tests/test_computer_automation.py @@ -0,0 +1,262 @@ +"""Cua Driver adapter: allowlists, fresh-state tokens, and approval metadata.""" + +from __future__ import annotations + +import pytest + +from coworker.connectors import computer_automation +from coworker.roots import RootDir + + +def _tools(tmp_path, monkeypatch, fake): + computer_automation._TOKEN_LABELS.clear() + computer_automation.configure_computer_use(enabled=True, allowed_programs=[]) + monkeypatch.setattr(computer_automation, "_run_driver", fake) + monkeypatch.setattr( + computer_automation, + "_prepare_allowed_window", + lambda _pid, _window_id: None, + ) + monkeypatch.setattr( + computer_automation, + "_sync_allowed_windows", + lambda windows, **_kwargs: (list(windows), False), + ) + return { + tool.__name__: tool + for tool in computer_automation.make_computer_automation_tools( + roots=[RootDir(tmp_path, writable=True)], session_id="test session" + ) + } + + +def test_validate_allowed_programs_rejects_interpreters_and_deduplicates(tmp_path): + editor = tmp_path / "editor.exe" + editor.write_bytes(b"MZ") + command = tmp_path / "powershell.exe" + command.write_bytes(b"MZ") + + assert computer_automation.validate_allowed_programs( + [ + {"name": "Editor", "path": str(editor)}, + {"name": "Duplicate", "path": str(editor)}, + ] + ) == [{"name": "Editor", "path": str(editor)}] + with pytest.raises(ValueError, match="cannot be allowed"): + computer_automation.validate_allowed_programs([str(command)]) + with pytest.raises(ValueError, match="only Windows .exe"): + computer_automation.validate_allowed_programs([str(tmp_path / "notes.txt")]) + + +def test_runtime_allowlist_binds_exact_executable_process_and_window(tmp_path, monkeypatch): + editor = tmp_path / "editor.exe" + other = tmp_path / "other.exe" + editor.write_bytes(b"MZ") + other.write_bytes(b"MZ") + computer_automation.configure_computer_use( + enabled=True, + allowed_programs=[{"name": "Editor", "path": str(editor)}], + ) + monkeypatch.setattr( + computer_automation, + "_process_executable", + lambda pid: str(editor if pid == 7 else other), + ) + + allowed = computer_automation._allowed_window_records( + [ + {"app_name": "editor.exe", "pid": 7, "window_id": 9, "title": "Draft"}, + {"app_name": "other.exe", "pid": 8, "window_id": 10, "title": "Private"}, + ] + ) + assert [(item["pid"], item["window_id"]) for item in allowed] == [(7, 9)] + manifest = computer_automation._manifest_document(allowed) + assert str(editor) in manifest + assert "applications:\n - 7" in manifest + assert "window_id: 9" in manifest + assert "Private" not in manifest and "window_id: 10" not in manifest + + +def test_daemon_uses_bounded_permission_mode(tmp_path): + driver = tmp_path / "cua-driver.exe" + args = computer_automation._daemon_args(driver) + assert args[args.index("--permission-mode") + 1] == "bounded" + assert "--dangerously-bypass-approvals" not in args + + +def test_shutdown_revokes_manifest_and_stops_daemon(tmp_path, monkeypatch): + driver = tmp_path / "cua-driver.exe" + driver.write_bytes(b"MZ") + calls = [] + monkeypatch.setattr(computer_automation, "_driver_path", lambda: driver) + monkeypatch.setattr( + computer_automation, + "_install_manifest", + lambda selected, windows, restart_if_running: calls.append( + ("manifest", selected, windows, restart_if_running) + ), + ) + monkeypatch.setattr( + computer_automation.subprocess, + "run", + lambda args, **_kwargs: calls.append(("run", args)), + ) + + computer_automation.shutdown_computer_use() + assert calls[0] == ("manifest", driver, [], False) + assert calls[1] == ("run", [str(driver), "stop"]) + + +def test_find_windows_filters_before_returning(tmp_path, monkeypatch): + calls = [] + + def fake(tool, args, **kwargs): + calls.append((tool, args, kwargs)) + return { + "ok": True, + "windows": [ + {"app_name": "editor.exe", "title": "Quarterly report", "pid": 7}, + {"app_name": "private.exe", "title": "Private window", "pid": 8}, + ], + } + + tools = _tools(tmp_path, monkeypatch, fake) + out = tools["computer_find_windows"]("report") + assert out["windows"] == [ + {"app_name": "editor.exe", "title": "Quarterly report", "pid": 7} + ] + assert calls[0][0:2] == ("list_windows", {}) + + +def test_snapshot_uses_exact_window_and_rotates_element_tokens(tmp_path, monkeypatch): + calls = [] + + def fake(tool, args, **kwargs): + calls.append((tool, args, kwargs)) + return { + "ok": True, + "pid": 7, + "window_id": 9, + "snapshot_id": "fresh", + "elements": [{"element_token": "fresh:0", "label": "Save"}], + "tree_markdown": "duplicate", + "screenshot_png_b64": "large", + } + + tools = _tools(tmp_path, monkeypatch, fake) + out = tools["computer_snapshot"](7, 9, query="Save") + assert out["snapshot_id"] == "fresh" + assert "tree_markdown" not in out and "screenshot_png_b64" not in out + assert calls[0][1]["include_screenshot"] is False + assert calls[0][1]["pid"] == 7 and calls[0][1]["window_id"] == 9 + assert calls[0][1]["query"] == "Save" + + +def test_screenshot_writes_only_to_the_session_root(tmp_path, monkeypatch): + def fake(_tool, _args, **kwargs): + kwargs["screenshot_path"].write_bytes(b"png") + return {"ok": True, "screenshot_png_b64": "discarded", "elements": []} + + tools = _tools(tmp_path, monkeypatch, fake) + out = tools["computer_screenshot"](7, 9) + screenshot = tmp_path / out["screenshot_path"] + assert screenshot.resolve().is_relative_to(tmp_path.resolve()) + assert screenshot.read_bytes() == b"png" + assert "screenshot_png_b64" not in out + + +def test_all_desktop_input_and_program_launches_require_approval(tmp_path, monkeypatch): + tools = _tools(tmp_path, monkeypatch, lambda *a, **k: {"ok": True}) + for name in ( + "computer_list_allowed_programs", + "computer_find_windows", + "computer_snapshot", + ): + assert tools[name].__aisuite_tool_metadata__.requires_approval is False + for name in ( + "computer_open_program", + "computer_screenshot", + "computer_click", + "computer_type_text", + "computer_press_key", + ): + assert tools[name].__aisuite_tool_metadata__.requires_approval is True + + +def test_input_actions_require_fresh_labelled_token(tmp_path, monkeypatch): + calls = [] + + def fake(tool, args, **kwargs): + calls.append((tool, args)) + if tool == "get_window_state": + return { + "ok": True, + "elements": [{"element_token": "fresh:1", "label": "Message"}], + } + return {"ok": True} + + tools = _tools(tmp_path, monkeypatch, fake) + assert "preceding" in tools["computer_click"]( + 7, 9, element_token="old:1", element_label="Message" + )["error"] + assert "preceding" in tools["computer_type_text"]( + 7, 9, text="hello", element_token="old:1", element_label="Message" + )["error"] + + tools["computer_snapshot"](7, 9) + assert "does not match" in tools["computer_click"]( + 7, 9, element_token="fresh:1", element_label="Delete" + )["error"] + tools["computer_snapshot"](7, 9) + assert tools["computer_click"]( + 7, 9, element_token="fresh:1", element_label="Message" + )["ok"] is True + assert "preceding" in tools["computer_click"]( + 7, 9, element_token="fresh:1", element_label="Message" + )["error"] + tools["computer_snapshot"](7, 9) + assert tools["computer_type_text"]( + 7, + 9, + text="hello\nworld", + element_token="fresh:1", + element_label="Message", + )["ok"] is True + tools["computer_snapshot"](7, 9) + assert tools["computer_press_key"]( + 7, + 9, + key="Enter", + element_token="fresh:1", + element_label="Message", + modifiers=[], + )["ok"] is True + assert [tool for tool, _args in calls if tool != "get_window_state"] == [ + "click", + "type_text", + "press_key", + ] + + +def test_integration_connector_filter_exposes_only_generic_computer_tools( + tmp_path, monkeypatch +): + from coworker.connectors.integration_tools import make_integration_tools + from coworker.secrets import SecretStore + + monkeypatch.setattr( + computer_automation, "_run_driver", lambda *a, **k: {"ok": True} + ) + tools = make_integration_tools( + SecretStore(tmp_path / "secrets.json"), enabled_connectors={"computer"} + ) + assert {tool.__name__ for tool in tools} == { + "computer_list_allowed_programs", + "computer_find_windows", + "computer_snapshot", + "computer_screenshot", + "computer_open_program", + "computer_click", + "computer_type_text", + "computer_press_key", + } diff --git a/tests/test_computer_use_settings.py b/tests/test_computer_use_settings.py new file mode 100644 index 0000000000..13102c51a8 --- /dev/null +++ b/tests/test_computer_use_settings.py @@ -0,0 +1,72 @@ +import json + +from fastapi.testclient import TestClient + +from coworker.server import manager as manager_module +from coworker.server.app import create_app +from coworker.server.manager import SessionManager + + +def test_computer_use_is_disabled_and_empty_by_default(tmp_path): + client = TestClient(create_app(SessionManager(data_dir=tmp_path / "data"))) + settings = client.get("/v1/settings/computer-use").json() + assert settings["enabled"] is False + assert settings["allowed_programs"] == [] + + +def test_computer_use_settings_persist_program_allowlist(tmp_path, monkeypatch): + reloads = [] + monkeypatch.setattr( + manager_module, + "reset_computer_use_permissions", + lambda: reloads.append(True) + or {"driver_installed": True, "driver_reloaded": True}, + ) + editor = tmp_path / "editor.exe" + editor.write_bytes(b"MZ") + data_dir = tmp_path / "data" + client = TestClient(create_app(SessionManager(data_dir=data_dir))) + + saved = client.post( + "/v1/settings/computer-use", + json={ + "enabled": True, + "allowed_programs": [{"name": "Editor", "path": str(editor)}], + }, + ).json() + assert saved["ok"] is True + assert saved["driver_reloaded"] is True + assert saved["allowed_programs"] == [ + {"name": "Editor", "path": str(editor), "available": True} + ] + assert reloads == [True] + prefs = json.loads((data_dir / "prefs.json").read_text()) + assert prefs["computer_use_enabled"] is True + assert prefs["computer_use_programs"] == [ + {"name": "Editor", "path": str(editor)} + ] + reborn = SessionManager(data_dir=data_dir) + assert reborn.computer_use_settings()["allowed_programs"][0]["path"] == str( + editor + ) + + +def test_computer_use_settings_reject_command_interpreters(tmp_path, monkeypatch): + monkeypatch.setattr( + manager_module, + "reset_computer_use_permissions", + lambda: {"driver_installed": False, "driver_reloaded": False}, + ) + command = tmp_path / "cmd.exe" + command.write_bytes(b"MZ") + client = TestClient(create_app(SessionManager(data_dir=tmp_path / "data"))) + + result = client.post( + "/v1/settings/computer-use", + json={ + "enabled": True, + "allowed_programs": [{"name": "Command Prompt", "path": str(command)}], + }, + ).json() + assert result["ok"] is False + assert "cannot be allowed" in result["error"] From 3a142765bafc0b605d9f149ea9fbee75194fc6b0 Mon Sep 17 00:00:00 2001 From: zheli Date: Tue, 25 Aug 2026 11:13:40 +0800 Subject: [PATCH 2/2] feat: support allowlisted macOS computer use --- README.md | 2 +- coworker/connectors/computer_automation.py | 342 +++++++++++++++--- coworker/server/app.py | 5 + coworker/server/manager.py | 12 +- packaging/build_dmg.sh | 54 ++- packaging/build_windows.ps1 | 4 +- packaging/cua-driver-capabilities.yaml | 11 +- surfaces/gui/src-tauri/Info.plist | 2 + surfaces/gui/src-tauri/src/lib.rs | 16 +- surfaces/gui/src/api.ts | 12 + .../components/ComputerUseSection.test.tsx | 39 ++ .../gui/src/components/ComputerUseSection.tsx | 38 +- surfaces/gui/src/tauri.ts | 2 +- tests/test_computer_automation.py | 132 ++++++- tests/test_computer_use_settings.py | 19 + 15 files changed, 617 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 42f8e7b601..547d01dff8 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Under the hood: - **Produce real deliverables** - documents, spreadsheets, reports, and web pages land as files you can open and share. - **Work from Slack** - mention `@OpenWorker` in a channel; a session opens on your desktop, the work happens with your tools, and the answer comes back as a thread reply. - **Use your everyday tools** - 25+ integrations including GitHub, Slack, Jira, Notion, Linear, HubSpot, Outlook, monday.com, Gmail, and Google Calendar, plus your **terminal and local files**. Any tool reachable over [MCP](https://modelcontextprotocol.io/) plugs in too, with per-tool control. -- **Control selected Windows apps** - opt in under Settings → Computer use, choose exact executable paths, and approve every program launch and input action. +- **Control selected desktop apps** - opt in on macOS or Windows under Settings → Computer use, choose exact application paths, and approve every program launch and input action. - **Run on a schedule** - automations for recurring work: a morning brief, a weekly report, a standing watch over a channel. Runs land in the app with full transcripts. - **Ask before acting** - writes, sends, and shell commands are approval-gated. Unattended runs park their asks in an inbox instead of acting on their own. diff --git a/coworker/connectors/computer_automation.py b/coworker/connectors/computer_automation.py index f232f53642..b6f52d4a89 100644 --- a/coworker/connectors/computer_automation.py +++ b/coworker/connectors/computer_automation.py @@ -1,4 +1,4 @@ -"""Allowlist-enforced Windows desktop automation through the Cua Driver CLI. +"""Allowlist-enforced desktop automation through the Cua Driver CLI. The driver is a local native sidecar. OpenWorker remains the approval authority for every input action, and refreshes a deny-by-default capability manifest with @@ -9,6 +9,7 @@ import json import os +import plistlib import re import shutil import subprocess @@ -21,12 +22,13 @@ import aisuite as ai import yaml +from ..secrets import state_dir from .tool_defs import approval_for_tool _DRIVER_LOCK = threading.Lock() _DAEMON_READY = False -_BLOCKED_PROGRAM_NAMES = { +_BLOCKED_WINDOWS_PROGRAM_NAMES = { "bash.exe", "cmd.exe", "conhost.exe", @@ -48,18 +50,80 @@ "wsl.exe", "wt.exe", } +_BLOCKED_MACOS_APP_NAMES = { + "automator.app", + "finder.app", + "iterm.app", + "iterm2.app", + "script editor.app", + "shortcuts.app", + "terminal.app", + "warp.app", + "wezterm.app", +} +_BLOCKED_MACOS_BUNDLE_IDS = { + "com.apple.automator", + "com.apple.finder", + "com.apple.scripteditor2", + "com.apple.shortcuts", + "com.apple.terminal", + "com.github.wez.wezterm", + "com.googlecode.iterm2", + "dev.warp.warp-stable", +} +_BLOCKED_MACOS_EXECUTABLE_NAMES = { + "automator", + "bash", + "finder", + "iterm2", + "node", + "osascript", + "perl", + "python", + "python3", + "ruby", + "script editor", + "shortcuts", + "sh", + "terminal", + "warp", + "wezterm-gui", + "zsh", +} _CONFIG_LOCK = threading.RLock() _COMPUTER_USE_ENABLED = False _ALLOWED_PROGRAMS: tuple[dict[str, str], ...] = () _TOKEN_LOCK = threading.Lock() _TOKEN_LABELS: dict[tuple[str, int, int, str], str] = {} +_MACHO_MAGICS = { + b"\xbe\xba\xfe\xca", + b"\xbf\xba\xfe\xca", + b"\xca\xfe\xba\xbe", + b"\xca\xfe\xba\xbf", + b"\xce\xfa\xed\xfe", + b"\xcf\xfa\xed\xfe", + b"\xfe\xed\xfa\xce", + b"\xfe\xed\xfa\xcf", +} + + +def computer_use_platform() -> str: + if sys.platform == "win32": + return "windows" + if sys.platform == "darwin": + return "macos" + return "unsupported" + + +def computer_use_supported() -> bool: + return computer_use_platform() != "unsupported" def _path_key(value: str | Path) -> str: path = os.path.normpath(os.path.expandvars(str(value or "").strip())) if path.startswith("\\\\?\\"): path = path[4:] - return path.casefold() + return path.casefold() if computer_use_platform() == "windows" else path def _program_name(path: str | Path) -> str: @@ -67,9 +131,73 @@ def _program_name(path: str | Path) -> str: return stem or Path(str(path)).name or "Program" +def _macos_app_details(path: Path) -> tuple[Path, str]: + """Resolve one .app bundle to its contained executable and bundle id.""" + + bundle = path.resolve(strict=True) + if not bundle.is_dir() or bundle.suffix.casefold() != ".app": + raise ValueError(f"only macOS .app bundles can be allowed: {path}") + contents = (bundle / "Contents").resolve(strict=True) + if not contents.is_relative_to(bundle): + raise ValueError(f"application Contents directory escapes its bundle: {path}") + info_path = contents / "Info.plist" + try: + with info_path.open("rb") as stream: + info = plistlib.load(stream) + except (OSError, plistlib.InvalidFileException) as exc: + raise ValueError(f"application has no valid Info.plist: {path}") from exc + executable_name = str(info.get("CFBundleExecutable") or "").strip() + if ( + not executable_name + or executable_name in {".", ".."} + or "/" in executable_name + or "\\" in executable_name + ): + raise ValueError(f"application has no valid CFBundleExecutable: {path}") + executable_root = (contents / "MacOS").resolve(strict=True) + if not executable_root.is_relative_to(contents): + raise ValueError(f"application executable directory escapes its bundle: {path}") + executable = (executable_root / executable_name).resolve(strict=True) + if not executable.is_relative_to(executable_root) or not executable.is_file(): + raise ValueError(f"application executable escapes its bundle: {path}") + if not os.access(executable, os.X_OK): + raise ValueError(f"application executable is not runnable: {executable}") + try: + with executable.open("rb") as stream: + magic = stream.read(4) + except OSError as exc: + raise ValueError(f"application executable cannot be read: {executable}") from exc + if magic not in _MACHO_MAGICS: + raise ValueError(f"application executable is not a Mach-O binary: {executable}") + return executable, str(info.get("CFBundleIdentifier") or "").strip().casefold() + + +def _program_executable(path: str | Path) -> Path: + selected = Path(str(path)).expanduser() + if computer_use_platform() == "windows": + resolved = selected.resolve(strict=True) + if not resolved.is_file() or resolved.suffix.casefold() != ".exe": + raise ValueError(f"only Windows .exe programs can be allowed: {selected}") + return resolved + if computer_use_platform() == "macos": + return _macos_app_details(selected)[0] + raise ValueError("Computer use is supported only on macOS and Windows") + + +def program_path_available(path: str | Path) -> bool: + try: + _program_executable(path) + except (OSError, ValueError): + return False + return True + + def validate_allowed_programs( value: Any, *, require_exists: bool = True ) -> list[dict[str, str]]: + platform = computer_use_platform() + if platform == "unsupported": + raise ValueError("Computer use is supported only on macOS and Windows") if not isinstance(value, list): raise ValueError("allowed_programs must be a list") if len(value) > 20: @@ -88,12 +216,32 @@ def validate_allowed_programs( path = Path(expanded) if not path.is_absolute(): raise ValueError(f"program path must be absolute: {raw_path}") - if path.suffix.casefold() != ".exe": - raise ValueError(f"only Windows .exe programs can be allowed: {path}") - if path.name.casefold() in _BLOCKED_PROGRAM_NAMES: - raise ValueError(f"system command interpreters cannot be allowed: {path.name}") - if require_exists and not path.is_file(): - raise ValueError(f"program was not found: {path}") + if platform == "windows": + if path.suffix.casefold() != ".exe": + raise ValueError(f"only Windows .exe programs can be allowed: {path}") + if path.name.casefold() in _BLOCKED_WINDOWS_PROGRAM_NAMES: + raise ValueError(f"system command interpreters cannot be allowed: {path.name}") + if require_exists and not path.is_file(): + raise ValueError(f"program was not found: {path}") + if path.exists(): + path = path.resolve() + else: + if path.suffix.casefold() != ".app": + raise ValueError(f"only macOS .app bundles can be allowed: {path}") + if path.name.casefold() in _BLOCKED_MACOS_APP_NAMES: + raise ValueError(f"system automation applications cannot be allowed: {path.name}") + if require_exists and not path.is_dir(): + raise ValueError(f"program was not found: {path}") + if path.exists(): + executable, bundle_id = _macos_app_details(path) + if ( + executable.name.casefold() in _BLOCKED_MACOS_EXECUTABLE_NAMES + or bundle_id in _BLOCKED_MACOS_BUNDLE_IDS + ): + raise ValueError( + f"system automation applications cannot be allowed: {path.name}" + ) + path = path.resolve() key = _path_key(path) if key in seen: continue @@ -127,11 +275,34 @@ def _effective_allowed_paths() -> dict[str, dict[str, Any]]: with _CONFIG_LOCK: if not _COMPUTER_USE_ENABLED: return {} - entries: list[dict[str, Any]] = [ - {"name": item["name"], "path": item["path"], "launch": True} - for item in _ALLOWED_PROGRAMS - ] - return {_path_key(item["path"]): item for item in entries} + configured = [dict(item) for item in _ALLOWED_PROGRAMS] + entries: list[dict[str, Any]] = [] + for item in configured: + try: + executable = _program_executable(item["path"]) + except (OSError, ValueError): + continue + entries.append( + { + "name": item["name"], + "path": item["path"], + "executable": str(executable), + "launch": True, + } + ) + return {_path_key(item["executable"]): item for item in entries} + + +def _allowed_program_for_path(program_path: str) -> Optional[dict[str, Any]]: + requested = _path_key(program_path) + return next( + ( + item + for item in _effective_allowed_paths().values() + if _path_key(item["path"]) == requested + ), + None, + ) def _element_text(element: dict[str, Any]) -> str: @@ -251,9 +422,26 @@ def _driver_path() -> Optional[Path]: def _process_executable(pid: int) -> Optional[str]: - """Resolve a Windows PID to its full executable without shelling out.""" + """Resolve a local PID to its full executable without shelling out.""" - if os.name != "nt" or int(pid) <= 0: + if int(pid) <= 0: + return None + if computer_use_platform() == "macos": + try: + import ctypes + + libproc = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True) + proc_pidpath = libproc.proc_pidpath + proc_pidpath.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32] + proc_pidpath.restype = ctypes.c_int + buffer = ctypes.create_string_buffer(4096) + length = proc_pidpath(int(pid), buffer, len(buffer)) + if length <= 0: + return None + return os.fsdecode(buffer.value) + except (AttributeError, OSError, ValueError): + return None + if computer_use_platform() != "windows": return None try: import ctypes @@ -321,12 +509,12 @@ def _manifest_document(allowed_windows: list[dict[str, Any]]) -> str: allowed_paths = _effective_allowed_paths() app_resources = [] for item in allowed_paths.values(): - path = Path(str(item["path"])) - if not path.is_file(): + executable = Path(str(item["executable"])) + if not executable.is_file(): continue app_resources.append( { - "executable": str(path), + "executable": str(executable), "launch": bool(item.get("launch")), "windows": "all", "terminate": "deny", @@ -349,6 +537,8 @@ def _manifest_document(allowed_windows: list[dict[str, Any]]) -> str: ) document = { "version": 3, + "expires_after": "8h", + "idle_timeout": "30m", "resources": { "apps": app_resources, "desktop": { @@ -373,13 +563,39 @@ def _manifest_document(allowed_windows: list[dict[str, Any]]) -> str: return yaml.safe_dump(document, allow_unicode=True, sort_keys=False) +def _manifest_path(_driver: Path) -> Path: + override = str(os.environ.get("OPENWORKER_CUA_MANIFEST") or "").strip() + if override: + return Path(override).expanduser() + return state_dir() / "cua-driver" / "computer-use-capabilities.yaml" + + +def _write_manifest(manifest: Path, content: str) -> None: + manifest.parent.mkdir(parents=True, exist_ok=True) + if os.name != "nt": + manifest.parent.chmod(0o700) + tmp = manifest.with_name( + f".{manifest.name}.{os.getpid()}.{threading.get_ident()}.tmp" + ) + try: + tmp.write_text(content, encoding="utf-8") + if os.name != "nt": + tmp.chmod(0o600) + tmp.replace(manifest) + finally: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + def _install_manifest( driver: Path, allowed_windows: list[dict[str, Any]], *, restart_if_running: bool ) -> bool: """Install a reviewed manifest and restart the immutable CUA daemon if needed.""" global _DAEMON_READY - manifest = driver.with_name("cua-driver-capabilities.yaml") + manifest = _manifest_path(driver) content = _manifest_document(allowed_windows) try: current = manifest.read_text(encoding="utf-8") if manifest.is_file() else "" @@ -406,9 +622,7 @@ def _install_manifest( ) except (OSError, subprocess.TimeoutExpired): pass - tmp = manifest.with_name(manifest.name + ".tmp") - tmp.write_text(content, encoding="utf-8") - tmp.replace(manifest) + _write_manifest(manifest, content) _DAEMON_READY = False if was_running and restart_if_running: _start_daemon(driver) @@ -469,7 +683,7 @@ def shutdown_computer_use() -> None: def _daemon_args(driver: Path) -> list[str]: - manifest = driver.with_name("cua-driver-capabilities.yaml") + manifest = _manifest_path(driver) return [ str(driver), "serve", @@ -482,9 +696,9 @@ def _daemon_args(driver: Path) -> list[str]: def _start_daemon(driver: Path) -> None: - manifest = driver.with_name("cua-driver-capabilities.yaml") + manifest = _manifest_path(driver) if not manifest.is_file(): - raise RuntimeError(f"Cua Driver capability manifest is missing: {manifest}") + _write_manifest(manifest, _manifest_document([])) env = os.environ.copy() env["CUA_DRIVER_RS_TELEMETRY_ENABLED"] = "false" env["CUA_TELEMETRY_ENABLED"] = "false" @@ -591,7 +805,7 @@ def _run_driver( if driver is None: return { "ok": False, - "error": "Cua Driver is not installed. Reinstall the Windows OpenWorker build.", + "error": "Cua Driver is not installed. Reinstall the OpenWorker desktop build.", } try: _ensure_daemon(driver) @@ -644,6 +858,36 @@ def _run_driver( return {"ok": True, **result} +def request_computer_use_permissions() -> dict[str, Any]: + """Start Cua Driver's user-initiated macOS permission onboarding flow.""" + + if computer_use_platform() != "macos": + return {"ok": False, "error": "Permission setup is available only on macOS"} + driver = _driver_path() + if driver is None: + return { + "ok": False, + "error": "Cua Driver is not installed. Reinstall the OpenWorker desktop build.", + } + env = os.environ.copy() + env["CUA_DRIVER_RS_TELEMETRY_ENABLED"] = "false" + env["CUA_TELEMETRY_ENABLED"] = "false" + try: + subprocess.Popen( + [str(driver), "permissions", "grant"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + ) + except OSError as exc: + return {"ok": False, "error": f"could not open macOS permission setup: {exc}"} + return { + "ok": True, + "message": "Follow the macOS prompts, then restart OpenWorker if requested.", + } + + def _session_label(session_id: Optional[str]) -> str: cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "-", session_id or "openworker") return f"ow-{cleaned[:48]}" @@ -887,26 +1131,41 @@ def computer_open_program(program_path: str) -> dict[str, Any]: disabled = _disabled_error() if disabled: return disabled - requested = _path_key(program_path) - allowed = _effective_allowed_paths().get(requested) + allowed = _allowed_program_for_path(program_path) if allowed is None or not bool(allowed.get("launch")): return { "ok": False, "error": "program is not allowed in Settings > Computer use", } path = Path(str(allowed["path"])) - if not path.is_file(): + executable = Path(str(allowed["executable"])) + if not program_path_available(path): return {"ok": False, "error": f"program was not found: {path}"} + launch_pid: Optional[int] = None try: - process = subprocess.Popen( - [str(path)], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=( - getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 - ), - ) + if computer_use_platform() == "macos": + opened = subprocess.run( + ["/usr/bin/open", str(path)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + check=False, + ) + if opened.returncode != 0: + return { + "ok": False, + "error": f"could not open {allowed['name']}: open exited {opened.returncode}", + } + else: + process = subprocess.Popen( + [str(executable)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + launch_pid = process.pid except OSError as exc: return {"ok": False, "error": f"could not open {allowed['name']}: {exc}"} time.sleep(1.5) @@ -919,14 +1178,15 @@ def computer_open_program(program_path: str) -> dict[str, Any]: windows = [ _public_window(window) for window in current - if _path_key(window.get("_executable") or "") == requested + if _path_key(window.get("_executable") or "") + == _path_key(executable) ] except (OSError, RuntimeError, yaml.YAMLError): pass return { "ok": True, "program": {"name": allowed["name"], "path": str(path)}, - "pid": process.pid, + "pid": int(windows[0]["pid"]) if windows else launch_pid, "windows": windows, "allowlist_reloaded": reloaded, "instruction": ( diff --git a/coworker/server/app.py b/coworker/server/app.py index 84bb02004d..806c8ea0db 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1871,6 +1871,11 @@ def settings_computer_use_set(body: dict) -> dict[str, Any]: enabled=b.get("enabled"), allowed_programs=b.get("allowed_programs"), ) + + @app.post("/v1/settings/computer-use/permissions") + def settings_computer_use_permissions() -> dict[str, Any]: + return manager.request_computer_use_permissions() + @app.post("/v1/settings/model-key") def settings_set_model_key(body: dict) -> dict[str, Any]: return manager.set_model_key((body or {}).get("api_key", "")) diff --git a/coworker/server/manager.py b/coworker/server/manager.py index fc3fb5e60c..e7ebc7c602 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -62,8 +62,12 @@ browser_take_screenshot, ) from ..connectors.computer_automation import ( + computer_use_platform, + computer_use_supported, computer_use_configuration, configure_computer_use, + program_path_available, + request_computer_use_permissions, reset_computer_use_permissions, shutdown_computer_use, validate_allowed_programs, @@ -3174,9 +3178,10 @@ def _apply_computer_use_preferences(self) -> dict[str, Any]: allowed_programs=programs, ) config = computer_use_configuration() - config["supported"] = os.name == "nt" + config["supported"] = computer_use_supported() + config["platform"] = computer_use_platform() config["allowed_programs"] = [ - {**program, "available": Path(program["path"]).is_file()} + {**program, "available": program_path_available(program["path"])} for program in config["allowed_programs"] ] return config @@ -3209,6 +3214,9 @@ def set_computer_use_settings( "reload_warning": str(exc), } return {"ok": True, **payload, **runtime} + + def request_computer_use_permissions(self) -> dict[str, Any]: + return request_computer_use_permissions() # -- direct-message routing ------------------------------------------------- def dm_session(self) -> Optional[str]: """The session a DM to the bot is routed to (user-designated). None → DMs are parked.""" diff --git a/packaging/build_dmg.sh b/packaging/build_dmg.sh index 724ce3e8cf..4ec0659faa 100755 --- a/packaging/build_dmg.sh +++ b/packaging/build_dmg.sh @@ -3,8 +3,9 @@ # # 1. PyInstaller-bundle the server into a standalone onedir folder (no venv at runtime). # 2. Stage it at binaries/sidecar/ for Tauri's `resources` slot (+ sign its Mach-Os). -# 3. `tauri build --bundles app` → OpenWorker.app (resources are copied in). -# 4. Wrap the .app in a compressed .dmg via hdiutil (reliable + headless; Tauri's own +# 3. Verify and stage the pinned universal Cua Driver + capability policy. +# 4. `tauri build --bundles app` → OpenWorker.app (resources are copied in). +# 5. Wrap the .app in a compressed .dmg via hdiutil (reliable + headless; Tauri's own # bundle_dmg.sh uses Finder AppleScript and fails in non-interactive sessions). # # Prerequisites (mirrors build_windows.ps1's header): @@ -73,11 +74,11 @@ if [ -n "${APPLE_CERTIFICATE:-}" ] && [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then security list-keychains -d user -s "$KC" login.keychain-db fi -echo "==> [1/5] PyInstaller: bundling openworker-server ($TRIPLE)" +echo "==> [1/6] PyInstaller: bundling openworker-server ($TRIPLE)" "$PLATFORM/.venv/bin/pyinstaller" --noconfirm --clean \ --distpath "$HERE/dist" --workpath "$HERE/build" "$HERE/openworker-server.spec" -echo "==> [2/5] staging sidecar resources" +echo "==> [2/6] staging sidecar resources" # Onedir bundle (exe + _internal/) ships via Tauri `resources` as Contents/Resources/sidecar/ # — onefile's per-launch self-extraction cost 6-7s of boot splash. rm -rf first: cp WRITES # THROUGH a symlink at the destination (a dev-convenience symlink in the old externalBin slot @@ -109,6 +110,41 @@ if [ -n "$(find "$GUI/src-tauri/binaries/sidecar" -type d -name "*.framework" | fi chmod +x "$GUI/src-tauri/binaries/sidecar/openworker-server" +echo "==> [3/6] staging pinned universal Cua Driver" +CUA_VERSION="0.22.0" +CUA_SHA256="202eb9dd2185d64fc0599079671f50efe2bf71b300a85644cf26d627bb7355e6" +CUA_CACHE="$HERE/cache" +CUA_ARCHIVE="${CUA_DRIVER_ARCHIVE:-$CUA_CACHE/cua-driver-$CUA_VERSION-darwin-universal.tar.gz}" +if [ ! -f "$CUA_ARCHIVE" ]; then + mkdir -p "$CUA_CACHE" + CUA_URL="https://github.com/trycua/cua/releases/download/cua-driver-rs-v$CUA_VERSION/cua-driver-rs-$CUA_VERSION-darwin-universal-binary.tar.gz" + echo " downloading $CUA_URL" + curl -L --fail --silent --show-error "$CUA_URL" -o "$CUA_ARCHIVE" +fi +ACTUAL_CUA_SHA="$(shasum -a 256 "$CUA_ARCHIVE" | awk '{print $1}')" +if [ "$ACTUAL_CUA_SHA" != "$CUA_SHA256" ]; then + echo "ERROR: Cua Driver archive checksum mismatch: expected $CUA_SHA256, got $ACTUAL_CUA_SHA" >&2 + exit 1 +fi +CUA_STAGE="$CUA_CACHE/extracted-$CUA_VERSION-darwin-universal" +CUA_DST="$GUI/src-tauri/binaries/sidecar/cua-driver" +rm -rf "$CUA_STAGE" "$CUA_DST" +mkdir -p "$CUA_STAGE" "$CUA_DST" +tar -xzf "$CUA_ARCHIVE" -C "$CUA_STAGE" +cp "$CUA_STAGE/cua-driver" "$CUA_DST/" +cp "$CUA_STAGE/cua-cursor-theme" "$CUA_DST/" +cp "$HERE/cua-driver-capabilities.yaml" "$CUA_DST/" +cp "$HERE/cua-driver-LICENSE.txt" "$CUA_DST/" +chmod +x "$CUA_DST/cua-driver" "$CUA_DST/cua-cursor-theme" +# Preserve Cua AI's notarized Developer ID signatures: macOS attributes Accessibility +# and Screen Recording grants to the driver's own identity. The outer OpenWorker bundle +# seals these unmodified resources in its signature. +find "$CUA_DST" -type f -print0 | while IFS= read -r -d '' f; do + file -b "$f" | grep -q "Mach-O" || continue + codesign --verify --strict "$f" +done +echo " -> $CUA_DST (v$CUA_VERSION universal, SHA256 and signatures verified)" + # Sign the sidecar's Mach-O files BEFORE tauri build: `tauri build` signs the .app (sealing # resources into its signature) but does NOT sign nested binaries inside resources — unsigned # Mach-Os there fail notarization. Hardened runtime + timestamp on every one, same identity, @@ -121,7 +157,7 @@ if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then # tree is fully dereferenced, so each file must validate standalone — that is exactly # what the notary service checks). Entitlements only on the entrypoint # (disable-library-validation: the bundled python.org dylibs carry another Team ID). - find "$SIDECAR" -type f ! -name "openworker-server" \ + find "$SIDECAR" -type f ! -path "$SIDECAR/cua-driver/*" ! -name "openworker-server" \ ! -name "*.py" ! -name "*.pyc" ! -name "*.txt" ! -name "*.pem" ! -name "*.json" \ -print0 | while IFS= read -r -d '' f; do file -b "$f" | grep -q "Mach-O" || continue @@ -131,7 +167,7 @@ if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then --entitlements "$GUI/src-tauri/entitlements.plist" "$SIDECAR/openworker-server" fi -echo "==> [3/5] tauri build (.app)" +echo "==> [4/6] tauri build (.app)" # Auto-update artifacts (.app.tar.gz + minisign .sig): produced only when the updater # signing key is available — from the env (CI secret TAURI_SIGNING_PRIVATE_KEY), or from # `.ocw-updater.env` one directory above the repo (same convention as the notary env). @@ -152,7 +188,7 @@ fi # under set -u on macOS's stock bash 3.2 — hit by keyless (fresh-clone) builds. ( cd "$GUI" && npm run tauri build -- --bundles app ${UPDATER_OVERLAY[@]+"${UPDATER_OVERLAY[@]}"} ) -echo "==> [4/5] hdiutil: wrapping into .dmg" +echo "==> [5/6] hdiutil: wrapping into .dmg" BUNDLE="$GUI/src-tauri/target/release/bundle" STAGING="$(mktemp -d)" cp -R "$BUNDLE/macos/$APP.app" "$STAGING/" @@ -232,10 +268,10 @@ if [ "${OCW_SKIP_NOTARIZE:-}" = "1" ] && [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; t # Local-iteration escape hatch: sign (seconds) but skip the notary round-trip # (minutes). Locally built DMGs carry no quarantine flag, so Gatekeeper never # prompts on this machine anyway. NEVER distribute a build made this way. - echo "==> [5/5] OCW_SKIP_NOTARIZE=1 — signing container, SKIPPING notarize/staple (do not distribute)" + echo "==> [6/6] OCW_SKIP_NOTARIZE=1 — signing container, SKIPPING notarize/staple (do not distribute)" codesign --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$DMG" elif [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then - echo "==> [5/5] release finishing: sign container → notarize → staple" + echo "==> [6/6] release finishing: sign container → notarize → staple" codesign --sign "$APPLE_SIGNING_IDENTITY" --timestamp "$DMG" # CI provides the App Store Connect key under tauri's APPLE_API_* names (release.yml) diff --git a/packaging/build_windows.ps1 b/packaging/build_windows.ps1 index 9e1a2d5fc2..c3773a1dcf 100644 --- a/packaging/build_windows.ps1 +++ b/packaging/build_windows.ps1 @@ -83,8 +83,8 @@ Copy-Item -Recurse -Force $Src $Dst Write-Host " -> $Dst" Write-Host "==> [3/4] staging pinned Cua Driver" -ForegroundColor Cyan -$CuaVersion = "0.20.0" -$CuaSha256 = "c020fefee01aacc174a27fea84a0cb77d47ef8290bfc772b3db7e3e06670d2b2" +$CuaVersion = "0.22.0" +$CuaSha256 = "18bf9530bfb78b360ac877d596c5b3f14ed8a3d8cdf5682d3b7231d20fb03f76" $CuaCache = Join-Path $Here "cache" $CuaArchive = if ($env:CUA_DRIVER_ARCHIVE) { $env:CUA_DRIVER_ARCHIVE diff --git a/packaging/cua-driver-capabilities.yaml b/packaging/cua-driver-capabilities.yaml index 995baa5223..4ca4f6303c 100644 --- a/packaging/cua-driver-capabilities.yaml +++ b/packaging/cua-driver-capabilities.yaml @@ -1,9 +1,12 @@ version: 3 +expires_after: 8h +idle_timeout: 30m -# Bootstrap profile. OpenWorker replaces this file with exact PIDs and window IDs -# for programs selected in Settings > Computer use before exposing any window state -# or input action. No shell, filesystem, browser-profile, process-control, or -# desktop-wide screenshot/input tools are admitted. +# Bootstrap profile reference. At runtime OpenWorker writes an equivalent short-lived +# manifest under the user's state directory, adding exact PIDs and window IDs for the +# applications selected in Settings > Computer use. The signed application bundle is +# never modified. No shell, filesystem, browser-profile, process-control, or desktop-wide +# screenshot/input tools are admitted. resources: apps: [] desktop: diff --git a/surfaces/gui/src-tauri/Info.plist b/surfaces/gui/src-tauri/Info.plist index 0723729680..8a5cb637f2 100644 --- a/surfaces/gui/src-tauri/Info.plist +++ b/surfaces/gui/src-tauri/Info.plist @@ -8,6 +8,8 @@ NSMicrophoneUsageDescription OpenWorker records only while you use the composer microphone to turn your spoken prompt into editable text. Audio is transcribed locally and is not uploaded. + NSScreenCaptureUsageDescription + OpenWorker captures only an application window you explicitly allow, so an approved task can inspect and interact with that application. NSDesktopFolderUsageDescription A task you run may need to read or save files on your Desktop. OpenWorker never scans this folder on its own. NSDocumentsFolderUsageDescription diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index c378b3d6a2..702834f163 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -357,18 +357,20 @@ async fn pick_folder(app: tauri::AppHandle) -> Option { rx.recv().ok().flatten().map(|fp| fp.to_string()) } -/// Native executable picker for Settings > Computer use. The selected path is +/// Native application picker for Settings > Computer use. The selected path is /// still validated by the local Python server before it enters the allowlist. #[tauri::command] async fn pick_program(app: tauri::AppHandle) -> Option { use tauri_plugin_dialog::DialogExt; let (tx, rx) = std::sync::mpsc::channel(); - app.dialog() - .file() - .add_filter("Windows programs", &["exe"]) - .pick_file(move |p| { - let _ = tx.send(p); - }); + let dialog = app.dialog().file().set_title("Allow an application"); + #[cfg(target_os = "windows")] + let dialog = dialog.add_filter("Windows programs", &["exe"]); + #[cfg(target_os = "macos")] + let dialog = dialog.add_filter("macOS applications", &["app"]); + dialog.pick_file(move |p| { + let _ = tx.send(p); + }); rx.recv().ok().flatten().map(|fp| fp.to_string()) } diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 5566619e8c..f1f946eb54 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -917,6 +917,7 @@ export interface ComputerUseProgram { export interface ComputerUseSettings { enabled: boolean; supported: boolean; + platform: "macos" | "windows" | "unsupported"; allowed_programs: ComputerUseProgram[]; driver_installed?: boolean; driver_reloaded?: boolean; @@ -1707,6 +1708,17 @@ export async function setComputerUseSettings(patch: { }); return res.json(); } + +export async function requestComputerUsePermissions(): Promise<{ + ok: boolean; + message?: string; + error?: string; +}> { + const res = await fetch(`${httpBase()}/v1/settings/computer-use/permissions`, { + method: "POST", + }); + return res.json(); +} export async function setModelKey( apiKey: string, ): Promise<{ ok: boolean; error?: string; has_key?: boolean; source?: string }> { diff --git a/surfaces/gui/src/components/ComputerUseSection.test.tsx b/surfaces/gui/src/components/ComputerUseSection.test.tsx index e894ee26a2..9e1ef430ca 100644 --- a/surfaces/gui/src/components/ComputerUseSection.test.tsx +++ b/surfaces/gui/src/components/ComputerUseSection.test.tsx @@ -5,6 +5,7 @@ import { ComputerUseSection } from "./ComputerUseSection"; const SETTINGS = { enabled: false, supported: true, + platform: "windows" as const, allowed_programs: [ { name: "Editor", @@ -70,4 +71,42 @@ describe("ComputerUseSection", () => { ], }); }); + + it("offers macOS permission setup and accepts app bundles", async () => { + const calls: string[] = []; + vi.stubGlobal("__OCW_PLATFORM__", "macos"); + vi.stubGlobal("__TAURI__", { + core: { + invoke: vi.fn(async (command: string) => + command === "pick_program" ? "/Applications/Pages.app" : null, + ), + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + calls.push(`${(init?.method || "GET").toUpperCase()} ${url}`); + if (url.endsWith("/permissions")) { + return { + ok: true, + json: async () => ({ ok: true, message: "Follow the macOS prompts." }), + } as Response; + } + return { + ok: true, + json: async () => ({ ...SETTINGS, platform: "macos", allowed_programs: [] }), + } as Response; + }), + ); + + render(); + expect((await screen.findByText("Allowed programs")).parentElement?.textContent).toContain( + "Select an installed .app.", + ); + fireEvent.click(screen.getByRole("button", { name: "Add program…" })); + expect(await screen.findByText("Pages")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Set up permissions…" })); + await waitFor(() => expect(calls.some((call) => call.includes("/permissions"))).toBe(true)); + expect(await screen.findByText("Follow the macOS prompts.")).toBeTruthy(); + }); }); diff --git a/surfaces/gui/src/components/ComputerUseSection.tsx b/surfaces/gui/src/components/ComputerUseSection.tsx index a18da92e59..02dc024a7a 100644 --- a/surfaces/gui/src/components/ComputerUseSection.tsx +++ b/surfaces/gui/src/components/ComputerUseSection.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { getComputerUseSettings, + requestComputerUsePermissions, setComputerUseSettings, type ComputerUseProgram, type ComputerUseSettings, @@ -18,7 +19,7 @@ const BTN_BORDERED = const programName = (path: string) => { const filename = path.split(/[\\/]/).pop() || "Program"; - return filename.replace(/\.exe$/i, "") || "Program"; + return filename.replace(/\.(exe|app)$/i, "") || "Program"; }; function ProgramRow({ @@ -66,6 +67,7 @@ export function ComputerUseSection() { const [enabled, setEnabled] = useState(false); const [programs, setPrograms] = useState([]); const [saving, setSaving] = useState(false); + const [granting, setGranting] = useState(false); const [message, setMessage] = useState(""); const apply = (next: ComputerUseSettings) => { @@ -126,13 +128,29 @@ export function ComputerUseSection() { } }; - const supported = settings?.supported ?? platformOS() === "windows"; + const platform = settings?.platform ?? platformOS(); + const supported = settings?.supported ?? (platform === "windows" || platform === "macos"); + const isMac = platform === "macos"; + + const grantPermissions = async () => { + setGranting(true); + setMessage(""); + try { + const result = await requestComputerUsePermissions(); + if (!result.ok) throw new Error(result.error || "Could not open macOS permission setup."); + setMessage(result.message || "Follow the macOS permission prompts."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not open macOS permission setup."); + } finally { + setGranting(false); + } + }; return (
@@ -147,9 +165,19 @@ export function ComputerUseSection() {
{!supported ? (

- Program control is available in the Windows desktop build. + Program control is available in the macOS and Windows desktop builds.

) : null} + {isMac ? ( +
+

+ macOS requires Accessibility and Screen Recording access. Start setup here and approve the system prompts before the first action. +

+ +
+ ) : null}
@@ -157,7 +185,7 @@ export function ComputerUseSection() {
Allowed programs

- Select an installed .exe. Command interpreters and other programs that could bypass this boundary are rejected. + {isMac ? "Select an installed .app." : "Select an installed .exe."} Command interpreters and other programs that could bypass this boundary are rejected.